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,125 @@
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Web.Infrastructure;
/// <summary>
/// Placeholder implementations for Collection services.
/// Temporary: to be replaced with actual PostgreSQL implementations.
/// </summary>
public class PlaceholderCollectionRepository : ICollectionRepository
{
private static readonly List<CollectionRunRecord> MockRuns = new();
private static readonly List<CollectionSnapshotRecord> MockSnapshots = new();
private static readonly List<CollectionErrorRecord> 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<List<CollectionRunRecord>> GetRecentRunsAsync(int limit = 20)
{
return Task.FromResult(MockRuns.OrderByDescending(r => r.StartedAt).Take(limit).ToList());
}
public Task<List<CollectionSnapshotRecord>> GetRunSnapshotsAsync(string runId)
{
return Task.FromResult(MockSnapshots.Where(s => s.RunId == runId).ToList());
}
public Task<List<CollectionErrorRecord>> GetRunErrorsAsync(string runId, int limit = 50)
{
return Task.FromResult(MockErrors.Where(e => e.RunId == runId).Take(limit).ToList());
}
public Task<CollectionDashboardStateRecord> 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<List<CollectionSnapshotRecord>> 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<string, (string Token, DateTime ExpiresAt)> Cache = new();
public Task<string?> GetCachedTokenAsync(string account)
{
if (Cache.TryGetValue(account, out var entry) && entry.ExpiresAt > DateTime.UtcNow.AddMinutes(10))
{
return Task.FromResult<string?>(entry.Token);
}
return Task.FromResult<string?>(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<string?> GetAccessTokenAsync(string account = "mock")
{
return Task.FromResult<string?>("placeholder_token");
}
public Task<dynamic?> GetQuotationAsync(string ticker, string account = "mock")
{
return Task.FromResult<dynamic?>(null);
}
public Task<dynamic?> GetRankingAsync(string sort = "price", int limit = 10, string account = "mock")
{
return Task.FromResult<dynamic?>(null);
}
}