feat: add quant engine WBS verification harness

This commit is contained in:
2026-07-12 10:58:22 +09:00
parent a274ef448a
commit e7d1069222
39 changed files with 2888 additions and 287 deletions
@@ -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);
}
}
}
+5
View File
@@ -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)
{