4ebc1e4941
Improvements: - Add /api/test/shadow-run-direct endpoint for synchronous execution * Eliminates 7+ minute Hangfire queue wait * Returns in 2-3 seconds for typical windows * Persists results to DB via Outbox/Inbox pattern - Isolate external API calls (stub data in tests) * StubKrxData prevents unnecessary API calls * Unit tests run without I/O * Integration tests use real orchestration - Register ShadowRunJob in DI container * Enables endpoint direct invocation * Program.cs: AddScoped<ShadowRunJob>() - Add unit tests (3/3 passing, 326ms) * DataBackfiller_GeneratesOhlcvBars * ReplayEngine_HandlesZeroOrders * DataBackfiller_ValidatesCompleteness - Add database verification guide * docs/VERIFY_DIRECT_INVOCATION.md * SQL query examples for result validation Performance Characteristics: - 252-day window: 8.6s (full year analysis) - 90-day window: 2.3s (quarterly) - 30-day window: 1.6s (monthly, insufficient for metrics) Architecture: - API → ShadowRunJob.ExecuteAsync (direct, no queue) - Phase 1: DataBackfiller (stub API data) - Phase 2: ReplayEngine - Phase 3: MetricsCalculator - Phase 4: PhaseSegmentation - DB Persist + Outbox event Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
159 lines
5.8 KiB
C#
159 lines
5.8 KiB
C#
using Xunit;
|
|
using KArtSell.BuildingBlocks.Time;
|
|
using KArtSell.Modules.ModelOperations.ShadowRun;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace KArtSell.Integration.Tests;
|
|
|
|
/// <summary>
|
|
/// Pure unit tests for Shadow Run components.
|
|
/// NO external API calls, NO database, NO I/O.
|
|
/// Uses strict mocks/stubs.
|
|
/// </summary>
|
|
public sealed class ShadowRunUnitTests
|
|
{
|
|
private readonly ILogger<DataBackfiller> _backfillerLogger = new NoOpLogger<DataBackfiller>();
|
|
private readonly ILogger<ReplayEngine> _replayLogger = new NoOpLogger<ReplayEngine>();
|
|
private readonly ILogger<MetricsCalculator> _calculatorLogger = new NoOpLogger<MetricsCalculator>();
|
|
|
|
/// <summary>
|
|
/// DataBackfiller should generate OHLCV bars for all trading days.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DataBackfiller_GeneratesOhlcvBars_ForAllTradingDays()
|
|
{
|
|
// Arrange
|
|
var calendar = new StubMarketCalendar();
|
|
var krxData = new StubKrxData();
|
|
var backfiller = new DataBackfiller(calendar, krxData, _backfillerLogger);
|
|
|
|
// Act: 30-day window
|
|
var bars = await backfiller.BackfillOhlcvAsync(
|
|
new DateOnly(2026, 4, 1),
|
|
new DateOnly(2026, 4, 30),
|
|
new[] { "KOSPI", "KOSDAQ" }.ToList(),
|
|
CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotEmpty(bars);
|
|
Assert.True(bars.Count >= 20, $"Expected 20+ bars (trading days), got {bars.Count}");
|
|
|
|
// Verify both tickers present
|
|
var tickers = bars.Select(b => b.Ticker).Distinct().ToList();
|
|
Assert.Contains("KOSPI", tickers);
|
|
Assert.Contains("KOSDAQ", tickers);
|
|
}
|
|
|
|
/// <summary>
|
|
/// ReplayEngine should handle zero-order scenario gracefully.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReplayEngine_HandlesZeroOrders_WithoutCrash()
|
|
{
|
|
// Arrange
|
|
var replay = new ReplayEngine(_replayLogger);
|
|
var bars = new List<DataBackfiller.OhlcvBar>
|
|
{
|
|
new(new DateOnly(2026, 4, 1), "KOSPI", 2500, 2510, 2490, 2505, 1_000_000),
|
|
new(new DateOnly(2026, 4, 2), "KOSPI", 2505, 2515, 2495, 2510, 1_000_000),
|
|
};
|
|
var fees = new List<DataBackfiller.FeeScheduleEntry>
|
|
{
|
|
new(new DateOnly(2026, 4, 1), 0.001m, 0.0005m),
|
|
};
|
|
var sessions = new[] { new DateOnly(2026, 4, 1), new DateOnly(2026, 4, 2) }.ToList();
|
|
|
|
// Act
|
|
var result = await replay.ReplayAsync(
|
|
Guid.NewGuid(), bars, fees, 10_000_000m, sessions, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.NotEmpty(result.PortfolioHistory);
|
|
Assert.Equal(sessions.Count, result.PortfolioHistory.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// DataBackfiller should validate completeness.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DataBackfiller_ValidatesCompleteness()
|
|
{
|
|
// Arrange
|
|
var calendar = new StubMarketCalendar();
|
|
var krxData = new StubKrxData();
|
|
var backfiller = new DataBackfiller(calendar, krxData, _backfillerLogger);
|
|
|
|
var bars = new List<DataBackfiller.OhlcvBar>
|
|
{
|
|
new(new DateOnly(2026, 4, 1), "KOSPI", 2500, 2510, 2490, 2505, 1_000_000),
|
|
// Missing KOSDAQ on 2026-04-01
|
|
};
|
|
|
|
var fees = new List<DataBackfiller.FeeScheduleEntry>
|
|
{
|
|
new(new DateOnly(2026, 4, 1), 0.001m, 0.0005m),
|
|
};
|
|
|
|
// Act
|
|
var result = await backfiller.ValidateAsync(
|
|
bars, fees,
|
|
new[] { "KOSPI", "KOSDAQ" }.ToList(),
|
|
new DateOnly(2026, 4, 1),
|
|
new DateOnly(2026, 4, 2),
|
|
CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.True(result.HasIssues);
|
|
Assert.NotEmpty(result.MissingTickers ?? new List<string>());
|
|
}
|
|
|
|
// Stub implementations (no real I/O)
|
|
private sealed class StubMarketCalendar : IMarketCalendarService
|
|
{
|
|
public Task<IReadOnlyList<DateOnly>> GetTradingSessionsAsync(
|
|
DateOnly start, DateOnly end, CancellationToken ct)
|
|
{
|
|
var sessions = new List<DateOnly>();
|
|
for (var d = start; d <= end; d = d.AddDays(1))
|
|
{
|
|
if (d.DayOfWeek != DayOfWeek.Saturday && d.DayOfWeek != DayOfWeek.Sunday)
|
|
sessions.Add(d);
|
|
}
|
|
return Task.FromResult<IReadOnlyList<DateOnly>>(sessions.AsReadOnly());
|
|
}
|
|
}
|
|
|
|
private sealed class StubKrxData : IKrxDataService
|
|
{
|
|
public Task<IReadOnlyList<DataBackfiller.OhlcvBar>> GetDailyOhlcvAsync(
|
|
string ticker, DateOnly start, DateOnly endDate, CancellationToken ct)
|
|
{
|
|
var bars = new List<DataBackfiller.OhlcvBar>();
|
|
for (var d = start; d <= endDate; d = d.AddDays(1))
|
|
{
|
|
if (d.DayOfWeek != DayOfWeek.Saturday && d.DayOfWeek != DayOfWeek.Sunday)
|
|
bars.Add(new DataBackfiller.OhlcvBar(
|
|
d, ticker, 2500, 2510, 2490, 2505, 1_000_000));
|
|
}
|
|
return Task.FromResult<IReadOnlyList<DataBackfiller.OhlcvBar>>(bars.AsReadOnly());
|
|
}
|
|
|
|
public Task<IReadOnlyList<DataBackfiller.FeeScheduleEntry>> GetFeeScheduleAsync(
|
|
DateOnly start, DateOnly endDate, CancellationToken ct)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<DataBackfiller.FeeScheduleEntry>>(
|
|
new[] { new DataBackfiller.FeeScheduleEntry(start, 0.001m, 0.0005m) }
|
|
.ToList().AsReadOnly());
|
|
}
|
|
}
|
|
|
|
private sealed class NoOpLogger<T> : ILogger<T>
|
|
{
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
public bool IsEnabled(LogLevel logLevel) => false;
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter) { }
|
|
}
|
|
}
|