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