351 lines
14 KiB
C#
351 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// KIS (한국투자증권) Open API 클라이언트.
|
|
/// 조회(read-only) 전용. 주문 API는 절대 호출하지 않음.
|
|
/// </summary>
|
|
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<KisApiClient> _logger;
|
|
private static readonly ConcurrentDictionary<string, SemaphoreSlim> TokenLocks = new();
|
|
private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1);
|
|
private DateTime _lastRequestTime = DateTime.MinValue;
|
|
|
|
public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> 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<Dictionary<string, object>> GetCurrentPriceAsync(string code, string account = "mock")
|
|
{
|
|
return await SendRequestAsync(
|
|
account,
|
|
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
|
"FHKST01010100",
|
|
new Dictionary<string, string>
|
|
{
|
|
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
|
{ "FID_INPUT_ISCD", code }
|
|
}
|
|
);
|
|
}
|
|
|
|
public async Task<Dictionary<string, object>> GetAskingPrice10LevelAsync(string code, string account = "mock")
|
|
{
|
|
return await SendRequestAsync(
|
|
account,
|
|
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
|
|
"FHKST01010200",
|
|
new Dictionary<string, string>
|
|
{
|
|
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
|
{ "FID_INPUT_ISCD", code }
|
|
}
|
|
);
|
|
}
|
|
|
|
public async Task<Dictionary<string, object>> 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<string, string>
|
|
{
|
|
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
|
{ "FID_INPUT_ISCD", code },
|
|
{ "FID_INPUT_DATE_1", startDate },
|
|
{ "FID_INPUT_DATE_2", endDate }
|
|
}
|
|
);
|
|
}
|
|
|
|
public async Task<Dictionary<string, object>> 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<string, string>
|
|
{
|
|
{ "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<Dictionary<string, object>> GetInvestorTrendAsync(string code, string account = "mock")
|
|
{
|
|
return await SendRequestAsync(
|
|
account,
|
|
"/uapi/domestic-stock/v1/quotations/inquire-investor",
|
|
"FHKST01010900",
|
|
new Dictionary<string, string>
|
|
{
|
|
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
|
{ "FID_INPUT_ISCD", code }
|
|
}
|
|
);
|
|
}
|
|
|
|
private async Task<Dictionary<string, object>> SendRequestAsync(
|
|
string account,
|
|
string path,
|
|
string trId,
|
|
Dictionary<string, string> parameters)
|
|
{
|
|
AssertReadOnly(path, trId);
|
|
|
|
var creds = KisCredentials.Load(account);
|
|
var token = await GetOrRefreshTokenAsync(creds);
|
|
|
|
var headers = new Dictionary<string, string>
|
|
{
|
|
{ "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<Dictionary<string, object>>();
|
|
return result ?? new Dictionary<string, object>();
|
|
}
|
|
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<string> 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<Dictionary<string, object>>();
|
|
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();
|
|
}
|
|
}
|
|
}
|