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,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<IResult> GetCollectionState(ICollectionRepository repo)
{
try
{
var state = await repo.GetDashboardStateAsync();
return Results.Ok(state);
}
catch (Exception ex)
{
return Results.StatusCode(500);
}
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> StartCollectionRun(ICollectionRepository repo, ILogger<Program> 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);
}
}
}