feat(dotnet): migrate core formulas, deploy tools, and blazor admin web app to .NET 10
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Has been cancelled
Quant Engine CI/CD Pipeline / validate-core (pull_request) Has been cancelled
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been cancelled
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Has been cancelled
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Has been cancelled
Quant Engine CI/CD Pipeline / validate-core (pull_request) Has been cancelled
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been cancelled
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
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 Task<string> GetCurrentPriceAsync(string code)
|
||||
{
|
||||
return SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"FHKST01010100",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public Task<string> GetAskingPrice10LevelAsync(string code)
|
||||
{
|
||||
return 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 }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public Task<string> GetDailyShortSaleAsync(string code, string startDate, string endDate)
|
||||
{
|
||||
return 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 }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public Task<string> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D")
|
||||
{
|
||||
return 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" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public Task<string> GetInvestorTrendAsync(string code)
|
||||
{
|
||||
return SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-investor",
|
||||
"FHKST01010900",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user