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

This commit is contained in:
2026-06-25 15:52:10 +09:00
parent 9abb8d3bc3
commit 2ba8def9bb
232 changed files with 10825 additions and 65 deletions
@@ -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 }
}
);
}
}
}
@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Infrastructure.External
{
public class NaverFinanceScraper : INaverFinanceScraper
{
private readonly HttpClient _httpClient;
private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
private const string Referer = "https://finance.naver.com/";
public NaverFinanceScraper(HttpClient httpClient)
{
_httpClient = httpClient;
// Register CodePages encoding provider to support EUC-KR
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
private double CleanNumber(string text)
{
if (string.IsNullOrWhiteSpace(text)) return 0.0;
var cleaned = text.Replace(",", "").Replace("+", "").Replace("%", "").Trim();
return double.TryParse(cleaned, out var result) ? result : 0.0;
}
public async Task<string> FetchPriceHistoryAsync(string code, int pages = 3)
{
var rows = new List<Dictionary<string, object>>();
var eucKr = Encoding.GetEncoding("euc-kr");
for (int page = 1; page <= pages; page++)
{
var url = $"https://finance.naver.com/item/sise_day.naver?code={code}&page={page}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("User-Agent", UserAgent);
request.Headers.Add("Referer", Referer);
var response = await _httpClient.SendAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
return JsonSerializer.Serialize(new
{
status = "CLOUDFLARE_BLOCKED_403",
rows = new List<object>(),
error = "Cloudflare rejected request (403 Forbidden)",
source_url = url
});
}
response.EnsureSuccessStatusCode();
var bytes = await response.Content.ReadAsByteArrayAsync();
var html = eucKr.GetString(bytes);
// Simple regex parser for table rows
var trMatches = Regex.Matches(html, @"<tr[^>]*>([\s\S]*?)<\/tr>");
foreach (Match trMatch in trMatches)
{
var trContent = trMatch.Groups[1].Value;
var tdMatches = Regex.Matches(trContent, @"<td[^>]*>([\s\S]*?)<\/td>");
if (tdMatches.Count == 7)
{
var dateText = Regex.Replace(tdMatches[0].Groups[1].Value, @"<[^>]*>", "").Trim();
if (string.IsNullOrEmpty(dateText) || !dateText.Contains(".")) continue;
rows.Add(new Dictionary<string, object>
{
{ "date", dateText.Replace(".", "-") },
{ "close", CleanNumber(Regex.Replace(tdMatches[1].Groups[1].Value, @"<[^>]*>", "")) },
{ "open", CleanNumber(Regex.Replace(tdMatches[3].Groups[1].Value, @"<[^>]*>", "")) },
{ "high", CleanNumber(Regex.Replace(tdMatches[4].Groups[1].Value, @"<[^>]*>", "")) },
{ "low", CleanNumber(Regex.Replace(tdMatches[5].Groups[1].Value, @"<[^>]*>", "")) },
{ "volume", CleanNumber(Regex.Replace(tdMatches[6].Groups[1].Value, @"<[^>]*>", "")) }
});
}
}
}
if (rows.Count == 0)
{
return JsonSerializer.Serialize(new { status = "DATA_MISSING", rows = new List<object>(), source_url = Referer });
}
return JsonSerializer.Serialize(new
{
status = "OK",
rows = rows,
source_url = $"https://finance.naver.com/item/sise_day.naver?code={code}",
source_as_of = DateTime.UtcNow.AddHours(9).ToString("o")
});
}
public async Task<string> FetchForeignInstitutionFlowAsync(string code, int pages = 2)
{
var rows = new List<Dictionary<string, object>>();
var eucKr = Encoding.GetEncoding("euc-kr");
for (int page = 1; page <= pages; page++)
{
var url = $"https://finance.naver.com/item/frgn.naver?code={code}&page={page}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("User-Agent", UserAgent);
request.Headers.Add("Referer", Referer);
var response = await _httpClient.SendAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
return JsonSerializer.Serialize(new
{
status = "CLOUDFLARE_BLOCKED_403",
rows = new List<object>(),
error = "Cloudflare rejected request (403 Forbidden)",
source_url = url
});
}
response.EnsureSuccessStatusCode();
var bytes = await response.Content.ReadAsByteArrayAsync();
var html = eucKr.GetString(bytes);
var trMatches = Regex.Matches(html, @"<tr[^>]*>([\s\S]*?)<\/tr>");
foreach (Match trMatch in trMatches)
{
var trContent = trMatch.Groups[1].Value;
var tdMatches = Regex.Matches(trContent, @"<td[^>]*>([\s\S]*?)<\/td>");
if (tdMatches.Count >= 8)
{
var dateText = Regex.Replace(tdMatches[0].Groups[1].Value, @"<[^>]*>", "").Trim();
if (string.IsNullOrEmpty(dateText) || !dateText.Contains(".")) continue;
rows.Add(new Dictionary<string, object>
{
{ "date", dateText.Replace(".", "-") },
{ "close", CleanNumber(Regex.Replace(tdMatches[1].Groups[1].Value, @"<[^>]*>", "")) },
{ "inst_net", CleanNumber(Regex.Replace(tdMatches[5].Groups[1].Value, @"<[^>]*>", "")) },
{ "frgn_net", CleanNumber(Regex.Replace(tdMatches[6].Groups[1].Value, @"<[^>]*>", "")) }
});
}
}
}
if (rows.Count == 0)
{
return JsonSerializer.Serialize(new { status = "DATA_MISSING", rows = new List<object>() });
}
return JsonSerializer.Serialize(new
{
status = "OK",
rows = rows,
source_url = $"https://finance.naver.com/item/frgn.naver?code={code}",
source_as_of = DateTime.UtcNow.AddHours(9).ToString("o")
});
}
}
}
@@ -0,0 +1,30 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Infrastructure.External
{
public class YahooFinanceClient : IYahooFinanceClient
{
private readonly HttpClient _httpClient;
private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
public YahooFinanceClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> FetchHistoricalDataAsync(string symbol, string range = "4mo", string interval = "1d")
{
var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range={range}&interval={interval}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("User-Agent", UserAgent);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
}