feat(kis): KIS API 클라이언트 .NET 포팅 완료

**구현:**
- IKisApiClient.cs: 완전한 read-only 메서드 인터페이스
  - GetCurrentPriceAsync, GetAskingPrice10LevelAsync
  - GetDailyShortSaleAsync, GetDailyItemChartPriceAsync
  - GetInvestorTrendAsync

- KisApiClient.cs: 완전한 .NET 구현 (kis_api_client_v1.py 포팅)
  - KisCredentials: 환경변수 + Windows 레지스트리 폴백
  - ITokenCache 통합: PostgreSQL 기반 토큰 캐싱
  - AssertReadOnly: 주문 API 차단 (governance/rules/06_no_direct_api_trading.yaml)
  - HttpClient: 비동기 API 호출 + 헤더 관리
  - 모든 quotation 조회 메서드 구현

**보안:**
- FORBIDDEN_PATH_SUBSTRINGS: "/trading/" 경로 차단
- FORBIDDEN_TR_ID_PREFIXES: TTTC/VTTC 주문 TR_ID 차단
- 매수/매도 API 절대 호출 불가 (2차 방어)

**DI 통합:**
- Program.cs: builder.Services.AddScoped<IKisApiClient, KisApiClient>();
- HttpClientFactory 패턴 활용

**다음 단계:**
- PostgresTokenCache 구현
- CollectionRepository PostgreSQL 구현
- Collection 엔드포인트 완성
- Web API 통합 테스트

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 23:15:40 +09:00
parent 66f75d9014
commit c56c9cc903
46 changed files with 921 additions and 15 deletions
@@ -0,0 +1,270 @@
using System;
using System.Collections.Generic;
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;
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}";
try
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in headers)
request.Headers.Add(header.Key, header.Value);
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)
{
_logger.LogError(ex, "KIS request failed: {Path} / {TrId}", path, trId);
throw new InvalidOperationException($"KIS read-only request failed for {path} / {trId}.", ex);
}
}
private async Task<string> GetOrRefreshTokenAsync(KisCredentials creds)
{
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 };
try
{
var response = await _httpClient.PostAsJsonAsync(
$"{creds.Domain}/oauth2/tokenP",
tokenRequest
);
response.EnsureSuccessStatusCode();
var tokenData = await response.Content.ReadAsAsync<Dictionary<string, object>>();
var accessToken = tokenData["access_token"]?.ToString() ?? throw new InvalidOperationException("No access_token in response");
var expiresInStr = tokenData.ContainsKey("expires_in") ? tokenData["expires_in"]?.ToString() : "86400";
var expiresInSec = int.TryParse(expiresInStr, out var seconds) ? seconds : 86400;
var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec);
await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt);
return accessToken;
}
catch (Exception ex)
{
_logger.LogError(ex, "KIS token refresh failed");
throw new InvalidOperationException("KIS token refresh failed; check credentials and API availability.", ex);
}
}
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;
}
}
}
@@ -0,0 +1,93 @@
using System;
using System.Threading.Tasks;
using Dapper;
using QuantEngine.Core.Interfaces;
using QuantEngine.Infrastructure.Data;
namespace QuantEngine.Infrastructure.Services
{
public class PostgresTokenCache : ITokenCache
{
private readonly IDbConnectionFactory _connectionFactory;
private static readonly int TokenRefreshSkewMinutes = 10;
public PostgresTokenCache(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<string?> GetCachedTokenAsync(string account)
{
await EnsureTableAsync();
using var conn = _connectionFactory.CreateConnection();
var token = await conn.QueryFirstOrDefaultAsync<dynamic>(@"
SELECT access_token as AccessToken, expires_at as ExpiresAt
FROM quantengine.kis_tokens
WHERE account = @Account",
new { Account = account }
);
if (token == null)
return null;
var expiresAt = DateTime.Parse(token.ExpiresAt);
var now = DateTime.UtcNow;
var refreshSkew = TimeSpan.FromMinutes(TokenRefreshSkewMinutes);
// Return token only if it expires more than refresh skew from now
if (expiresAt > now.Add(refreshSkew))
return token.AccessToken;
return null;
}
public async Task SaveTokenAsync(string account, string token, DateTime expiresAt)
{
await EnsureTableAsync();
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(@"
INSERT INTO quantengine.kis_tokens (account, access_token, expires_at, updated_at)
VALUES (@Account, @Token, @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 = account,
Token = token,
ExpiresAt = expiresAt.ToString("o"),
UpdatedAt = DateTime.UtcNow.ToString("o")
}
);
}
public async Task ClearExpiredTokensAsync()
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(@"
DELETE FROM quantengine.kis_tokens
WHERE expires_at < @Now",
new { Now = DateTime.UtcNow.ToString("o") }
);
}
private async Task EnsureTableAsync()
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS quantengine.kis_tokens (
account TEXT PRIMARY KEY,
access_token TEXT NOT NULL,
expires_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_kis_tokens_expires_at ON quantengine.kis_tokens(expires_at);
");
}
}
}