using FluentAssertions; using Marathon.Domain.AnomalyDetection; using Marathon.Domain.Backtesting; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; namespace Marathon.Domain.Tests.Backtesting; /// /// Unit tests for . Math-heavy — every test /// pins one branch of the loop and the resulting headline numbers. /// public sealed class BacktestSimulatorTests { private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private static readonly DateTimeOffset BaseTime = new(2026, 5, 10, 18, 0, 0, MoscowOffset); // ── Strategy helpers ───────────────────────────────────────────────────── private static BacktestStrategy Flat(decimal bankroll = 1000m, decimal stake = 100m, decimal minScore = 0.30m) => new(StartingBankroll: bankroll, MinScore: minScore, StakeRule: StakeRule.Flat, FlatStake: stake, PercentOfBankroll: 0.02m, KellyFraction: 0.25m); private static BacktestStrategy Percent(decimal pct = 0.10m, decimal bankroll = 1000m, decimal minScore = 0.30m) => new(StartingBankroll: bankroll, MinScore: minScore, StakeRule: StakeRule.PercentOfBankroll, FlatStake: 1m, PercentOfBankroll: pct, KellyFraction: 0.25m); private static BacktestStrategy Kelly(decimal fraction = 1.0m, decimal bankroll = 1000m, decimal minScore = 0.30m) => new(StartingBankroll: bankroll, MinScore: minScore, StakeRule: StakeRule.Kelly, FlatStake: 1m, PercentOfBankroll: 0.02m, KellyFraction: fraction); // ── Candidate helpers ──────────────────────────────────────────────────── private static BacktestCandidate MakeCandidate( DateTimeOffset detectedAt, decimal score, Side postFav, Side winnerSide, decimal postRate1 = 2.0m, decimal postRate2 = 2.0m, bool twoWay = false, int s1 = 1, int s2 = 0) { var ev = BuildEvidence(postFav, postRate1, postRate2, twoWay); var anomaly = new Anomaly( Id: Guid.NewGuid(), EventId: new EventId(detectedAt.Ticks.ToString()), DetectedAt: detectedAt, Kind: AnomalyKind.SuspensionFlip, Score: score, EvidenceJson: "{\"x\":0}"); // unused — evidence is passed in directly var result = new EventResult( EventId: anomaly.EventId, Side1Score: s1, Side2Score: s2, WinnerSide: winnerSide, CompletedAt: detectedAt.AddHours(2)); return new BacktestCandidate(anomaly, ev, result, Sport: null); } private static AnomalyEvidenceData BuildEvidence( Side postFav, decimal postRate1, decimal postRate2, bool twoWay) { // Construct probabilities consistent with the rates so the simulator's // Kelly path has a meaningful p to read. decimal p1 = 1m / postRate1; decimal p2 = 1m / postRate2; decimal? pDraw = twoWay ? null : (decimal?)(1m - p1 - p2); decimal? rateDraw = twoWay ? null : (decimal?)5.0m; // Normalise to 1.0 (mirrors AnomalyDetector's normalisation). decimal total = p1 + p2 + (pDraw ?? 0m); p1 /= total; p2 /= total; if (pDraw is not null) pDraw = pDraw.Value / total; // Override the post-favourite side to actually be the highest probability — // tests want to verify behaviour for that specific side being the favourite. // We set the chosen side's prob to 0.6, distribute the rest. switch (postFav) { case Side.Side1: p1 = 0.60m; p2 = twoWay ? 0.40m : 0.30m; pDraw = twoWay ? null : 0.10m; break; case Side.Side2: p2 = 0.60m; p1 = twoWay ? 0.40m : 0.30m; pDraw = twoWay ? null : 0.10m; break; case Side.Draw: pDraw = 0.50m; p1 = 0.25m; p2 = 0.25m; break; } var preSide = new AnomalyEvidenceSide( CapturedAt: BaseTime, P1: p2, // pre = flipped (irrelevant to most tests) PDraw: pDraw, P2: p1, Rate1: postRate2, RateDraw: rateDraw, Rate2: postRate1); var postSide = new AnomalyEvidenceSide( CapturedAt: BaseTime.AddMinutes(1), P1: p1, PDraw: pDraw, P2: p2, Rate1: postRate1, RateDraw: rateDraw, Rate2: postRate2); return new AnomalyEvidenceData(60, preSide, postSide); } // ── Tests ──────────────────────────────────────────────────────────────── [Fact] public void Should_ReturnEmptyShell_When_NoCandidates() { var result = BacktestSimulator.Run(Flat(), Array.Empty()); result.BetsPlaced.Should().Be(0); result.FinalBankroll.Should().Be(1000m); result.NetProfit.Should().Be(0m); result.RoiPercent.Should().BeNull(); result.Trace.Should().BeEmpty(); } [Fact] public void Should_PlaceFlatBet_AndWin_PayoutEqualsStakeTimesRate() { // Stake 100 at rate 2.0 winning → +100 profit; bankroll 1000 → 1100. var candidate = MakeCandidate( detectedAt: BaseTime, score: 0.50m, postFav: Side.Side1, winnerSide: Side.Side1, postRate1: 2.0m); var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { candidate }); result.BetsPlaced.Should().Be(1); result.Wins.Should().Be(1); result.Losses.Should().Be(0); result.FinalBankroll.Should().Be(1100m); result.NetProfit.Should().Be(100m); result.RoiPercent.Should().Be(100m, "+100 / 100 staked"); result.TotalStaked.Should().Be(100m); result.TotalReturned.Should().Be(200m); result.Trace.Single().IsWin.Should().BeTrue(); result.Trace.Single().Payout.Should().Be(200m); result.Trace.Single().BankrollAfter.Should().Be(1100m); } [Fact] public void Should_PlaceFlatBet_AndLose_PayoutZero() { var candidate = MakeCandidate( detectedAt: BaseTime, score: 0.50m, postFav: Side.Side1, winnerSide: Side.Side2, postRate1: 2.0m); var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { candidate }); result.BetsPlaced.Should().Be(1); result.Losses.Should().Be(1); result.FinalBankroll.Should().Be(900m); result.NetProfit.Should().Be(-100m); result.Trace.Single().IsWin.Should().BeFalse(); result.Trace.Single().Payout.Should().Be(0m); } [Fact] public void Should_SkipCandidate_When_ScoreBelowThreshold() { var candidate = MakeCandidate( detectedAt: BaseTime, score: 0.20m, postFav: Side.Side1, winnerSide: Side.Side1); var result = BacktestSimulator.Run(Flat(minScore: 0.50m), new[] { candidate }); result.BetsPlaced.Should().Be(0); result.Skipped.Should().Be(1); result.SkippedByThreshold.Should().Be(1, "score 0.20 is below threshold 0.50"); result.SkippedByDataQuality.Should().Be(0); result.SkippedByBankroll.Should().Be(0); result.FinalBankroll.Should().Be(1000m); } [Fact] public void Should_SkipTwoWayCandidate_When_WinnerIsDraw() { // Tennis cannot draw — refuse to grade. var candidate = MakeCandidate( detectedAt: BaseTime, score: 0.50m, postFav: Side.Side1, winnerSide: Side.Draw, twoWay: true); var result = BacktestSimulator.Run(Flat(), new[] { candidate }); result.BetsPlaced.Should().Be(0); result.Skipped.Should().Be(1); result.SkippedByDataQuality.Should().Be(1, "two-way market with draw winner is structurally impossible"); result.SkippedByThreshold.Should().Be(0); } [Fact] public void Should_ProcessCandidates_InChronologicalOrder() { // Provide out-of-order — simulator must sort by DetectedAt. var c1 = MakeCandidate(BaseTime.AddHours(0), 0.50m, Side.Side1, Side.Side1, postRate1: 2.0m); var c2 = MakeCandidate(BaseTime.AddHours(1), 0.50m, Side.Side1, Side.Side2, postRate1: 2.0m); var c3 = MakeCandidate(BaseTime.AddHours(2), 0.50m, Side.Side1, Side.Side1, postRate1: 2.0m); var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { c3, c1, c2 }); result.Trace.Select(t => t.DetectedAt).Should().BeInAscendingOrder(); // Bankroll: 1000 → 1100 (win) → 1000 (loss) → 1100 (win) result.Trace[0].BankrollAfter.Should().Be(1100m); result.Trace[1].BankrollAfter.Should().Be(1000m); result.Trace[2].BankrollAfter.Should().Be(1100m); } [Fact] public void Should_TrackMaxDrawdown_Across_Losses() { // 5 candidates: W W L L L → bankroll 1000 → 1100 → 1200 → 1100 → 1000 → 900 // Peak = 1200, trough = 900, max drawdown = 300. var cands = new[] { MakeCandidate(BaseTime.AddHours(0), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m), MakeCandidate(BaseTime.AddHours(1), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m), MakeCandidate(BaseTime.AddHours(2), 0.5m, Side.Side1, Side.Side2, postRate1: 2.0m), MakeCandidate(BaseTime.AddHours(3), 0.5m, Side.Side1, Side.Side2, postRate1: 2.0m), MakeCandidate(BaseTime.AddHours(4), 0.5m, Side.Side1, Side.Side2, postRate1: 2.0m), }; var result = BacktestSimulator.Run(Flat(stake: 100m), cands); result.MaxDrawdown.Should().Be(300m); result.MaxDrawdownPercent.Should().Be(25m, "300 / 1200 = 25 %"); result.MaxLossStreak.Should().Be(3); result.MaxWinStreak.Should().Be(2); } [Fact] public void Should_CompoundBankroll_With_PercentOfBankrollRule() { // 10 % of bankroll. Bankroll 1000 → bet 100 at 2.0 win → 1100 → bet 110 at 2.0 win → 1210. var c1 = MakeCandidate(BaseTime.AddHours(0), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m); var c2 = MakeCandidate(BaseTime.AddHours(1), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m); var result = BacktestSimulator.Run(Percent(pct: 0.10m), new[] { c1, c2 }); result.Trace[0].Stake.Should().Be(100m); result.Trace[0].BankrollAfter.Should().Be(1100m); result.Trace[1].Stake.Should().Be(110m); result.Trace[1].BankrollAfter.Should().Be(1210m); } [Fact] public void Kelly_Should_StakeZero_When_EdgeIsNegative() { // Post-favourite has 60% prob at rate 1.50 → b = 0.5, p = 0.6, q = 0.4. // Full Kelly = (0.5*0.6 - 0.4) / 0.5 = (0.30 - 0.40) / 0.5 = -0.20 → no bet. var c = MakeCandidate(BaseTime, 0.5m, Side.Side1, Side.Side1, postRate1: 1.50m); var result = BacktestSimulator.Run(Kelly(fraction: 1.0m), new[] { c }); result.BetsPlaced.Should().Be(0); result.Skipped.Should().Be(1); result.FinalBankroll.Should().Be(1000m); } [Fact] public void Kelly_Should_StakePositive_When_EdgeIsPositive() { // Post-favourite has 60% prob (set inside BuildEvidence) at rate 2.0 → b = 1, p = 0.6, q = 0.4. // Full Kelly = (1*0.6 - 0.4) / 1 = 0.20. Stake = 0.20 * 1000 = 200. var c = MakeCandidate(BaseTime, 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m); var result = BacktestSimulator.Run(Kelly(fraction: 1.0m), new[] { c }); result.BetsPlaced.Should().Be(1); // BankrollAfter on a win at rate 2.0 with stake 200 = 1000 - 200 + 400 = 1200. result.Trace.Single().Stake.Should().Be(200m); result.Trace.Single().BankrollAfter.Should().Be(1200m); } [Fact] public void QuarterKelly_Should_StakeAQuarterOfFullKelly() { // Same setup as Kelly_Should_StakePositive but fraction 0.25 → stake 50. var c = MakeCandidate(BaseTime, 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m); var result = BacktestSimulator.Run(Kelly(fraction: 0.25m), new[] { c }); result.Trace.Single().Stake.Should().Be(50m); result.Trace.Single().BankrollAfter.Should().Be(1050m, "1000 - 50 + 100"); } [Fact] public void Should_SkipBet_When_StakeExceedsBankroll() { // Starting bankroll 500, flat stake 500 each bet. // c1 loses → bankroll 0. c2 + c3 then can't be sized (stake > bankroll). var c1 = MakeCandidate(BaseTime.AddHours(0), 0.5m, Side.Side1, Side.Side2, postRate1: 2.0m); var c2 = MakeCandidate(BaseTime.AddHours(1), 0.5m, Side.Side1, Side.Side2, postRate1: 2.0m); var c3 = MakeCandidate(BaseTime.AddHours(2), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m); var result = BacktestSimulator.Run(Flat(bankroll: 500m, stake: 500m), new[] { c1, c2, c3 }); result.BetsPlaced.Should().Be(1); result.Skipped.Should().Be(2); result.SkippedByBankroll.Should().Be(2, "bankroll empty / stake too large"); result.FinalBankroll.Should().Be(0m); } [Fact] public void Should_PickDeepestDrawdown_AcrossMultipleWindows() { // Two drawdown windows: 1000→1100→1050 (dd=50), then 1050→1250→1100 (dd=150). // Max drawdown should be the second window (150), not the first. var cands = new[] { MakeCandidate(BaseTime.AddHours(0), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m), // win → 1100 MakeCandidate(BaseTime.AddHours(1), 0.5m, Side.Side1, Side.Side2, postRate1: 1.5m), // loss → 1050 MakeCandidate(BaseTime.AddHours(2), 0.5m, Side.Side1, Side.Side1, postRate1: 3.0m), // win → 1250 MakeCandidate(BaseTime.AddHours(3), 0.5m, Side.Side1, Side.Side2, postRate1: 1.5m), // loss → 1150 MakeCandidate(BaseTime.AddHours(4), 0.5m, Side.Side1, Side.Side2, postRate1: 1.5m), // loss → 1050 }; var result = BacktestSimulator.Run(Flat(stake: 100m), cands); // Window 1: peak 1100 → trough 1050 = 50 drop. // Window 2: peak 1250 → trough 1050 = 200 drop. // (Bankroll path: 1000 → 1100 → 1050 → 1250 → 1150 → 1050) result.MaxDrawdown.Should().Be(200m); result.MaxDrawdownPercent.Should().Be(16.67m, "200 / 1200 ≈ 16.67 % (peak was 1200 not 1250)"); } [Fact] public void Should_HandleDrawFavourite_Win() { // 3-way market, post-flip favourite is Draw, event ends in Draw → win. var c = MakeCandidate( detectedAt: BaseTime, score: 0.5m, postFav: Side.Draw, winnerSide: Side.Draw, twoWay: false); var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { c }); result.BetsPlaced.Should().Be(1); result.Wins.Should().Be(1); result.Trace.Single().PostFlipFavourite.Should().Be(Side.Draw); result.Trace.Single().IsWin.Should().BeTrue(); } [Fact] public void Should_PassEventTitles_Through_ToResult() { var c = MakeCandidate(BaseTime, 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m); var titles = new Dictionary { [c.Anomaly.EventId] = "Arsenal vs Chelsea", }; var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { c }, titles); result.EventTitles.Should().ContainKey(c.Anomaly.EventId); result.EventTitles[c.Anomaly.EventId].Should().Be("Arsenal vs Chelsea"); } [Fact] public void Should_ReturnEmptyEventTitles_When_NoneProvided() { var c = MakeCandidate(BaseTime, 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m); var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { c }); result.EventTitles.Should().NotBeNull().And.BeEmpty(); } }