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);