using System; using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Threading.Tasks; using Dapper; using QuantEngine.Core.Interfaces; using QuantEngine.Infrastructure.Data; namespace QuantEngine.Infrastructure.External { public class KisCredentials { public string AppKey { get; } public string AppSecret { get; } public string Account { get; } // "real" | "mock" public string Domain { get; } public KisCredentials(string appKey, string appSecret, string account) { AppKey = appKey; AppSecret = appSecret; Account = account; Domain = account == "real" ? "https://openapi.koreainvestment.com:9443" : "https://openapivts.koreainvestment.com:29443"; } public static KisCredentials Load(string account = "mock") { string keyVar = account == "real" ? "KIS_APP_Key" : "KIS_APP_Key_TEST"; string secretVar = account == "real" ? "KIS_APP_Secret" : "KIS_APP_Secret_TEST"; string? appKey = Environment.GetEnvironmentVariable(keyVar); string? appSecret = Environment.GetEnvironmentVariable(secretVar); if (string.IsNullOrEmpty(appKey) || string.IsNullOrEmpty(appSecret)) { // Fallback registry checks are not cross-platform and environment variables should be defined. // In production/Linux it is env-only. throw new InvalidOperationException( $"KIS Credentials Environment Variables missing: {keyVar} or {secretVar}." ); } return new KisCredentials(appKey, appSecret, account); } } public class KisApiClient : IKisApiClient { private readonly HttpClient _httpClient; private readonly IDbConnectionFactory _dbConnectionFactory; private readonly KisCredentials _creds; private static readonly string[] ForbiddenPathSubstrings = { "/trading/" }; private static readonly string[] ForbiddenTrIdPrefixes = { "TTTC08", "VTTC08", "TTTC01", "VTTC01", "TTTC8434R", "VTTC8434R" }; public KisApiClient(HttpClient httpClient, IDbConnectionFactory dbConnectionFactory, string account = "mock") { _httpClient = httpClient; _dbConnectionFactory = dbConnectionFactory; _creds = KisCredentials.Load(account); } private void AssertReadOnly(string path, string trId) { foreach (var forbidden in ForbiddenPathSubstrings) { if (path.Contains(forbidden, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"BLOCKED: 주문 관련 경로 호출 시도 차단 — path={path}"); } } foreach (var prefix in ForbiddenTrIdPrefixes) { if (trId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"BLOCKED: 주문 관련 TR_ID 호출 시도 차단 — tr_id={trId}"); } } } private async Task IssueOrReuseTokenAsync() { using var conn = _dbConnectionFactory.CreateConnection(); conn.Open(); // 1. Try to load cached token var cached = await conn.QueryFirstOrDefaultAsync<(string access_token, string expires_at)>( "SELECT access_token, expires_at FROM quantengine.kis_tokens WHERE account = @Account", new { Account = _creds.Account } ); if (cached.access_token != null) { if (DateTime.TryParse(cached.expires_at, out var expiresAtUtc)) { // Reuse token if it has more than 10 minutes left before expiration if (DateTime.UtcNow < expiresAtUtc.AddMinutes(-10)) { return cached.access_token; } } } // 2. Request new token from KIS API var requestUrl = $"{_creds.Domain}/oauth2/tokenP"; var requestBody = new { grant_type = "client_credentials", appkey = _creds.AppKey, appsecret = _creds.AppSecret }; var response = await _httpClient.PostAsJsonAsync(requestUrl, requestBody); response.EnsureSuccessStatusCode(); var resData = await response.Content.ReadFromJsonAsync(); var accessToken = resData.GetProperty("access_token").GetString() ?? throw new InvalidOperationException("Failed to parse access_token from response."); var expiresInSec = resData.GetProperty("expires_in").GetInt32(); var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec); // 3. Upsert token cache into PG database await conn.ExecuteAsync(@" INSERT INTO quantengine.kis_tokens (account, access_token, expires_at, updated_at) VALUES (@Account, @AccessToken, @ExpiresAt, @UpdatedAt) ON CONFLICT (account) DO UPDATE SET access_token = EXCLUDED.access_token, expires_at = EXCLUDED.expires_at, updated_at = EXCLUDED.updated_at", new { Account = _creds.Account, AccessToken = accessToken, ExpiresAt = expiresAt.ToString("o"), UpdatedAt = DateTime.UtcNow.ToString("o") } ); return accessToken; } private async Task SendRequestAsync(string path, string trId, Dictionary queryParams) { AssertReadOnly(path, trId); var token = await IssueOrReuseTokenAsync(); var queryBuilder = new List(); foreach (var kvp in queryParams) { queryBuilder.Add($"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"); } var fullUrl = $"{_creds.Domain}{path}?{string.Join("&", queryBuilder)}"; using var request = new HttpRequestMessage(HttpMethod.Get, fullUrl); request.Headers.Accept.Clear(); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Add("authorization", $"Bearer {token}"); request.Headers.Add("appkey", _creds.AppKey); request.Headers.Add("appsecret", _creds.AppSecret); request.Headers.Add("tr_id", trId); request.Headers.Add("custtype", "P"); var response = await _httpClient.SendAsync(request); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); } public async Task> GetCurrentPriceAsync(string code, string account = "mock") { var json = await SendRequestAsync( "/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100", new Dictionary { { "FID_COND_MRKT_DIV_CODE", "J" }, { "FID_INPUT_ISCD", code } } ); return JsonSerializer.Deserialize>(json) ?? new(); } public async Task> GetAskingPrice10LevelAsync(string code, string account = "mock") { var json = await SendRequestAsync( "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", "FHKST01010200", new Dictionary { { "FID_COND_MRKT_DIV_CODE", "J" }, { "FID_INPUT_ISCD", code } } ); return JsonSerializer.Deserialize>(json) ?? new(); } public async Task> GetDailyShortSaleAsync(string code, string startDate, string endDate, string account = "mock") { var json = await SendRequestAsync( "/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 } } ); return JsonSerializer.Deserialize>(json) ?? new(); } public async Task> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D", string account = "mock") { var json = await SendRequestAsync( "/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" } } ); return JsonSerializer.Deserialize>(json) ?? new(); } public async Task> GetInvestorTrendAsync(string code, string account = "mock") { var json = await SendRequestAsync( "/uapi/domestic-stock/v1/quotations/inquire-investor", "FHKST01010900", new Dictionary { { "FID_COND_MRKT_DIV_CODE", "J" }, { "FID_INPUT_ISCD", code } } ); return JsonSerializer.Deserialize>(json) ?? new(); } } }