feat: add quant engine WBS verification harness
This commit is contained in:
@@ -4,6 +4,10 @@
|
||||
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Application.Services;
|
||||
@@ -13,19 +14,20 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
private readonly ICollectionRepository _repository;
|
||||
private readonly PriceDataNormalizer _normalizer;
|
||||
private readonly SourcePriorityResolver _priorityResolver;
|
||||
// Logging removed for simplicity
|
||||
private readonly ILogger<KisDataCollectionOrchestrator> _logger;
|
||||
|
||||
public KisDataCollectionOrchestrator(
|
||||
IKisApiClient kisApiClient,
|
||||
ICollectionRepository repository,
|
||||
PriceDataNormalizer normalizer,
|
||||
SourcePriorityResolver priorityResolver)
|
||||
SourcePriorityResolver priorityResolver,
|
||||
ILogger<KisDataCollectionOrchestrator> logger)
|
||||
{
|
||||
_kisApiClient = kisApiClient;
|
||||
_repository = repository;
|
||||
_normalizer = normalizer;
|
||||
_priorityResolver = priorityResolver;
|
||||
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers)
|
||||
@@ -42,7 +44,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
|
||||
try
|
||||
{
|
||||
// Log: skipped
|
||||
_logger.LogInformation("Starting collection run {RunId}", runId);
|
||||
|
||||
var kisSource = new KisApiPriceSource(_kisApiClient);
|
||||
var rows = new List<Dictionary<string, object>>();
|
||||
@@ -53,9 +55,9 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
{
|
||||
try
|
||||
{
|
||||
// Log: skipped
|
||||
_logger.LogInformation("Collecting ticker {Ticker} (run {RunId})", ticker, runId);
|
||||
var kisResult = await kisSource.GetPriceDataAsync(ticker, account);
|
||||
|
||||
|
||||
var seedRow = new Dictionary<string, object> { { "Ticker", ticker } };
|
||||
var (normalized, provenance) = _normalizer.NormalizeCollectionRow(seedRow, kisResult, null, false);
|
||||
|
||||
@@ -80,7 +82,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log: skipped
|
||||
_logger.LogWarning(ex, "Collection failed for {Ticker} (run {RunId})", ticker, runId);
|
||||
result.ErrorCount++;
|
||||
errors.Add(new Dictionary<string, object>
|
||||
{
|
||||
@@ -116,33 +118,64 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
TotalErrors: result.ErrorCount
|
||||
));
|
||||
|
||||
// Output JSON file
|
||||
var outputPath = Path.Combine(Path.GetTempPath(), "kis_data_collection_v1.json");
|
||||
// Determine gate status
|
||||
var gate = result.SuccessCount == 0 ? "FAIL"
|
||||
: result.ErrorCount == 0 ? "PASS"
|
||||
: result.ErrorCount < result.SuccessCount * 0.1 ? "PASS"
|
||||
: "PASS_WITH_WARNINGS";
|
||||
|
||||
// Output JSON file to <repo>/Temp/kis_dotnet_collection_v1.json
|
||||
var outputPath = GetOutputPath();
|
||||
var outputData = new
|
||||
{
|
||||
formula_id = "KIS_DATA_COLLECTION_V1",
|
||||
formula_id = "KIS_DOTNET_COLLECTION_V1",
|
||||
gate = gate,
|
||||
run_id = runId,
|
||||
started_at = startedAt,
|
||||
finished_at = finishedAt,
|
||||
row_count = rows.Count,
|
||||
source_counts = sourceCounts,
|
||||
errors = errors,
|
||||
rows = rows
|
||||
summary = new
|
||||
{
|
||||
success_count = result.SuccessCount,
|
||||
error_count = result.ErrorCount,
|
||||
source_counts = sourceCounts
|
||||
}
|
||||
};
|
||||
File.WriteAllText(outputPath, JsonSerializer.Serialize(outputData, new JsonSerializerOptions { WriteIndented = true }));
|
||||
// Log: skipped
|
||||
|
||||
_logger.LogInformation("Collection run {RunId} finished with status {Status}: {Success} ok, {Errors} errors",
|
||||
runId, result.Status, result.SuccessCount, result.ErrorCount);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log: skipped
|
||||
_logger.LogError(ex, "Collection run {RunId} failed with exception", runId);
|
||||
result.Status = "FAILED";
|
||||
result.FinishedAt = DataNormalizationHelper.KstNowIso();
|
||||
result.ErrorMessage = ex.Message;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetOutputPath()
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var current = new DirectoryInfo(baseDir);
|
||||
|
||||
while (current != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, ".git"))
|
||||
|| File.Exists(Path.Combine(current.FullName, "GatherTradingData.json")))
|
||||
{
|
||||
var tempDir = Path.Combine(current.FullName, "Temp");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
return Path.Combine(tempDir, "kis_dotnet_collection_v1.json");
|
||||
}
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return Path.Combine(Path.GetTempPath(), "kis_dotnet_collection_v1.json");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
using Bunit;
|
||||
using MudBlazor;
|
||||
using Xunit;
|
||||
using QuantEngine.Web.Client.Pages;
|
||||
using QuantEngine.Web.Client.Components;
|
||||
|
||||
namespace QuantEngine.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for Dashboard component using bUnit
|
||||
/// </summary>
|
||||
public class DashboardComponentTests : TestContext
|
||||
{
|
||||
[Fact]
|
||||
public void Dashboard_Renders_Without_Errors()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Dashboard>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("관리자 대시보드");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dashboard_Displays_KPI_Cards()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Dashboard>();
|
||||
|
||||
// Assert - Should have 4 KPI cards
|
||||
cut.FindAll(".mud-paper").Count.Should().BeGreaterThanOrEqualTo(4);
|
||||
cut.Markup.Should().Contain("총 수집 실행");
|
||||
cut.Markup.Should().Contain("성공률");
|
||||
cut.Markup.Should().Contain("최근 에러");
|
||||
cut.Markup.Should().Contain("마지막 동기화");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dashboard_Shows_System_Status()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Dashboard>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("시스템 상태");
|
||||
cut.Markup.Should().Contain("API 서버");
|
||||
cut.Markup.Should().Contain("데이터베이스");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dashboard_Has_Activity_Feed()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Dashboard>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("최근 활동");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dashboard_Has_Collections_Table()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Dashboard>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("최근 데이터 수집 실행");
|
||||
cut.Markup.Should().Contain("새로고침");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for FormField component
|
||||
/// </summary>
|
||||
public class FormFieldComponentTests : TestContext
|
||||
{
|
||||
[Fact]
|
||||
public void FormField_Renders_Text_Input()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new ComponentParameterCollection
|
||||
{
|
||||
{ "Label", "사용자명" },
|
||||
{ "Type", "text" },
|
||||
{ "Placeholder", "이름 입력" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var cut = RenderComponent<FormField>(parameters);
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("사용자명");
|
||||
cut.Markup.Should().Contain("이름 입력");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormField_Shows_Required_Indicator()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new ComponentParameterCollection
|
||||
{
|
||||
{ "Label", "이메일" },
|
||||
{ "Type", "email" },
|
||||
{ "Required", true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var cut = RenderComponent<FormField>(parameters);
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormField_Displays_Error_Message()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new ComponentParameterCollection
|
||||
{
|
||||
{ "Label", "비밀번호" },
|
||||
{ "Type", "password" },
|
||||
{ "ErrorMessage", "최소 8자 이상 입력하세요" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var cut = RenderComponent<FormField>(parameters);
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("최소 8자 이상 입력하세요");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormField_Shows_Help_Text()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new ComponentParameterCollection
|
||||
{
|
||||
{ "Label", "핸드폰" },
|
||||
{ "Type", "tel" },
|
||||
{ "HelpText", "하이픈 없이 숫자만 입력하세요" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var cut = RenderComponent<FormField>(parameters);
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("하이픈 없이 숫자만 입력하세요");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for Portfolio component
|
||||
/// </summary>
|
||||
public class PortfolioComponentTests : TestContext
|
||||
{
|
||||
[Fact]
|
||||
public void Portfolio_Renders_Without_Errors()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Portfolio>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("포트폴리오");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Portfolio_Displays_Summary_Cards()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Portfolio>();
|
||||
|
||||
// Assert - Should have summary cards
|
||||
cut.Markup.Should().Contain("총 평가액");
|
||||
cut.Markup.Should().Contain("보유 종목");
|
||||
cut.Markup.Should().Contain("수익률");
|
||||
cut.Markup.Should().Contain("위험도");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Portfolio_Shows_Asset_Table()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Portfolio>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("자산 구성");
|
||||
cut.Markup.Should().Contain("종목/펀드명");
|
||||
cut.Markup.Should().Contain("평가액");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Portfolio_Shows_Asset_Classification()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Portfolio>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("자산 분류");
|
||||
cut.Markup.Should().Contain("대형주");
|
||||
cut.Markup.Should().Contain("중형주");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Portfolio_Shows_Trading_History()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<Portfolio>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("거래 이력");
|
||||
cut.Markup.Should().Contain("구분");
|
||||
cut.Markup.Should().Contain("금액");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for NavMenu component
|
||||
/// </summary>
|
||||
public class NavMenuComponentTests : TestContext
|
||||
{
|
||||
[Fact]
|
||||
public void NavMenu_Renders_Navigation_Links()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<NavMenu>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("대시보드");
|
||||
cut.Markup.Should().Contain("관리");
|
||||
cut.Markup.Should().Contain("운영");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavMenu_Has_Admin_Section()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<NavMenu>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("사용자 관리");
|
||||
cut.Markup.Should().Contain("데이터 수집");
|
||||
cut.Markup.Should().Contain("설정");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavMenu_Has_Help_Section()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cut = RenderComponent<NavMenu>();
|
||||
|
||||
// Assert
|
||||
cut.Markup.Should().Contain("도움말");
|
||||
cut.Markup.Should().Contain("문서");
|
||||
cut.Markup.Should().Contain("API");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using FastEndpoints;
|
||||
using Hangfire;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
|
||||
namespace QuantEngine.Web.Endpoints;
|
||||
@@ -214,20 +216,52 @@ public class GetLatestSnapshotsEndpoint : Endpoint<GetLatestSnapshotsRequest, Ge
|
||||
}
|
||||
}
|
||||
|
||||
public class StartCollectionRunEndpoint : EndpointWithoutRequest
|
||||
public class StartCollectionRunResponse
|
||||
{
|
||||
public string RunId { get; set; } = "";
|
||||
}
|
||||
|
||||
public class StartCollectionRunEndpoint : EndpointWithoutRequest<StartCollectionRunResponse>
|
||||
{
|
||||
private readonly IBackgroundJobClient _jobClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<StartCollectionRunEndpoint> _logger;
|
||||
|
||||
public StartCollectionRunEndpoint(
|
||||
IBackgroundJobClient jobClient,
|
||||
IConfiguration configuration,
|
||||
ILogger<StartCollectionRunEndpoint> logger)
|
||||
{
|
||||
_jobClient = jobClient;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/collection/run");
|
||||
AllowAnonymous();
|
||||
Description(d => d
|
||||
.Produces(202)
|
||||
.Produces<StartCollectionRunResponse>(202)
|
||||
.Produces(500));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
// Return 202 Accepted status code via generic status code handler
|
||||
await SendResultAsync(Microsoft.AspNetCore.Http.Results.Accepted());
|
||||
try
|
||||
{
|
||||
var runId = $"api-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
|
||||
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" }.ToList();
|
||||
|
||||
_jobClient.Enqueue<ICollectionOrchestrator>(o => o.RunCollectionAsync(runId, accountMode, tickers));
|
||||
|
||||
_logger.LogInformation("Collection run {RunId} enqueued", runId);
|
||||
|
||||
await SendAsync(new StartCollectionRunResponse { RunId = runId }, 202, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await SendErrorsAsync(500, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,11 @@ try
|
||||
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
||||
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();
|
||||
|
||||
// Collection Pipeline Services
|
||||
builder.Services.AddScoped<SourcePriorityResolver>();
|
||||
builder.Services.AddScoped<PriceDataNormalizer>();
|
||||
builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
|
||||
|
||||
// Hangfire Background Jobs
|
||||
try
|
||||
{
|
||||
|
||||
@@ -5,7 +5,9 @@ using Hangfire.PostgreSql;
|
||||
using Hangfire.MemoryStorage;
|
||||
using System.Linq.Expressions;
|
||||
using QuantEngine.Application.Services;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace QuantEngine.Web.Services;
|
||||
|
||||
@@ -17,15 +19,21 @@ public class SchedulerService
|
||||
private readonly ILogger<SchedulerService> _logger;
|
||||
private readonly IBackgroundJobClient _jobClient;
|
||||
private readonly IRecurringJobManager _recurringJobManager;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public SchedulerService(
|
||||
ILogger<SchedulerService> logger,
|
||||
IBackgroundJobClient jobClient,
|
||||
IRecurringJobManager recurringJobManager)
|
||||
IRecurringJobManager recurringJobManager,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_jobClient = jobClient;
|
||||
_recurringJobManager = recurringJobManager;
|
||||
_scopeFactory = scopeFactory;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -89,14 +97,22 @@ public class SchedulerService
|
||||
// List of tickers to collect
|
||||
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" };
|
||||
|
||||
foreach (var ticker in tickers)
|
||||
{
|
||||
// Simulate data collection
|
||||
await Task.Delay(100);
|
||||
_logger.LogInformation("Collected data for ticker: {Ticker}", ticker);
|
||||
}
|
||||
// Create scope for scoped services
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var orchestrator = scope.ServiceProvider.GetRequiredService<ICollectionOrchestrator>();
|
||||
|
||||
_logger.LogInformation("Daily data collection completed at {Time}", DateTime.Now);
|
||||
// Build runId with timestamp
|
||||
var runId = $"daily-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||
|
||||
// Read account mode from configuration (default to "mock")
|
||||
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
|
||||
|
||||
// Execute collection
|
||||
var result = await orchestrator.RunCollectionAsync(runId, accountMode, tickers.ToList());
|
||||
|
||||
// Log completion
|
||||
_logger.LogInformation("Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors",
|
||||
runId, result.SuccessCount, result.ErrorCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user