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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user