e7e7d1470d
- 수정: KisApiClient 메서드 반환 타입 Task<string> → Task<Dictionary<string, object>>로 통일 - 수정: GetOrRefreshTokenAsync ReadAsAsync → ReadFromJsonAsync로 변경 - 수정: PlaceholderKisApiClient IKisApiClient 인터페이스 완전 구현 - 수정: CollectionEndpoints 모든 WithOpenApi() 호출 제거 - 수정: Program.cs using 지시문 순서 재정렬 - 추가: Client/_Imports.razor Fluent UI 컴포넌트 네임스페이스 정의 이제 빌드 성공: QuantEngine.Web 프로젝트 컴파일 완료 (경고 0, 에러 0) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
251 lines
10 KiB
C#
251 lines
10 KiB
C#
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<string> 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<JsonElement>();
|
|
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<string> SendRequestAsync(string path, string trId, Dictionary<string, string> queryParams)
|
|
{
|
|
AssertReadOnly(path, trId);
|
|
var token = await IssueOrReuseTokenAsync();
|
|
|
|
var queryBuilder = new List<string>();
|
|
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<Dictionary<string, object>> GetCurrentPriceAsync(string code, string account = "mock")
|
|
{
|
|
var json = await SendRequestAsync(
|
|
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
|
"FHKST01010100",
|
|
new Dictionary<string, string>
|
|
{
|
|
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
|
{ "FID_INPUT_ISCD", code }
|
|
}
|
|
);
|
|
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
|
}
|
|
|
|
public async Task<Dictionary<string, object>> GetAskingPrice10LevelAsync(string code, string account = "mock")
|
|
{
|
|
var json = await SendRequestAsync(
|
|
"/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 }
|
|
}
|
|
);
|
|
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
|
}
|
|
|
|
public async Task<Dictionary<string, object>> 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<string, string>
|
|
{
|
|
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
|
{ "FID_INPUT_ISCD", code },
|
|
{ "FID_INPUT_DATE_1", startDate },
|
|
{ "FID_INPUT_DATE_2", endDate }
|
|
}
|
|
);
|
|
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
|
}
|
|
|
|
public async Task<Dictionary<string, object>> 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<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" }
|
|
}
|
|
);
|
|
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
|
}
|
|
|
|
public async Task<Dictionary<string, object>> GetInvestorTrendAsync(string code, string account = "mock")
|
|
{
|
|
var json = await SendRequestAsync(
|
|
"/uapi/domestic-stock/v1/quotations/inquire-investor",
|
|
"FHKST01010900",
|
|
new Dictionary<string, string>
|
|
{
|
|
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
|
{ "FID_INPUT_ISCD", code }
|
|
}
|
|
);
|
|
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
|
}
|
|
}
|
|
}
|