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);
}
}