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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -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")]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
930d2761d13a35c4440ddf7633edaa4c1f2424cf64d2d3e7777d3bad3db490e2
|
||||
e7c350baac109d1911ad834a377d673266deb4edf4fd2e63153edc74440843e2
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -3,12 +3,40 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace QuantEngine.Core.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// KIS Open API 클라이언트 (read-only 전용).
|
||||
/// 매수/매도 주문은 절대 금지 (governance/rules/06_no_direct_api_trading.yaml).
|
||||
/// </summary>
|
||||
public interface IKisApiClient
|
||||
{
|
||||
Task<string> GetCurrentPriceAsync(string code);
|
||||
Task<string> GetAskingPrice10LevelAsync(string code);
|
||||
Task<string> GetDailyShortSaleAsync(string code, string startDate, string endDate);
|
||||
Task<string> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D");
|
||||
Task<string> GetInvestorTrendAsync(string code);
|
||||
/// <summary>
|
||||
/// 주식현재가 시세 조회.
|
||||
/// TR_ID: FHKST01010100
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object>> GetCurrentPriceAsync(string code, string account = "mock");
|
||||
|
||||
/// <summary>
|
||||
/// 주식현재가 호가/예상체결 (10단계).
|
||||
/// TR_ID: FHKST01010200
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object>> GetAskingPrice10LevelAsync(string code, string account = "mock");
|
||||
|
||||
/// <summary>
|
||||
/// 국내주식 공매도 일별추이.
|
||||
/// TR_ID: FHPST04830000
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object>> GetDailyShortSaleAsync(string code, string startDate, string endDate, string account = "mock");
|
||||
|
||||
/// <summary>
|
||||
/// 주식현재가 일자별 차트.
|
||||
/// TR_ID: FHKST03010100
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object>> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D", string account = "mock");
|
||||
|
||||
/// <summary>
|
||||
/// 주식현재가 투자자 매매동향 (개인/외국인/기관).
|
||||
/// TR_ID: FHKST01010900
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object>> GetInvestorTrendAsync(string code, string account = "mock");
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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")]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
a275438f4b4df0f8d54e6834eea46c8f83eabbb1cf21ee0533f06d867e49ec68
|
||||
a42f144c7048f344f7d30f4862120ef236c2b6773e17f93afe31b500b0ce422d
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
79145411294c3e36a015e6e3a0e89de48f8827bccbb71741b1505491550e55a3
|
||||
b49c624a74a19d171e6b45c0e42dc7f77445eb8fdde390082a56dd78ecd8c3b8
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// KIS (한국투자증권) Open API 클라이언트.
|
||||
/// 조회(read-only) 전용. 주문 API는 절대 호출하지 않음.
|
||||
/// </summary>
|
||||
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<KisApiClient> _logger;
|
||||
|
||||
public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> 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<Dictionary<string, object>> GetCurrentPriceAsync(string code, string account = "mock")
|
||||
{
|
||||
return await SendRequestAsync(
|
||||
account,
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"FHKST01010100",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetAskingPrice10LevelAsync(string code, string account = "mock")
|
||||
{
|
||||
return await SendRequestAsync(
|
||||
account,
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
|
||||
"FHKST01010200",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> 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<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code },
|
||||
{ "FID_INPUT_DATE_1", startDate },
|
||||
{ "FID_INPUT_DATE_2", endDate }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> 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<string, string>
|
||||
{
|
||||
{ "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<Dictionary<string, object>> GetInvestorTrendAsync(string code, string account = "mock")
|
||||
{
|
||||
return await SendRequestAsync(
|
||||
account,
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-investor",
|
||||
"FHKST01010900",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, object>> SendRequestAsync(
|
||||
string account,
|
||||
string path,
|
||||
string trId,
|
||||
Dictionary<string, string> parameters)
|
||||
{
|
||||
AssertReadOnly(path, trId);
|
||||
|
||||
var creds = KisCredentials.Load(account);
|
||||
var token = await GetOrRefreshTokenAsync(creds);
|
||||
|
||||
var headers = new Dictionary<string, string>
|
||||
{
|
||||
{ "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<Dictionary<string, object>>();
|
||||
return result ?? new Dictionary<string, object>();
|
||||
}
|
||||
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<string> 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<Dictionary<string, object>>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string?> GetCachedTokenAsync(string account)
|
||||
{
|
||||
await EnsureTableAsync();
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
|
||||
var token = await conn.QueryFirstOrDefaultAsync<dynamic>(@"
|
||||
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);
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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")]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
81874ee42dc1cd987327edd0297aee8e6b316cf668043fbbe4613e20c39b86f1
|
||||
d5c5f777488b3948c52768724c22864266e92e3ebbb4d65d72d02b574d714276
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
ef37ffbb87bec2866e799f108f761929231bdf4f3ec552a36b0f7987d485aa40
|
||||
fa897814897a7f9fc8482c16d1fdf5031e7aaa5172cdd1a52e59a54d590109c6
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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")]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
a8375a1d4a5016aeabd712ecbbaf4070437c2a16769ec70b2782c3be7909d221
|
||||
195595b70c86f17cd0f5f22ea89f31d535dcbf947e2dfd120fa4982c4d045443
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<IWorkspaceRepository, WorkspaceRepository>();
|
||||
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
||||
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
||||
builder.Services.AddScoped<HistoryIngestionService>();
|
||||
|
||||
// Collection Pipeline Services
|
||||
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
|
||||
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
||||
builder.Services.AddScoped<IKisApiClient, KisApiClient>();
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -55,6 +62,10 @@ app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user