using Xunit; using KArtSell.BuildingBlocks.Time; using KArtSell.Modules.ModelOperations.ShadowRun; using Microsoft.Extensions.Logging; namespace KArtSell.Integration.Tests; /// /// Shadow run validation tests. /// Covers: Backfill, Replay, Metrics, Validation gates. /// public sealed class ShadowRunTests { private readonly ILogger _backfillerLogger = new NoOpLogger(); private readonly ILogger _replayLogger = new NoOpLogger(); private readonly ILogger _calculatorLogger = new NoOpLogger(); [Fact] public async Task DataBackfiller_ValidatesCompleteness_DetectsMissingTickers() { // Arrange var marketCalendar = new StubMarketCalendar(); var krxData = new StubKrxData(); var backfiller = new DataBackfiller(marketCalendar, krxData, _backfillerLogger); var bars = new List { new(new DateOnly(2024, 1, 2), "KOSPI", 2500, 2510, 2490, 2505, 1_000_000), // Missing KOSDAQ bar }; var fees = new List { new(new DateOnly(2024, 1, 1), 0.001m, 0.0005m), }; // Act var result = await backfiller.ValidateAsync( bars, fees, new[] { "KOSPI", "KOSDAQ" }.ToList(), new DateOnly(2024, 1, 2), new DateOnly(2024, 1, 2), CancellationToken.None); // Assert Assert.True(result.HasIssues); var missingTickers = result.MissingTickers ?? new List(); Assert.NotEmpty(missingTickers); Assert.Contains("KOSDAQ", missingTickers); } [Fact] public async Task ReplayEngine_GeneratesPortfolioSnapshots_ReturnsOrders() { // Arrange var replay = new ReplayEngine(_replayLogger); var ohlcv = new List { new(new DateOnly(2024, 1, 2), "KOSPI", 2500, 2510, 2490, 2505, 1_000_000), new(new DateOnly(2024, 1, 3), "KOSPI", 2505, 2515, 2500, 2510, 1_100_000), }; var fees = new List { new(new DateOnly(2024, 1, 1), 0.001m, 0.0005m), }; var sessions = new[] { new DateOnly(2024, 1, 2), new DateOnly(2024, 1, 3) }.ToList(); // Act var result = await replay.ReplayAsync( Guid.NewGuid(), ohlcv, fees, initialCashBalance: 10_000_000m, sessions, CancellationToken.None); // Assert Assert.NotNull(result); Assert.Equal(2, result.PortfolioHistory.Count); Assert.True(result.PortfolioHistory[0].TotalValue > 0); } [Fact] public async Task MetricsCalculator_CalculatesSharpe_WithinRange() { // Arrange var calculator = new MetricsCalculator(_calculatorLogger); var portfolioHistory = new List { new(new DateOnly(2024, 1, 2), new Dictionary(), 10_000_000m, 10_000_000m), new(new DateOnly(2024, 1, 3), new Dictionary(), 10_100_000m, 10_100_000m), new(new DateOnly(2024, 1, 4), new Dictionary(), 10_050_000m, 10_050_000m), }; var dailyReturns = new List<(DateOnly, decimal)> { (new DateOnly(2024, 1, 2), 0m), (new DateOnly(2024, 1, 3), 0.01m), // +1% (new DateOnly(2024, 1, 4), -0.005m), // -0.5% }; var ohlcv = new List(); var fees = new List(); var replay = new ReplayResult( Guid.NewGuid(), portfolioHistory, new List(), new List(), dailyReturns); // Act var metrics = await calculator.CalculateAsync(replay, ohlcv, fees, CancellationToken.None); // Assert Assert.NotNull(metrics); Assert.True(metrics.SharpeRatio >= -5 && metrics.SharpeRatio <= 5, "Sharpe should be in reasonable range"); Assert.True(metrics.WinRate >= 0 && metrics.WinRate <= 1, "Win rate should be [0, 1]"); Assert.True(metrics.ProbOfBacktestOverfit >= 0 && metrics.ProbOfBacktestOverfit <= 1, "PBO should be [0, 1]"); } [Fact] public void ValidationGates_AllGatePassed_WhenAllMetricsExceed() { // Arrange var gates = new ValidationGates( PboUnder20: true, DsrAbove95: true, CostTwoXPositive: true, AllGatesPassed: true); // Assert Assert.True(gates.AllGatesPassed); Assert.True(gates.PboUnder20); Assert.True(gates.DsrAbove95); } private sealed class StubMarketCalendar : IMarketCalendarService { public Task> GetTradingSessionsAsync( DateOnly start, DateOnly end, CancellationToken ct) { var sessions = new List(); 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>(sessions.AsReadOnly()); } } private sealed class StubKrxData : IKrxDataService { public Task> GetDailyOhlcvAsync( string ticker, DateOnly start, DateOnly endDate, CancellationToken ct) { var bars = new List(); 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>(bars.AsReadOnly()); } public Task> GetFeeScheduleAsync( DateOnly start, DateOnly endDate, CancellationToken ct) { return Task.FromResult>( new[] { new DataBackfiller.FeeScheduleEntry(start, 0.001m, 0.0005m) }.ToList().AsReadOnly()); } } private sealed class NoOpLogger : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => false; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { } } }