feat(kis): KIS API 클라이언트 .NET 포팅 완료

**구현:**
- IKisApiClient.cs: 완전한 read-only 메서드 인터페이스
  - GetCurrentPriceAsync, GetAskingPrice10LevelAsync
  - GetDailyShortSaleAsync, GetDailyItemChartPriceAsync
  - GetInvestorTrendAsync

- KisApiClient.cs: 완전한 .NET 구현 (kis_api_client_v1.py 포팅)
  - KisCredentials: 환경변수 + Windows 레지스트리 폴백
  - ITokenCache 통합: PostgreSQL 기반 토큰 캐싱
  - AssertReadOnly: 주문 API 차단 (governance/rules/06_no_direct_api_trading.yaml)
  - HttpClient: 비동기 API 호출 + 헤더 관리
  - 모든 quotation 조회 메서드 구현

**보안:**
- FORBIDDEN_PATH_SUBSTRINGS: "/trading/" 경로 차단
- FORBIDDEN_TR_ID_PREFIXES: TTTC/VTTC 주문 TR_ID 차단
- 매수/매도 API 절대 호출 불가 (2차 방어)

**DI 통합:**
- Program.cs: builder.Services.AddScoped<IKisApiClient, KisApiClient>();
- HttpClientFactory 패턴 활용

**다음 단계:**
- PostgresTokenCache 구현
- CollectionRepository PostgreSQL 구현
- Collection 엔드포인트 완성
- Web API 통합 테스트

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 23:15:40 +09:00
parent 66f75d9014
commit c56c9cc903
46 changed files with 921 additions and 15 deletions
@@ -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<List<CollectionRunRecord>> GetRecentRunsAsync(int limit = 20)
{
using var conn = _connectionFactory.CreateConnection();
return (await conn.QueryAsync<CollectionRunRecord>(@"
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<List<CollectionSnapshotRecord>> GetRunSnapshotsAsync(string runId)
{
using var conn = _connectionFactory.CreateConnection();
return (await conn.QueryAsync<CollectionSnapshotRecord>(@"
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<List<CollectionErrorRecord>> GetRunErrorsAsync(string runId, int limit = 50)
{
using var conn = _connectionFactory.CreateConnection();
return (await conn.QueryAsync<CollectionErrorRecord>(@"
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<CollectionDashboardStateRecord> GetDashboardStateAsync()
{
using var conn = _connectionFactory.CreateConnection();
var lastRun = await conn.QueryFirstOrDefaultAsync<CollectionRunRecord>(@"
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<dynamic>(@"
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<CollectionErrorRecord>(@"
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<List<CollectionSnapshotRecord>> GetLatestSnapshotsForTickerAsync(string ticker, int limit = 10)
{
using var conn = _connectionFactory.CreateConnection();
return (await conn.QueryAsync<CollectionSnapshotRecord>(@"
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);
");
}
}
}