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();
}
}