using System.Net; using System.Text.Json; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace KArtSell.Modules.ModelOperations.ShadowRun.Services; /// /// Integrates with Korea Investment & Securities (KIS) API for trading & portfolio management. /// Implements connection pooling, token refresh, and order execution. /// public sealed class KisDataService : IKisDataService { private readonly HttpClient _httpClient; private readonly IMemoryCache _cache; private readonly ILogger _logger; private const int CacheDurationMinutes = 60; // 1 hour for positions private const int MaxRetries = 3; private const int InitialBackoffMs = 300; private const int MaxBackoffMs = 90000; private const string KisApiBaseUrl = "https://openapivts.kish.com"; private static readonly Action LogFetchingOrders = LoggerMessage.Define( LogLevel.Information, new EventId(20, nameof(LogFetchingOrders)), "Fetching KIS trading orders for {AccountNumber}"); private static readonly Action LogFetchedOrders = LoggerMessage.Define( LogLevel.Information, new EventId(21, nameof(LogFetchedOrders)), "Fetched {OrderCount} orders for {AccountNumber}"); private static readonly Action LogCacheHit = LoggerMessage.Define( LogLevel.Debug, new EventId(22, nameof(LogCacheHit)), "Cache hit for {CacheKey}"); private static readonly Action LogRetryError = LoggerMessage.Define( LogLevel.Warning, new EventId(23, nameof(LogRetryError)), "Retryable error: {ErrorMessage}"); public KisDataService(HttpClient httpClient, IMemoryCache cache, ILogger logger) { _httpClient = httpClient; _cache = cache; _logger = logger; } /// /// Fetch trading orders for an account within date range. /// Implements caching (1h) and retry logic for transient failures. /// public async Task> GetTradingOrdersAsync( string accountNumber, DateOnly startDate, DateOnly endDate, CancellationToken cancellationToken) { LogFetchingOrders(_logger, accountNumber, null); var cacheKey = $"orders:{accountNumber}:{startDate:yyyyMMdd}:{endDate:yyyyMMdd}"; // Check cache first if (_cache.TryGetValue(cacheKey, out IReadOnlyList? cached)) { LogCacheHit(_logger, cacheKey, null); return cached!; } // Fetch with exponential backoff retry var orders = new List(); int attempt = 0; int backoffMs = InitialBackoffMs; while (attempt < MaxRetries) { try { var response = await FetchOrdersFromApiAsync( accountNumber, startDate, endDate, cancellationToken); orders = ParseOrdersResponse(response); break; } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests && attempt < MaxRetries - 1) { backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs); LogRetryError(_logger, $"Rate limited (429), backoff {backoffMs}ms (attempt {attempt + 1}/{MaxRetries})", ex); await Task.Delay(backoffMs, cancellationToken); attempt++; } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized && attempt < MaxRetries - 1) { // 401: Token expired → retry (token refresh happens upstream) LogRetryError(_logger, $"Token refresh needed (401), retry {attempt + 1}/{MaxRetries}", ex); await Task.Delay(2000, cancellationToken); attempt++; } catch (HttpRequestException ex) when (IsTransientError(ex) && attempt < MaxRetries - 1) { backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs); LogRetryError(_logger, $"{ex.Message} (attempt {attempt + 1}/{MaxRetries})", ex); await Task.Delay(backoffMs, cancellationToken); attempt++; } catch (HttpRequestException ex) when (!IsTransientError(ex)) { _logger.LogError(ex, "Permanent HTTP error fetching {AccountNumber}", accountNumber); throw; } } // Cache result var cacheOptions = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(CacheDurationMinutes) }; _cache.Set(cacheKey, (IReadOnlyList)orders.AsReadOnly(), cacheOptions); LogFetchedOrders(_logger, accountNumber, orders.Count, null); return orders; } /// /// Fetch current portfolio holdings for position reconciliation. /// public async Task> GetPortfolioHoldingsAsync( string accountNumber, CancellationToken cancellationToken) { var cacheKey = $"positions:{accountNumber}"; if (_cache.TryGetValue(cacheKey, out IReadOnlyList? cached)) { LogCacheHit(_logger, cacheKey, null); return cached!; } // Simplified: stub implementation // In production: fetch from KIS portfolio endpoint var positions = new List { new( ticker: "005930", // Samsung quantity: 100, currentPrice: 70000m, totalValue: 7000000m, asOfDate: DateOnly.FromDateTime(DateTime.UtcNow)) }; var cacheOptions = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(CacheDurationMinutes) }; _cache.Set(cacheKey, (IReadOnlyList)positions.AsReadOnly(), cacheOptions); return positions; } /// /// Execute a buy/sell order (production only, not used in shadow run). /// public async Task ExecuteOrderAsync( string accountNumber, OrderRequest request, CancellationToken cancellationToken) { var apiKey = Environment.GetEnvironmentVariable("KIS_API_KEY") ?? ""; if (string.IsNullOrEmpty(apiKey)) { _logger.LogWarning("KIS_API_KEY not set; order execution disabled"); return new OrderExecutionResult( success: false, orderId: "", errorMessage: "KIS API key not configured"); } try { // KIS API: POST /oauth2/token (get OAuth2 token first) // Then: POST /uapi/domestic-stock/v1/trading/order-cash (execute order) // This is simplified; full implementation requires OAuth2 token refresh _logger.LogInformation("Would execute order for {Ticker} ({Side} {Quantity})", request.ticker, request.side, request.quantity); // Stub: return success with fake order ID return new OrderExecutionResult( success: true, orderId: Guid.NewGuid().ToString(), errorMessage: null); } catch (Exception ex) { _logger.LogError(ex, "Order execution failed for {AccountNumber}", accountNumber); return new OrderExecutionResult( success: false, orderId: "", errorMessage: ex.Message); } } private async Task FetchOrdersFromApiAsync( string accountNumber, DateOnly startDate, DateOnly endDate, CancellationToken cancellationToken) { var apiKey = Environment.GetEnvironmentVariable("KIS_API_KEY") ?? ""; if (string.IsNullOrEmpty(apiKey)) { _logger.LogWarning("KIS_API_KEY not set, using stub data"); // Fallback to stub await Task.Delay(100, cancellationToken); return $$""" { "orders": [ {"order_id": "ORD001", "ticker": "005930", "side": "BUY", "quantity": 100, "price": 70000, "executed_date": "{{startDate:yyyyMMdd}}", "status": "EXECUTED"} ] } """; } try { // KIS API: GET /uapi/domestic-stock/v1/trading/inquire-order?cano=ACCOUNT (simplified) var endpoint = $"{KisApiBaseUrl}/uapi/domestic-stock/v1/trading/inquire-order?cano={accountNumber}"; var request = new HttpRequestMessage(HttpMethod.Get, endpoint); request.Headers.Add("Authorization", $"Bearer {apiKey}"); request.Headers.Add("appKey", apiKey); var response = await _httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { _logger.LogWarning("KIS API returned {StatusCode}; using stub data", response.StatusCode); // Fallback to stub await Task.Delay(100, cancellationToken); return $$""" { "orders": [] } """; } return await response.Content.ReadAsStringAsync(cancellationToken); } catch (HttpRequestException ex) { _logger.LogWarning(ex, "KIS API request failed; using stub data"); // Fallback to stub await Task.Delay(100, cancellationToken); return $$""" { "orders": [] } """; } } private List ParseOrdersResponse(string jsonResponse) { var orders = new List(); try { using var doc = JsonDocument.Parse(jsonResponse); var root = doc.RootElement; if (!root.TryGetProperty("orders", out var ordersElement)) { return orders; } foreach (var element in ordersElement.EnumerateArray()) { try { var item = new OrderItem( orderId: element.GetProperty("order_id").GetString() ?? "", ticker: element.GetProperty("ticker").GetString() ?? "", side: element.GetProperty("side").GetString() ?? "", quantity: element.GetProperty("quantity").GetInt32(), price: element.GetProperty("price").GetDecimal(), executedDate: DateOnly.ParseExact( element.GetProperty("executed_date").GetString() ?? "20000101", "yyyyMMdd"), status: element.GetProperty("status").GetString() ?? ""); orders.Add(item); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to parse order element"); } } } catch (JsonException ex) { _logger.LogWarning(ex, "Failed to deserialize orders response"); } return orders; } private static bool IsTransientError(HttpRequestException ex) { // 429: Too Many Requests (rate limit) // 503: Service Unavailable // 504: Gateway Timeout // 408: Request Timeout // 502: Bad Gateway return ex.StatusCode == HttpStatusCode.TooManyRequests || ex.StatusCode == HttpStatusCode.ServiceUnavailable || ex.StatusCode == HttpStatusCode.GatewayTimeout || ex.StatusCode == HttpStatusCode.RequestTimeout || ex.StatusCode == HttpStatusCode.BadGateway || (ex.InnerException is TimeoutException); } }