using Xunit; using KArtSell.BuildingBlocks.Time; using KArtSell.Modules.ModelOperations.ShadowRun; using Microsoft.Extensions.Logging; namespace KArtSell.Integration.Tests; /// /// Pure unit tests for Shadow Run components. /// NO external API calls, NO database, NO I/O. /// Uses strict mocks/stubs. /// public sealed class ShadowRunUnitTests { private readonly ILogger _backfillerLogger = new NoOpLogger(); private readonly ILogger _replayLogger = new NoOpLogger(); private readonly ILogger _calculatorLogger = new NoOpLogger(); /// /// DataBackfiller should generate OHLCV bars for all trading days. /// [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); } /// /// ReplayEngine should handle zero-order scenario gracefully. /// [Fact] public async Task ReplayEngine_HandlesZeroOrders_WithoutCrash() { // Arrange var replay = new ReplayEngine(_replayLogger); var bars = new List { 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 { 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); } /// /// DataBackfiller should validate completeness. /// [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 { new(new DateOnly(2026, 4, 1), "KOSPI", 2500, 2510, 2490, 2505, 1_000_000), // Missing KOSDAQ on 2026-04-01 }; var fees = new List { 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()); } // Stub implementations (no real I/O) 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) { } } }