Files
KArtSell.Aegis/tests/KArtSell.Integration.Tests/ShadowRunUnitTests.cs
T
kjh2064 4ebc1e4941 feat: implement direct Shadow Run invocation endpoint (bypass Hangfire queue)
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>
2026-08-12 15:23:23 +09:00

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) { }
}
}