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