diff --git a/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Application.dll b/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Application.dll index eaee181..da0d1d4 100644 Binary files a/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Application.dll and b/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Application.dll differ diff --git a/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Application.pdb b/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Application.pdb index db50324..ebff989 100644 Binary files a/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Application.pdb and b/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Application.pdb differ diff --git a/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Core.dll b/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Core.dll index c8c1b16..3e18d19 100644 Binary files a/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Core.dll and b/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Core.dll differ diff --git a/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Core.pdb b/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Core.pdb index 9bf9f94..439c7e0 100644 Binary files a/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Core.pdb and b/src/dotnet/QuantEngine.Application/bin/Debug/net10.0/QuantEngine.Core.pdb differ diff --git a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.AssemblyInfo.cs b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.AssemblyInfo.cs index 85a4478..a0fd5f5 100644 --- a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.AssemblyInfo.cs +++ b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("QuantEngine.Application")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+aad4788e8430ad7244d0628047aaf40d0590ef95")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+66f75d90147cc79aede830c90dd0b6fefe5dce0b")] [assembly: System.Reflection.AssemblyProductAttribute("QuantEngine.Application")] [assembly: System.Reflection.AssemblyTitleAttribute("QuantEngine.Application")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.AssemblyInfoInputs.cache b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.AssemblyInfoInputs.cache index 98b2033..b229291 100644 --- a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.AssemblyInfoInputs.cache +++ b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.AssemblyInfoInputs.cache @@ -1 +1 @@ -930d2761d13a35c4440ddf7633edaa4c1f2424cf64d2d3e7777d3bad3db490e2 +e7c350baac109d1911ad834a377d673266deb4edf4fd2e63153edc74440843e2 diff --git a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.csproj.AssemblyReference.cache b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.csproj.AssemblyReference.cache index 7fece8a..5e092d8 100644 Binary files a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.csproj.AssemblyReference.cache and b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.csproj.AssemblyReference.cache differ diff --git a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.dll b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.dll index eaee181..da0d1d4 100644 Binary files a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.dll and b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.dll differ diff --git a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.pdb b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.pdb index db50324..ebff989 100644 Binary files a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.pdb and b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/QuantEngine.Application.pdb differ diff --git a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/ref/QuantEngine.Application.dll b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/ref/QuantEngine.Application.dll index 2285bea..9f7b195 100644 Binary files a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/ref/QuantEngine.Application.dll and b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/ref/QuantEngine.Application.dll differ diff --git a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/refint/QuantEngine.Application.dll b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/refint/QuantEngine.Application.dll index 2285bea..9f7b195 100644 Binary files a/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/refint/QuantEngine.Application.dll and b/src/dotnet/QuantEngine.Application/obj/Debug/net10.0/refint/QuantEngine.Application.dll differ diff --git a/src/dotnet/QuantEngine.Core/Interfaces/IKisApiClient.cs b/src/dotnet/QuantEngine.Core/Interfaces/IKisApiClient.cs index 971a10f..593aa6b 100644 --- a/src/dotnet/QuantEngine.Core/Interfaces/IKisApiClient.cs +++ b/src/dotnet/QuantEngine.Core/Interfaces/IKisApiClient.cs @@ -3,12 +3,40 @@ using System.Threading.Tasks; namespace QuantEngine.Core.Interfaces { + /// + /// KIS Open API 클라이언트 (read-only 전용). + /// 매수/매도 주문은 절대 금지 (governance/rules/06_no_direct_api_trading.yaml). + /// public interface IKisApiClient { - Task GetCurrentPriceAsync(string code); - Task GetAskingPrice10LevelAsync(string code); - Task GetDailyShortSaleAsync(string code, string startDate, string endDate); - Task GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D"); - Task GetInvestorTrendAsync(string code); + /// + /// 주식현재가 시세 조회. + /// TR_ID: FHKST01010100 + /// + Task> GetCurrentPriceAsync(string code, string account = "mock"); + + /// + /// 주식현재가 호가/예상체결 (10단계). + /// TR_ID: FHKST01010200 + /// + Task> GetAskingPrice10LevelAsync(string code, string account = "mock"); + + /// + /// 국내주식 공매도 일별추이. + /// TR_ID: FHPST04830000 + /// + Task> GetDailyShortSaleAsync(string code, string startDate, string endDate, string account = "mock"); + + /// + /// 주식현재가 일자별 차트. + /// TR_ID: FHKST03010100 + /// + Task> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D", string account = "mock"); + + /// + /// 주식현재가 투자자 매매동향 (개인/외국인/기관). + /// TR_ID: FHKST01010900 + /// + Task> GetInvestorTrendAsync(string code, string account = "mock"); } } diff --git a/src/dotnet/QuantEngine.Core/bin/Debug/net10.0/QuantEngine.Core.dll b/src/dotnet/QuantEngine.Core/bin/Debug/net10.0/QuantEngine.Core.dll index c8c1b16..3e18d19 100644 Binary files a/src/dotnet/QuantEngine.Core/bin/Debug/net10.0/QuantEngine.Core.dll and b/src/dotnet/QuantEngine.Core/bin/Debug/net10.0/QuantEngine.Core.dll differ diff --git a/src/dotnet/QuantEngine.Core/bin/Debug/net10.0/QuantEngine.Core.pdb b/src/dotnet/QuantEngine.Core/bin/Debug/net10.0/QuantEngine.Core.pdb index 9bf9f94..439c7e0 100644 Binary files a/src/dotnet/QuantEngine.Core/bin/Debug/net10.0/QuantEngine.Core.pdb and b/src/dotnet/QuantEngine.Core/bin/Debug/net10.0/QuantEngine.Core.pdb differ diff --git a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.AssemblyInfo.cs b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.AssemblyInfo.cs index 2a2c1c5..12290bf 100644 --- a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.AssemblyInfo.cs +++ b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("QuantEngine.Core")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+aad4788e8430ad7244d0628047aaf40d0590ef95")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+66f75d90147cc79aede830c90dd0b6fefe5dce0b")] [assembly: System.Reflection.AssemblyProductAttribute("QuantEngine.Core")] [assembly: System.Reflection.AssemblyTitleAttribute("QuantEngine.Core")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.AssemblyInfoInputs.cache b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.AssemblyInfoInputs.cache index a0a341f..e7a345e 100644 --- a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.AssemblyInfoInputs.cache +++ b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.AssemblyInfoInputs.cache @@ -1 +1 @@ -a275438f4b4df0f8d54e6834eea46c8f83eabbb1cf21ee0533f06d867e49ec68 +a42f144c7048f344f7d30f4862120ef236c2b6773e17f93afe31b500b0ce422d diff --git a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.csproj.CoreCompileInputs.cache b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.csproj.CoreCompileInputs.cache index f9384aa..5892960 100644 --- a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.csproj.CoreCompileInputs.cache +++ b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.csproj.CoreCompileInputs.cache @@ -1 +1 @@ -79145411294c3e36a015e6e3a0e89de48f8827bccbb71741b1505491550e55a3 +b49c624a74a19d171e6b45c0e42dc7f77445eb8fdde390082a56dd78ecd8c3b8 diff --git a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.dll b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.dll index c8c1b16..3e18d19 100644 Binary files a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.dll and b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.dll differ diff --git a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.pdb b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.pdb index 9bf9f94..439c7e0 100644 Binary files a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.pdb and b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/QuantEngine.Core.pdb differ diff --git a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/ref/QuantEngine.Core.dll b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/ref/QuantEngine.Core.dll index 6c62557..4919998 100644 Binary files a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/ref/QuantEngine.Core.dll and b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/ref/QuantEngine.Core.dll differ diff --git a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/refint/QuantEngine.Core.dll b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/refint/QuantEngine.Core.dll index 6c62557..4919998 100644 Binary files a/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/refint/QuantEngine.Core.dll and b/src/dotnet/QuantEngine.Core/obj/Debug/net10.0/refint/QuantEngine.Core.dll differ diff --git a/src/dotnet/QuantEngine.Infrastructure/Repositories/CollectionRepository.cs b/src/dotnet/QuantEngine.Infrastructure/Repositories/CollectionRepository.cs new file mode 100644 index 0000000..f414a57 --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Repositories/CollectionRepository.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using QuantEngine.Core.Interfaces; +using QuantEngine.Infrastructure.Data; + +namespace QuantEngine.Infrastructure.Repositories +{ + public class CollectionRepository : ICollectionRepository + { + private readonly IDbConnectionFactory _connectionFactory; + + public CollectionRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task SaveRunAsync(CollectionRunRecord run) + { + await EnsureTablesAsync(); + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync(@" + INSERT INTO quantengine.kis_collection_runs (run_id, status, started_at, finished_at, total_snapshots, total_errors, updated_at) + VALUES (@RunId, @Status, @StartedAt, @FinishedAt, @TotalSnapshots, @TotalErrors, @UpdatedAt) + ON CONFLICT (run_id) DO UPDATE SET + status = EXCLUDED.status, + finished_at = EXCLUDED.finished_at, + total_snapshots = EXCLUDED.total_snapshots, + total_errors = EXCLUDED.total_errors, + updated_at = EXCLUDED.updated_at", + run + ); + } + + public async Task UpdateRunStatusAsync(string runId, string status, string? finishedAt = null, int? totalSnapshots = null, int? totalErrors = null) + { + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync(@" + UPDATE quantengine.kis_collection_runs + SET status = @Status, finished_at = @FinishedAt, total_snapshots = @TotalSnapshots, total_errors = @TotalErrors, updated_at = @UpdatedAt + WHERE run_id = @RunId", + new { RunId = runId, Status = status, FinishedAt = finishedAt, TotalSnapshots = totalSnapshots, TotalErrors = totalErrors, UpdatedAt = DateTime.UtcNow.ToString("o") } + ); + } + + public async Task SaveSnapshotAsync(CollectionSnapshotRecord snapshot) + { + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync(@" + INSERT INTO quantengine.kis_collection_snapshots (run_id, dataset_name, ticker, source_name, payload_json, captured_at, created_at) + VALUES (@RunId, @DatasetName, @Ticker, @SourceName, @PayloadJson, @CapturedAt, @CreatedAt) + ON CONFLICT (run_id, ticker, source_name) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + captured_at = EXCLUDED.captured_at", + snapshot + ); + } + + public async Task SaveErrorAsync(CollectionErrorRecord error) + { + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync(@" + INSERT INTO quantengine.kis_collection_errors (run_id, source_name, error_kind, error_message, ticker, created_at) + VALUES (@RunId, @SourceName, @ErrorKind, @ErrorMessage, @Ticker, @CreatedAt)", + error + ); + } + + public async Task> GetRecentRunsAsync(int limit = 20) + { + using var conn = _connectionFactory.CreateConnection(); + return (await conn.QueryAsync(@" + SELECT run_id as RunId, status, started_at as StartedAt, finished_at as FinishedAt, + total_snapshots as TotalSnapshots, total_errors as TotalErrors, updated_at as UpdatedAt + FROM quantengine.kis_collection_runs + ORDER BY started_at DESC + LIMIT @Limit", + new { Limit = limit } + )).ToList(); + } + + public async Task> GetRunSnapshotsAsync(string runId) + { + using var conn = _connectionFactory.CreateConnection(); + return (await conn.QueryAsync(@" + SELECT run_id as RunId, dataset_name as DatasetName, ticker, source_name as SourceName, + payload_json as PayloadJson, captured_at as CapturedAt, created_at as CreatedAt + FROM quantengine.kis_collection_snapshots + WHERE run_id = @RunId + ORDER BY captured_at DESC", + new { RunId = runId } + )).ToList(); + } + + public async Task> GetRunErrorsAsync(string runId, int limit = 50) + { + using var conn = _connectionFactory.CreateConnection(); + return (await conn.QueryAsync(@" + SELECT run_id as RunId, source_name as SourceName, error_kind as ErrorKind, + error_message as ErrorMessage, ticker as Ticker, created_at as CreatedAt + FROM quantengine.kis_collection_errors + WHERE run_id = @RunId + ORDER BY created_at DESC + LIMIT @Limit", + new { RunId = runId, Limit = limit } + )).ToList(); + } + + public async Task GetDashboardStateAsync() + { + using var conn = _connectionFactory.CreateConnection(); + + var lastRun = await conn.QueryFirstOrDefaultAsync(@" + SELECT run_id as RunId, status, started_at as StartedAt, finished_at as FinishedAt, + total_snapshots as TotalSnapshots, total_errors as TotalErrors, updated_at as UpdatedAt + FROM quantengine.kis_collection_runs + ORDER BY started_at DESC + LIMIT 1"); + + var stats = await conn.QueryFirstOrDefaultAsync(@" + SELECT + COALESCE(SUM(total_snapshots), 0) as TotalSnapshots, + COALESCE(SUM(total_errors), 0) as TotalErrors + FROM quantengine.kis_collection_runs"); + + var recentErrors = (await conn.QueryAsync(@" + SELECT run_id as RunId, source_name as SourceName, error_kind as ErrorKind, + error_message as ErrorMessage, ticker as Ticker, created_at as CreatedAt + FROM quantengine.kis_collection_errors + ORDER BY created_at DESC + LIMIT 5")).ToList(); + + return new CollectionDashboardStateRecord( + LastRunId: lastRun?.RunId, + LastRunStatus: lastRun?.Status, + LastFinishedAt: lastRun?.FinishedAt, + TotalSnapshots: stats?.TotalSnapshots ?? 0, + TotalErrors: stats?.TotalErrors ?? 0, + RecentErrors: recentErrors + ); + } + + public async Task> GetLatestSnapshotsForTickerAsync(string ticker, int limit = 10) + { + using var conn = _connectionFactory.CreateConnection(); + return (await conn.QueryAsync(@" + SELECT run_id as RunId, dataset_name as DatasetName, ticker, source_name as SourceName, + payload_json as PayloadJson, captured_at as CapturedAt, created_at as CreatedAt + FROM quantengine.kis_collection_snapshots + WHERE ticker = @Ticker + ORDER BY captured_at DESC + LIMIT @Limit", + new { Ticker = ticker, Limit = limit } + )).ToList(); + } + + private async Task EnsureTablesAsync() + { + using var conn = _connectionFactory.CreateConnection(); + await conn.ExecuteAsync(@" + CREATE TABLE IF NOT EXISTS quantengine.kis_collection_runs ( + run_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + total_snapshots INTEGER, + total_errors INTEGER, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS quantengine.kis_collection_snapshots ( + run_id TEXT NOT NULL, + dataset_name TEXT, + ticker TEXT NOT NULL, + source_name TEXT NOT NULL, + payload_json TEXT NOT NULL, + captured_at TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (run_id, ticker, source_name) + ); + + CREATE TABLE IF NOT EXISTS quantengine.kis_collection_errors ( + id SERIAL PRIMARY KEY, + run_id TEXT NOT NULL, + source_name TEXT NOT NULL, + error_kind TEXT NOT NULL, + error_message TEXT, + ticker TEXT, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_kis_runs_started_at ON quantengine.kis_collection_runs(started_at DESC); + CREATE INDEX IF NOT EXISTS idx_kis_snapshots_ticker ON quantengine.kis_collection_snapshots(ticker); + CREATE INDEX IF NOT EXISTS idx_kis_snapshots_captured_at ON quantengine.kis_collection_snapshots(captured_at DESC); + CREATE INDEX IF NOT EXISTS idx_kis_errors_run_id ON quantengine.kis_collection_errors(run_id); + "); + } + } +} diff --git a/src/dotnet/QuantEngine.Infrastructure/Services/KisApiClient.cs b/src/dotnet/QuantEngine.Infrastructure/Services/KisApiClient.cs new file mode 100644 index 0000000..955c3d2 --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Services/KisApiClient.cs @@ -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; + +/// +/// KIS (한국투자증권) Open API 클라이언트. +/// 조회(read-only) 전용. 주문 API는 절대 호출하지 않음. +/// +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 _logger; + + public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger 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> GetCurrentPriceAsync(string code, string account = "mock") + { + return await SendRequestAsync( + account, + "/uapi/domestic-stock/v1/quotations/inquire-price", + "FHKST01010100", + new Dictionary + { + { "FID_COND_MRKT_DIV_CODE", "J" }, + { "FID_INPUT_ISCD", code } + } + ); + } + + public async Task> GetAskingPrice10LevelAsync(string code, string account = "mock") + { + return await SendRequestAsync( + account, + "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", + "FHKST01010200", + new Dictionary + { + { "FID_COND_MRKT_DIV_CODE", "J" }, + { "FID_INPUT_ISCD", code } + } + ); + } + + public async Task> 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 + { + { "FID_COND_MRKT_DIV_CODE", "J" }, + { "FID_INPUT_ISCD", code }, + { "FID_INPUT_DATE_1", startDate }, + { "FID_INPUT_DATE_2", endDate } + } + ); + } + + public async Task> 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 + { + { "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> GetInvestorTrendAsync(string code, string account = "mock") + { + return await SendRequestAsync( + account, + "/uapi/domestic-stock/v1/quotations/inquire-investor", + "FHKST01010900", + new Dictionary + { + { "FID_COND_MRKT_DIV_CODE", "J" }, + { "FID_INPUT_ISCD", code } + } + ); + } + + private async Task> SendRequestAsync( + string account, + string path, + string trId, + Dictionary parameters) + { + AssertReadOnly(path, trId); + + var creds = KisCredentials.Load(account); + var token = await GetOrRefreshTokenAsync(creds); + + var headers = new Dictionary + { + { "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>(); + return result ?? new Dictionary(); + } + 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 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>(); + 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; + } + } +} diff --git a/src/dotnet/QuantEngine.Infrastructure/Services/PostgresTokenCache.cs b/src/dotnet/QuantEngine.Infrastructure/Services/PostgresTokenCache.cs new file mode 100644 index 0000000..392eeb4 --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Services/PostgresTokenCache.cs @@ -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 GetCachedTokenAsync(string account) + { + await EnsureTableAsync(); + using var conn = _connectionFactory.CreateConnection(); + + var token = await conn.QueryFirstOrDefaultAsync(@" + 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); + "); + } + } +} diff --git a/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.AssemblyInfo.cs b/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.AssemblyInfo.cs index 727340f..ab60bc0 100644 --- a/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.AssemblyInfo.cs +++ b/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("QuantEngine.Infrastructure")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+aad4788e8430ad7244d0628047aaf40d0590ef95")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+66f75d90147cc79aede830c90dd0b6fefe5dce0b")] [assembly: System.Reflection.AssemblyProductAttribute("QuantEngine.Infrastructure")] [assembly: System.Reflection.AssemblyTitleAttribute("QuantEngine.Infrastructure")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.AssemblyInfoInputs.cache b/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.AssemblyInfoInputs.cache index 3f353ec..fcb5271 100644 --- a/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.AssemblyInfoInputs.cache +++ b/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.AssemblyInfoInputs.cache @@ -1 +1 @@ -81874ee42dc1cd987327edd0297aee8e6b316cf668043fbbe4613e20c39b86f1 +d5c5f777488b3948c52768724c22864266e92e3ebbb4d65d72d02b574d714276 diff --git a/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.csproj.AssemblyReference.cache b/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.csproj.AssemblyReference.cache index 9b4aea5..0760408 100644 Binary files a/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.csproj.AssemblyReference.cache and b/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.csproj.AssemblyReference.cache differ diff --git a/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.csproj.CoreCompileInputs.cache b/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.csproj.CoreCompileInputs.cache index bf85891..9eec20c 100644 --- a/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.csproj.CoreCompileInputs.cache +++ b/src/dotnet/QuantEngine.Infrastructure/obj/Debug/net10.0/QuantEngine.Infrastructure.csproj.CoreCompileInputs.cache @@ -1 +1 @@ -ef37ffbb87bec2866e799f108f761929231bdf4f3ec552a36b0f7987d485aa40 +fa897814897a7f9fc8482c16d1fdf5031e7aaa5172cdd1a52e59a54d590109c6 diff --git a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Application.dll b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Application.dll index eaee181..da0d1d4 100644 Binary files a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Application.dll and b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Application.dll differ diff --git a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Application.pdb b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Application.pdb index db50324..ebff989 100644 Binary files a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Application.pdb and b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Application.pdb differ diff --git a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Core.dll b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Core.dll index c8c1b16..3e18d19 100644 Binary files a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Core.dll and b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Core.dll differ diff --git a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Core.pdb b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Core.pdb index 9bf9f94..439c7e0 100644 Binary files a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Core.pdb and b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Core.pdb differ diff --git a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.dll b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.dll index 37c8e1a..2175817 100644 Binary files a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.dll and b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.dll differ diff --git a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.exe b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.exe index 8e65c93..01902f7 100644 Binary files a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.exe and b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.exe differ diff --git a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.pdb b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.pdb index 295cccc..35f8c21 100644 Binary files a/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.pdb and b/src/dotnet/QuantEngine.Tools/bin/Debug/net10.0/QuantEngine.Tools.pdb differ diff --git a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.AssemblyInfo.cs b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.AssemblyInfo.cs index 401a6e6..66f719a 100644 --- a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.AssemblyInfo.cs +++ b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("QuantEngine.Tools")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+aad4788e8430ad7244d0628047aaf40d0590ef95")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+66f75d90147cc79aede830c90dd0b6fefe5dce0b")] [assembly: System.Reflection.AssemblyProductAttribute("QuantEngine.Tools")] [assembly: System.Reflection.AssemblyTitleAttribute("QuantEngine.Tools")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.AssemblyInfoInputs.cache b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.AssemblyInfoInputs.cache index 5fffd3f..13df2bc 100644 --- a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.AssemblyInfoInputs.cache +++ b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.AssemblyInfoInputs.cache @@ -1 +1 @@ -a8375a1d4a5016aeabd712ecbbaf4070437c2a16769ec70b2782c3be7909d221 +195595b70c86f17cd0f5f22ea89f31d535dcbf947e2dfd120fa4982c4d045443 diff --git a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.csproj.AssemblyReference.cache b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.csproj.AssemblyReference.cache index c0f1e41..56da1bd 100644 Binary files a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.csproj.AssemblyReference.cache and b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.csproj.AssemblyReference.cache differ diff --git a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.dll b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.dll index 37c8e1a..2175817 100644 Binary files a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.dll and b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.dll differ diff --git a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.pdb b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.pdb index 295cccc..35f8c21 100644 Binary files a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.pdb and b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/QuantEngine.Tools.pdb differ diff --git a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/apphost.exe b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/apphost.exe index 8e65c93..01902f7 100644 Binary files a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/apphost.exe and b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/apphost.exe differ diff --git a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/ref/QuantEngine.Tools.dll b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/ref/QuantEngine.Tools.dll index 3018ca9..350f6b8 100644 Binary files a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/ref/QuantEngine.Tools.dll and b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/ref/QuantEngine.Tools.dll differ diff --git a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/refint/QuantEngine.Tools.dll b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/refint/QuantEngine.Tools.dll index 3018ca9..350f6b8 100644 Binary files a/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/refint/QuantEngine.Tools.dll and b/src/dotnet/QuantEngine.Tools/obj/Debug/net10.0/refint/QuantEngine.Tools.dll differ diff --git a/src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs b/src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs new file mode 100644 index 0000000..443ca0a --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs @@ -0,0 +1,178 @@ +using QuantEngine.Core.Interfaces; +using System.Diagnostics; + +namespace QuantEngine.Web.Endpoints; + +public static class CollectionEndpoints +{ + public static void MapCollectionEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/collection") + .WithName("Collection") + .WithOpenApi(); + + group.MapGet("/state", GetCollectionState) + .WithName("GetCollectionState") + .WithOpenApi() + .Produces(200) + .Produces(500); + + group.MapGet("/runs", GetRecentRuns) + .WithName("GetRecentRuns") + .WithOpenApi() + .Produces(200) + .Produces(500); + + group.MapGet("/runs/{runId}/snapshots", GetRunSnapshots) + .WithName("GetRunSnapshots") + .WithOpenApi() + .Produces(200) + .Produces(404) + .Produces(500); + + group.MapGet("/runs/{runId}/errors", GetRunErrors) + .WithName("GetRunErrors") + .WithOpenApi() + .Produces(200) + .Produces(404) + .Produces(500); + + group.MapGet("/latest/{ticker}", GetLatestSnapshotsForTicker) + .WithName("GetLatestSnapshotsForTicker") + .WithOpenApi() + .Produces(200) + .Produces(500); + + group.MapPost("/run", StartCollectionRun) + .WithName("StartCollectionRun") + .WithOpenApi() + .Produces(202) + .Produces(500); + } + + private static async Task GetCollectionState(ICollectionRepository repo) + { + try + { + var state = await repo.GetDashboardStateAsync(); + return Results.Ok(state); + } + catch (Exception ex) + { + return Results.StatusCode(500); + } + } + + private static async Task GetRecentRuns(ICollectionRepository repo, int limit = 20) + { + try + { + var runs = await repo.GetRecentRunsAsync(limit); + return Results.Ok(new { runs, count = runs.Count }); + } + catch (Exception ex) + { + return Results.StatusCode(500); + } + } + + private static async Task GetRunSnapshots(string runId, ICollectionRepository repo) + { + try + { + var snapshots = await repo.GetRunSnapshotsAsync(runId); + return Results.Ok(new { runId, snapshots, count = snapshots.Count }); + } + catch (Exception ex) + { + return Results.StatusCode(500); + } + } + + private static async Task GetRunErrors(string runId, ICollectionRepository repo, int limit = 50) + { + try + { + var errors = await repo.GetRunErrorsAsync(runId, limit); + return Results.Ok(new { runId, errors, count = errors.Count }); + } + catch (Exception ex) + { + return Results.StatusCode(500); + } + } + + private static async Task GetLatestSnapshotsForTicker(string ticker, ICollectionRepository repo, int limit = 10) + { + try + { + var snapshots = await repo.GetLatestSnapshotsForTickerAsync(ticker, limit); + return Results.Ok(new { ticker, snapshots, count = snapshots.Count }); + } + catch (Exception ex) + { + return Results.StatusCode(500); + } + } + + private static async Task StartCollectionRun(ICollectionRepository repo, ILogger logger) + { + try + { + var runId = Guid.NewGuid().ToString("N"); + var now = DateTime.UtcNow.ToString("o"); + + var run = new CollectionRunRecord( + RunId: runId, + Status: "running", + StartedAt: now, + FinishedAt: null, + TotalSnapshots: null, + TotalErrors: null, + UpdatedAt: now + ); + + await repo.SaveRunAsync(run); + + // Temp: Invoke Python subprocess for actual collection + _ = Task.Run(async () => + { + try + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "python", + Arguments = "tools/run_kis_data_collection_v1.py --input-json GatherTradingData.json --sqlite-db src/quant_engine/kis_data_collection.db --kis-account real", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + await repo.UpdateRunStatusAsync(runId, "completed", DateTime.UtcNow.ToString("o"), 0, 0); + } + catch (Exception ex) + { + logger.LogError(ex, $"Collection run {runId} failed"); + await repo.UpdateRunStatusAsync(runId, "failed", DateTime.UtcNow.ToString("o"), null, null); + } + }); + + return Results.Accepted($"/api/collection/runs/{runId}", new + { + runId, + status = "running", + startedAt = now + }); + } + catch (Exception ex) + { + return Results.StatusCode(500); + } + } +} diff --git a/src/dotnet/QuantEngine.Web/Infrastructure/PlaceholderImplementations.cs b/src/dotnet/QuantEngine.Web/Infrastructure/PlaceholderImplementations.cs new file mode 100644 index 0000000..727642a --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Infrastructure/PlaceholderImplementations.cs @@ -0,0 +1,125 @@ +using QuantEngine.Core.Interfaces; + +namespace QuantEngine.Web.Infrastructure; + +/// +/// Placeholder implementations for Collection services. +/// Temporary: to be replaced with actual PostgreSQL implementations. +/// + +public class PlaceholderCollectionRepository : ICollectionRepository +{ + private static readonly List MockRuns = new(); + private static readonly List MockSnapshots = new(); + private static readonly List MockErrors = new(); + + public Task SaveRunAsync(CollectionRunRecord run) + { + MockRuns.Add(run); + return Task.CompletedTask; + } + + public Task UpdateRunStatusAsync(string runId, string status, string? finishedAt = null, int? totalSnapshots = null, int? totalErrors = null) + { + var run = MockRuns.FirstOrDefault(r => r.RunId == runId); + if (run != null) + { + var idx = MockRuns.IndexOf(run); + MockRuns[idx] = new CollectionRunRecord(runId, status, run.StartedAt, finishedAt, totalSnapshots, totalErrors, DateTime.UtcNow.ToString("o")); + } + return Task.CompletedTask; + } + + public Task SaveSnapshotAsync(CollectionSnapshotRecord snapshot) + { + MockSnapshots.Add(snapshot); + return Task.CompletedTask; + } + + public Task SaveErrorAsync(CollectionErrorRecord error) + { + MockErrors.Add(error); + return Task.CompletedTask; + } + + public Task> GetRecentRunsAsync(int limit = 20) + { + return Task.FromResult(MockRuns.OrderByDescending(r => r.StartedAt).Take(limit).ToList()); + } + + public Task> GetRunSnapshotsAsync(string runId) + { + return Task.FromResult(MockSnapshots.Where(s => s.RunId == runId).ToList()); + } + + public Task> GetRunErrorsAsync(string runId, int limit = 50) + { + return Task.FromResult(MockErrors.Where(e => e.RunId == runId).Take(limit).ToList()); + } + + public Task GetDashboardStateAsync() + { + var lastRun = MockRuns.OrderByDescending(r => r.StartedAt).FirstOrDefault(); + var recentErrors = MockErrors.OrderByDescending(e => e.CreatedAt).Take(5).ToList(); + + return Task.FromResult(new CollectionDashboardStateRecord( + LastRunId: lastRun?.RunId, + LastRunStatus: lastRun?.Status, + LastFinishedAt: lastRun?.FinishedAt, + TotalSnapshots: MockSnapshots.Count, + TotalErrors: MockErrors.Count, + RecentErrors: recentErrors + )); + } + + public Task> GetLatestSnapshotsForTickerAsync(string ticker, int limit = 10) + { + return Task.FromResult(MockSnapshots.Where(s => s.Ticker == ticker).OrderByDescending(s => s.CapturedAt).Take(limit).ToList()); + } +} + +public class PlaceholderTokenCache : ITokenCache +{ + private static readonly Dictionary Cache = new(); + + public Task GetCachedTokenAsync(string account) + { + if (Cache.TryGetValue(account, out var entry) && entry.ExpiresAt > DateTime.UtcNow.AddMinutes(10)) + { + return Task.FromResult(entry.Token); + } + return Task.FromResult(null); + } + + public Task SaveTokenAsync(string account, string token, DateTime expiresAt) + { + Cache[account] = (token, expiresAt); + return Task.CompletedTask; + } + + public Task ClearExpiredTokensAsync() + { + var expired = Cache.Where(kv => kv.Value.ExpiresAt <= DateTime.UtcNow).Select(kv => kv.Key).ToList(); + foreach (var key in expired) Cache.Remove(key); + return Task.CompletedTask; + } +} + +public class PlaceholderKisApiClient : IKisApiClient +{ + // Placeholder: To be implemented with actual KIS API calls + public Task GetAccessTokenAsync(string account = "mock") + { + return Task.FromResult("placeholder_token"); + } + + public Task GetQuotationAsync(string ticker, string account = "mock") + { + return Task.FromResult(null); + } + + public Task GetRankingAsync(string sort = "price", int limit = 10, string account = "mock") + { + return Task.FromResult(null); + } +} diff --git a/src/dotnet/QuantEngine.Web/Program.cs b/src/dotnet/QuantEngine.Web/Program.cs index a3665bc..80d7303 100644 --- a/src/dotnet/QuantEngine.Web/Program.cs +++ b/src/dotnet/QuantEngine.Web/Program.cs @@ -1,6 +1,7 @@ using QuantEngine.Web.Components; using QuantEngine.Infrastructure.Data; using QuantEngine.Infrastructure.Repositories; +using QuantEngine.Infrastructure.Services; using QuantEngine.Core.Interfaces; using QuantEngine.Application.Services; using System.Text.Json; @@ -33,6 +34,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + +// Collection Pipeline Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddHttpClient(); var app = builder.Build(); @@ -55,6 +62,10 @@ app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); +// Collection API Endpoints +using QuantEngine.Web.Endpoints; +app.MapCollectionEndpoints(); + app.MapGet("/api/history/{domain}", async (string domain, int? limit, IPostgresqlHistorySnapshotReader reader) => { var rows = await reader.ReadAsync(domain, limit ?? 500);