using System; using System.Collections.Generic; using System.Collections.Concurrent; using System.Globalization; using System.Linq; using System.Net.Http; using System.Net.Http.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using QuantEngine.Core.Interfaces; namespace QuantEngine.Infrastructure.Services; /// /// KIS (한국투자증권) Open API 클라이언트. /// 조회(read-only) 전용. 주문 API는 절대 호출하지 않음. /// public class KisApiClient : IKisApiClient { private const string RealDomain = "https://openapi.koreainvestment.com:9443"; private const string MockDomain = "https://openapivts.koreainvestment.com:29443"; private const int TokenRefreshSkewMinutes = 10; private static readonly string[] ForbiddenPathSubstrings = { "/trading/" }; private static readonly string[] ForbiddenTrIdPrefixes = { "TTTC08", "VTTC08", "TTTC01", "VTTC01", "TTTC8434R", "VTTC8434R" }; private readonly HttpClient _httpClient; private readonly ITokenCache _tokenCache; private readonly ILogger _logger; private static readonly ConcurrentDictionary TokenLocks = new(); private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1); private DateTime _lastRequestTime = DateTime.MinValue; public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger logger) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task> GetCurrentPriceAsync(string code, string account = "mock") { return await SendRequestAsync( account, "/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100", new Dictionary { { "FID_COND_MRKT_DIV_CODE", "J" }, { "FID_INPUT_ISCD", code } } ); } public async Task> GetAskingPrice10LevelAsync(string code, string account = "mock") { return await SendRequestAsync( account, "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", "FHKST01010200", new Dictionary { { "FID_COND_MRKT_DIV_CODE", "J" }, { "FID_INPUT_ISCD", code } } ); } public async Task> GetDailyShortSaleAsync(string code, string startDate, string endDate, string account = "mock") { return await SendRequestAsync( account, "/uapi/domestic-stock/v1/quotations/daily-short-sale", "FHPST04830000", new Dictionary { { "FID_COND_MRKT_DIV_CODE", "J" }, { "FID_INPUT_ISCD", code }, { "FID_INPUT_DATE_1", startDate }, { "FID_INPUT_DATE_2", endDate } } ); } public async Task> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D", string account = "mock") { return await SendRequestAsync( account, "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", "FHKST03010100", new Dictionary { { "FID_COND_MRKT_DIV_CODE", "J" }, { "FID_INPUT_ISCD", code }, { "FID_INPUT_DATE_1", startDate }, { "FID_INPUT_DATE_2", endDate }, { "FID_PERIOD_DIV_CODE", period }, { "FID_ORG_ADJ_PRC", "0" } } ); } public async Task> GetInvestorTrendAsync(string code, string account = "mock") { return await SendRequestAsync( account, "/uapi/domestic-stock/v1/quotations/inquire-investor", "FHKST01010900", new Dictionary { { "FID_COND_MRKT_DIV_CODE", "J" }, { "FID_INPUT_ISCD", code } } ); } private async Task> SendRequestAsync( string account, string path, string trId, Dictionary parameters) { AssertReadOnly(path, trId); var creds = KisCredentials.Load(account); var token = await GetOrRefreshTokenAsync(creds); var headers = new Dictionary { { "Authorization", $"Bearer {token}" }, { "appkey", creds.AppKey }, { "appsecret", creds.AppSecret }, { "tr_id", trId }, { "custtype", "P" } }; var url = $"{creds.Domain}{path}"; var queryString = string.Join("&", parameters.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); if (!string.IsNullOrEmpty(queryString)) url += $"?{queryString}"; int maxAttempts = 3; int delayMs = 1000; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { var request = new HttpRequestMessage(HttpMethod.Get, url); foreach (var header in headers) request.Headers.Add(header.Key, header.Value); await ApplyRateLimitDelayAsync(account); var response = await _httpClient.SendAsync(request); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync>(); return result ?? new Dictionary(); } catch (Exception ex) when (attempt < maxAttempts) { _logger.LogWarning(ex, "KIS request failed on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms...", attempt, maxAttempts, delayMs); await Task.Delay(delayMs); delayMs *= 2; } catch (Exception ex) { _logger.LogError(ex, "KIS request failed after {MaxAttempts} attempts: {Path} / {TrId}", maxAttempts, path, trId); throw new InvalidOperationException($"KIS read-only request failed for {path} / {trId} after {maxAttempts} attempts.", ex); } } throw new InvalidOperationException("Unreachable code in KIS client SendRequestAsync"); } private async Task GetOrRefreshTokenAsync(KisCredentials creds) { var tokenLock = TokenLocks.GetOrAdd(creds.Account, _ => new SemaphoreSlim(1, 1)); await tokenLock.WaitAsync(); try { // Re-check after acquiring the account lock. Another request may // have refreshed the shared cache while this request was waiting. var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account); if (!string.IsNullOrEmpty(cachedToken)) return cachedToken; var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret }; int maxAttempts = 3; int delayMs = 1000; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { await ApplyRateLimitDelayAsync(creds.Account); var response = await _httpClient.PostAsJsonAsync( $"{creds.Domain}/oauth2/tokenP", tokenRequest ); response.EnsureSuccessStatusCode(); var tokenData = await response.Content.ReadFromJsonAsync>(); if (tokenData == null) throw new InvalidOperationException("Token response body is empty"); if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null) throw new InvalidOperationException("No access_token in response"); var accessToken = tokenObj.ToString()!; var expiresInStr = tokenData.TryGetValue("expires_in", out var expiresObj) && expiresObj != null ? expiresObj.ToString() : "86400"; var expiresInSec = int.TryParse(expiresInStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) ? seconds : 86400; var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec); await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt); _logger.LogInformation("KIS token refreshed for {Account}; expires at {ExpiresAtUtc}", creds.Account, expiresAt); return accessToken; } catch (Exception ex) when (attempt < maxAttempts) { _logger.LogWarning(ex, "KIS token refresh failed on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms...", attempt, maxAttempts, delayMs); await Task.Delay(delayMs); delayMs *= 2; } catch (Exception ex) { _logger.LogError(ex, "KIS token refresh failed after {MaxAttempts} attempts", maxAttempts); throw new InvalidOperationException($"KIS token refresh failed after {maxAttempts} attempts; check credentials and API availability.", ex); } } throw new InvalidOperationException("Unreachable code in KIS client TokenRefresh"); } finally { tokenLock.Release(); } } private static void AssertReadOnly(string path, string trId) { foreach (var forbidden in ForbiddenPathSubstrings) { if (path.Contains(forbidden, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException( $"BLOCKED: 주문 관련 경로 호출 시도 차단 — path={path}. " + "이 엔진은 매수/매도를 API로 직접 실행하지 않습니다 (governance/rules/06_no_direct_api_trading.yaml)." ); } foreach (var prefix in ForbiddenTrIdPrefixes) { if (trId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException( $"BLOCKED: 주문 관련 TR_ID 호출 시도 차단 — tr_id={trId}. " + "이 엔진은 매수/매도를 API로 직접 실행하지 않습니다 (governance/rules/06_no_direct_api_trading.yaml)." ); } } private class KisCredentials { public string AppKey { get; } public string AppSecret { get; } public string Account { get; } public string Domain { get; } private KisCredentials(string appKey, string appSecret, string account) { AppKey = appKey; AppSecret = appSecret; Account = account; Domain = account == "real" ? RealDomain : MockDomain; } public static KisCredentials Load(string account = "mock") { if (account != "real" && account != "mock") throw new ArgumentException("account must be 'real' or 'mock'"); var (keyName, secretName) = account == "real" ? ("KIS_APP_Key", "KIS_APP_Secret") : ("KIS_APP_Key_TEST", "KIS_APP_Secret_TEST"); var appKey = ReadEnvVar(keyName); var appSecret = ReadEnvVar(secretName); if (string.IsNullOrEmpty(appKey) || string.IsNullOrEmpty(appSecret)) throw new InvalidOperationException( $"{keyName}/{secretName} 환경변수를 찾을 수 없습니다. " + "Windows 환경변수 설정 후 새 셸에서 재시도하거나 HKCU\\Environment 레지스트리를 확인하세요." ); return new KisCredentials(appKey, appSecret, account); } private static string? ReadEnvVar(string name) { var value = Environment.GetEnvironmentVariable(name); if (!string.IsNullOrEmpty(value)) return value; if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) { try { using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey("Environment"); var regValue = key?.GetValue(name) as string; if (!string.IsNullOrEmpty(regValue)) return regValue; } catch { } } return null; } } private async Task ApplyRateLimitDelayAsync(string account) { await _rateLimitSemaphore.WaitAsync(); try { var mode = account.Contains("real", StringComparison.OrdinalIgnoreCase) ? "real" : "mock"; int minIntervalMs = mode == "real" ? 150 : 400; var now = DateTime.UtcNow; var elapsed = (now - _lastRequestTime).TotalMilliseconds; if (elapsed < minIntervalMs) { var delay = minIntervalMs - (int)elapsed; _logger.LogDebug("Rate limit throttling: delaying for {Delay}ms (Mode: {Mode})", delay, mode); await Task.Delay(delay); } _lastRequestTime = DateTime.UtcNow; } finally { _rateLimitSemaphore.Release(); } } }