0d52b7beff
Adds an interactive backtester that replays the SuspensionFlip detector over all flagged anomalies under a chosen score threshold and staking rule (flat / percent-of-bankroll / Kelly), and reports the headline numbers a user needs to judge edge: final bankroll, ROI, max drawdown (peak-to-trough), win/loss streaks, plus per-bet equity curve. Domain (pure): - StakeRule enum + BacktestStrategy params (with validation). - BacktestSimulator: deterministic function taking strategy + chronological candidates → BacktestResult. Implements Kelly with post-flip implied prob as p (skipping negative-edge bets), peak-to-trough drawdown tracking, and win/loss streak rollups. Mirrors AnomalyOutcomeEvaluator on the 2-way Draw guard so tennis data inconsistencies are refused rather than miss-counted. - Skipped counter split into SkippedByThreshold / SkippedByDataQuality / SkippedByBankroll so the UI can distinguish "strategy choice" from "data-quality" from "bankroll empty". Application: - RunBacktestUseCase: loads anomalies + events + results, parses evidence, builds candidates, hands event titles into the simulator so the UI does zero repository round-trips of its own. UI: - Pages/Anomalies/Backtest.razor: hero, strategy form (MudBlazor — conditional sub-field per staking rule), 4-card KPI strip (final bankroll / net profit / ROI / max drawdown), counters row, inline-SVG equity curve, trade-trace table with per-bet outcome pills and link-back to the source anomaly. - Nav entry under Analysis. RU + EN i18n. Tests: +20 (16 simulator math — flat / percent compounding / Kelly +/- edge / quarter-Kelly / bankroll-exceeded / out-of-order chronology / Draw favourite / multi-window drawdown / event-title pass-through + 4 use-case join). All 399 tests pass. Money rounding switched to MidpointRounding.AwayFromZero throughout the simulator output for accounting convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
387 lines
16 KiB
C#
387 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="BacktestSimulator"/>. Math-heavy — every test
|
|
/// pins one branch of the loop and the resulting headline numbers.
|
|
/// </summary>
|
|
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<BacktestCandidate>());
|
|
|
|
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<Marathon.Domain.ValueObjects.EventId, string>
|
|
{
|
|
[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();
|
|
}
|
|
}
|