From 0d52b7beffb1afa461b7a9348ab09716a6f8ff61 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 16 May 2026 18:34:42 +0300 Subject: [PATCH] feat(backtest): historical strategy backtester MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/Marathon.Application/ApplicationModule.cs | 2 + .../UseCases/RunBacktestUseCase.cs | 115 +++ .../Backtesting/BacktestCandidate.cs | 24 + .../Backtesting/BacktestResult.cs | 113 +++ .../Backtesting/BacktestSimulator.cs | 248 +++++ .../Backtesting/BacktestStrategy.cs | 72 ++ src/Marathon.Domain/Backtesting/StakeRule.cs | 28 + src/Marathon.UI/Components/NavBody.razor | 4 + .../Pages/Anomalies/Backtest.razor | 867 ++++++++++++++++++ .../Resources/SharedResource.en.resx | 48 + .../Resources/SharedResource.ru.resx | 48 + src/Marathon.UI/Services/BacktestService.cs | 61 ++ .../Services/BacktestViewModels.cs | 89 ++ src/Marathon.UI/Services/IBacktestService.cs | 13 + .../Services/UiServicesExtensions.cs | 1 + .../UseCases/RunBacktestUseCaseTests.cs | 130 +++ .../Backtesting/BacktestSimulatorTests.cs | 386 ++++++++ 17 files changed, 2249 insertions(+) create mode 100644 src/Marathon.Application/UseCases/RunBacktestUseCase.cs create mode 100644 src/Marathon.Domain/Backtesting/BacktestCandidate.cs create mode 100644 src/Marathon.Domain/Backtesting/BacktestResult.cs create mode 100644 src/Marathon.Domain/Backtesting/BacktestSimulator.cs create mode 100644 src/Marathon.Domain/Backtesting/BacktestStrategy.cs create mode 100644 src/Marathon.Domain/Backtesting/StakeRule.cs create mode 100644 src/Marathon.UI/Pages/Anomalies/Backtest.razor create mode 100644 src/Marathon.UI/Services/BacktestService.cs create mode 100644 src/Marathon.UI/Services/BacktestViewModels.cs create mode 100644 src/Marathon.UI/Services/IBacktestService.cs create mode 100644 tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs create mode 100644 tests/Marathon.Domain.Tests/Backtesting/BacktestSimulatorTests.cs diff --git a/src/Marathon.Application/ApplicationModule.cs b/src/Marathon.Application/ApplicationModule.cs index 14313e9..0b47a67 100644 --- a/src/Marathon.Application/ApplicationModule.cs +++ b/src/Marathon.Application/ApplicationModule.cs @@ -37,6 +37,8 @@ public static class ApplicationModule services.AddScoped(); services.AddScoped(); + services.AddScoped(); + return services; } } diff --git a/src/Marathon.Application/UseCases/RunBacktestUseCase.cs b/src/Marathon.Application/UseCases/RunBacktestUseCase.cs new file mode 100644 index 0000000..3fe3177 --- /dev/null +++ b/src/Marathon.Application/UseCases/RunBacktestUseCase.cs @@ -0,0 +1,115 @@ +using Marathon.Application.Abstractions; +using Marathon.Domain.AnomalyDetection; +using Marathon.Domain.Backtesting; +using Marathon.Domain.Entities; +using Microsoft.Extensions.Logging; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.Application.UseCases; + +/// +/// Loads every persisted anomaly paired with its event metadata and result, +/// constructs rows, and runs the pure +/// with the supplied strategy. +/// +/// +/// +/// Composes the two analytics features already in place: anomalies come from +/// the SuspensionFlip detector, and results come from the results loader. The +/// simulator never touches I/O — all data loading happens here, then the run +/// is a deterministic function of (strategy, candidates). +/// +/// +/// Anomalies whose evidence JSON fails to parse, whose source events lack a +/// final result, or whose event row has been pruned are filtered out before +/// simulation. They are not counted as "skipped" by the simulator — the +/// simulator's counter only reflects +/// runs the strategy chose not to bet on (below threshold, no edge, etc.). +/// +/// +public sealed class RunBacktestUseCase +{ + private readonly IAnomalyRepository _anomalies; + private readonly IEventRepository _events; + private readonly IResultRepository _results; + private readonly ILogger _logger; + + public RunBacktestUseCase( + IAnomalyRepository anomalies, + IEventRepository events, + IResultRepository results, + ILogger logger) + { + _anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies)); + _events = events ?? throw new ArgumentNullException(nameof(events)); + _results = results ?? throw new ArgumentNullException(nameof(results)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ExecuteAsync( + BacktestStrategy strategy, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(strategy); + + _logger.LogInformation( + "RunBacktestUseCase: started — bankroll={Bankroll}, minScore={MinScore}, stakeRule={Rule}", + strategy.StartingBankroll, strategy.MinScore, strategy.StakeRule); + + var anomalies = await _anomalies.ListAsync(ct).ConfigureAwait(false); + if (anomalies.Count == 0) + { + _logger.LogInformation("RunBacktestUseCase: no anomalies — empty result"); + return BacktestSimulator.Run(strategy, Array.Empty()); + } + + // Distinct event lookups — minimises repo calls. + // TODO (perf, future): batch via IEventRepository.GetManyAsync / + // IResultRepository.GetManyAsync once those exist — currently shared + // with EvaluateAnomalyOutcomesUseCase, acceptable at expected volumes. + var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList(); + + var eventLookup = new Dictionary(distinctEventIds.Count); + var resultLookup = new Dictionary(distinctEventIds.Count); + var titles = new Dictionary(distinctEventIds.Count); + foreach (var id in distinctEventIds) + { + ct.ThrowIfCancellationRequested(); + + var ev = await _events.GetAsync(id, ct).ConfigureAwait(false); + if (ev is not null) + { + eventLookup[id] = ev; + titles[id] = string.Concat(ev.Side1Name, " vs ", ev.Side2Name); + } + + var res = await _results.GetAsync(id, ct).ConfigureAwait(false); + if (res is not null) resultLookup[id] = res; + } + + var candidates = new List(anomalies.Count); + foreach (var anomaly in anomalies) + { + ct.ThrowIfCancellationRequested(); + + // Cannot simulate a bet whose event hasn't been graded yet. + if (!resultLookup.TryGetValue(anomaly.EventId, out var result)) + continue; + + if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var evidence)) + continue; + + eventLookup.TryGetValue(anomaly.EventId, out var ev); + candidates.Add(new BacktestCandidate(anomaly, evidence, result, ev?.Sport)); + } + + var simResult = BacktestSimulator.Run(strategy, candidates, titles); + + _logger.LogInformation( + "RunBacktestUseCase: done — bets={Bets}, wins={Wins}, losses={Losses}, ROI={Roi:0.##}%, finalBankroll={Final}", + simResult.BetsPlaced, simResult.Wins, simResult.Losses, + simResult.RoiPercent ?? 0m, simResult.FinalBankroll); + + return simResult; + } +} diff --git a/src/Marathon.Domain/Backtesting/BacktestCandidate.cs b/src/Marathon.Domain/Backtesting/BacktestCandidate.cs new file mode 100644 index 0000000..f49a2fe --- /dev/null +++ b/src/Marathon.Domain/Backtesting/BacktestCandidate.cs @@ -0,0 +1,24 @@ +using Marathon.Domain.AnomalyDetection; +using Marathon.Domain.Entities; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Backtesting; + +/// +/// Input row for — one anomaly fully resolved +/// against its event metadata and result. The use case constructs these once +/// per simulation run and feeds them to the pure simulator in chronological +/// order. +/// +/// The flagged anomaly being simulated. +/// +/// Parsed evidence payload (pre- and post-suspension snapshots). The simulator +/// reads the post-suspension favourite and rate from here. +/// +/// Final event result — drives the win/loss verdict. +/// Sport metadata, optional, surfaced into the trace row. +public sealed record BacktestCandidate( + Anomaly Anomaly, + AnomalyEvidenceData Evidence, + EventResult Result, + SportCode? Sport); diff --git a/src/Marathon.Domain/Backtesting/BacktestResult.cs b/src/Marathon.Domain/Backtesting/BacktestResult.cs new file mode 100644 index 0000000..ba062ad --- /dev/null +++ b/src/Marathon.Domain/Backtesting/BacktestResult.cs @@ -0,0 +1,113 @@ +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Backtesting; + +/// +/// Aggregate output of one simulation run. Contains both the headline numbers +/// the user looks at (final bankroll, ROI, max drawdown) and the per-bet +/// trace needed to draw an equity curve. +/// +/// Echoed from the strategy for the UI. +/// Bankroll after the last simulated bet settled. +/// FinalBankroll − StartingBankroll. +/// +/// NetProfit / TotalStaked × 100. Null when no bets were placed +/// (no anomaly met the threshold, or the bankroll went to zero before any +/// stake could be sized). +/// +/// Sum of stake sizes across every settled bet. +/// Sum of gross returns across every settled bet. +/// +/// Largest peak-to-trough drop in bankroll observed during the run, as an +/// absolute amount. Always ≥ 0. +/// +/// +/// as a percentage of the peak that preceded it. +/// Null when there were no draws (no bets or no losses). +/// +/// Total bets the strategy actually placed. +/// Settled bets whose post-flip favourite won. +/// Settled bets whose post-flip favourite lost. +/// +/// Total anomalies inspected but skipped. Equals +/// + + +/// . Surfaced separately so the UI can +/// distinguish a strategy choice ("threshold too high") from a real-world +/// signal ("bankroll empty") or a data-quality issue. +/// +/// +/// Skipped because Anomaly.Score < strategy.MinScore — pure strategy choice. +/// +/// +/// Skipped because the evidence parsed but the post-flip favourite has no +/// rate / probability, or because a two-way market produced a Draw winner. +/// Strategy-orthogonal — these would be skipped under any rule. +/// +/// +/// Skipped because the sized stake was non-positive (Kelly returned no edge, +/// or bankroll was depleted) or exceeded the current bankroll. +/// +/// Longest run of consecutive wins. +/// Longest run of consecutive losses. +/// +/// Per-bet records in chronological order — drives the equity curve. +/// +/// +/// Pre-shaped "Side1Name vs Side2Name" strings keyed by event id, for +/// every event in . Carried alongside the result so the UI +/// projection does not need a second pass over IEventRepository. +/// Missing events (pruned by retention) are absent from the map; consumers +/// fall back to EventId.Value. +/// +public sealed record BacktestResult( + decimal StartingBankroll, + decimal FinalBankroll, + decimal NetProfit, + decimal? RoiPercent, + decimal TotalStaked, + decimal TotalReturned, + decimal MaxDrawdown, + decimal? MaxDrawdownPercent, + int BetsPlaced, + int Wins, + int Losses, + int Skipped, + int SkippedByThreshold, + int SkippedByDataQuality, + int SkippedByBankroll, + int MaxWinStreak, + int MaxLossStreak, + IReadOnlyList Trace, + IReadOnlyDictionary EventTitles); + +/// +/// One settled simulated bet. Carries enough metadata to surface a +/// drill-down row and a point on the equity curve. +/// +/// Source anomaly for the link-back affordance. +/// Event being bet on. +/// When the anomaly was originally detected. +/// Confidence score of the anomaly. +/// Sport metadata if available — null when the event is missing. +/// Side bet on (the post-suspension favourite). +/// Rate at which the simulator "bought" the bet (post-flip rate). +/// Stake sized for this bet. +/// Actual winner of the event. +/// true if the post-flip favourite was the winner. +/// Gross return — Stake × Rate for a win, 0 for a loss. +/// Bankroll after this bet settled — equity-curve y-axis. +public sealed record BacktestTrace( + Guid AnomalyId, + EventId EventId, + DateTimeOffset DetectedAt, + decimal Score, + SportCode? Sport, + Side PostFlipFavourite, + decimal TakenRate, + decimal Stake, + Side WinnerSide, + bool IsWin, + decimal Payout, + decimal BankrollAfter); diff --git a/src/Marathon.Domain/Backtesting/BacktestSimulator.cs b/src/Marathon.Domain/Backtesting/BacktestSimulator.cs new file mode 100644 index 0000000..a8023d4 --- /dev/null +++ b/src/Marathon.Domain/Backtesting/BacktestSimulator.cs @@ -0,0 +1,248 @@ +using Marathon.Domain.Enums; + +namespace Marathon.Domain.Backtesting; + +/// +/// Pure simulator that replays a over a +/// chronological list of rows and returns the +/// resulting . No I/O, no DI — safe to call in +/// hot loops or property tests. +/// +/// +/// +/// Loop body per candidate: +/// +/// Skip if Anomaly.Score < strategy.MinScore. +/// +/// Skip if the evidence is two-way and the actual winner is Draw: +/// this mirrors AnomalyOutcomeEvaluator — we refuse to grade +/// selections that are structurally impossible for the market. +/// +/// Compute stake from the chosen . +/// Skip when the stake is non-positive (Kelly returned no edge, or bankroll empty). +/// Settle: payout = stake × rate when the post-flip favourite won, 0 otherwise. +/// Update bankroll, streaks, and running peak-to-trough drawdown. +/// +/// +/// +public static class BacktestSimulator +{ + public static BacktestResult Run( + BacktestStrategy strategy, + IReadOnlyList candidates, + IReadOnlyDictionary? eventTitles = null) + { + ArgumentNullException.ThrowIfNull(strategy); + ArgumentNullException.ThrowIfNull(candidates); + + var bankroll = strategy.StartingBankroll; + var peakBankroll = bankroll; + var maxDrawdown = 0m; + decimal? maxDrawdownPct = null; + + var trace = new List(); + var totalStaked = 0m; + var totalReturned = 0m; + var wins = 0; + var losses = 0; + var skippedByThreshold = 0; + var skippedByDataQuality = 0; + var skippedByBankroll = 0; + var currentWinStreak = 0; + var currentLossStreak = 0; + var maxWinStreak = 0; + var maxLossStreak = 0; + + // Process in chronological order so bankroll progression is meaningful. + var ordered = candidates + .OrderBy(c => c.Anomaly.DetectedAt) + .ToList(); + + foreach (var candidate in ordered) + { + if (candidate.Anomaly.Score < strategy.MinScore) + { + skippedByThreshold++; + continue; + } + + var postFav = candidate.Evidence.PostSuspension.Favourite; + var isTwoWay = candidate.Evidence.PreSuspension.PDraw is null + && candidate.Evidence.PostSuspension.PDraw is null; + + if (isTwoWay && candidate.Result.WinnerSide == Side.Draw) + { + // Data inconsistency — refuse to grade. + skippedByDataQuality++; + continue; + } + + var (postRate, postProb) = ExtractPostFlipRateAndProbability(candidate.Evidence, postFav); + if (postRate is null || postProb is null) + { + skippedByDataQuality++; + continue; + } + + var stake = SizeStake( + strategy: strategy, + bankroll: bankroll, + postRate: postRate.Value, + postProb: postProb.Value); + + if (stake <= 0m || stake > bankroll) + { + // Either Kelly returned no edge, or the user is broke. Either way + // do not place this bet. + skippedByBankroll++; + continue; + } + + var isWin = postFav == candidate.Result.WinnerSide; + var payout = isWin ? stake * postRate.Value : 0m; + + bankroll = bankroll - stake + payout; + totalStaked += stake; + totalReturned += payout; + + if (isWin) + { + wins++; + currentWinStreak++; + currentLossStreak = 0; + maxWinStreak = Math.Max(maxWinStreak, currentWinStreak); + } + else + { + losses++; + currentLossStreak++; + currentWinStreak = 0; + maxLossStreak = Math.Max(maxLossStreak, currentLossStreak); + } + + // Drawdown tracking: peak is the running maximum bankroll observed + // before the current point; drawdown is peak − current. We update + // peak only on new highs so the trough is measured from the right + // reference. + if (bankroll > peakBankroll) + { + peakBankroll = bankroll; + } + else + { + var dd = peakBankroll - bankroll; + if (dd > maxDrawdown) + { + maxDrawdown = dd; + maxDrawdownPct = peakBankroll > 0m + ? Math.Round((dd / peakBankroll) * 100m, 2) + : null; + } + } + + // Round money columns away-from-zero so a -0.005 stake reads as "-0.01" + // — the convention every accountant in the world expects. + trace.Add(new BacktestTrace( + AnomalyId: candidate.Anomaly.Id, + EventId: candidate.Anomaly.EventId, + DetectedAt: candidate.Anomaly.DetectedAt, + Score: candidate.Anomaly.Score, + Sport: candidate.Sport, + PostFlipFavourite: postFav, + TakenRate: postRate.Value, + Stake: Math.Round(stake, 2, MidpointRounding.AwayFromZero), + WinnerSide: candidate.Result.WinnerSide, + IsWin: isWin, + Payout: Math.Round(payout, 2, MidpointRounding.AwayFromZero), + BankrollAfter: Math.Round(bankroll, 2, MidpointRounding.AwayFromZero))); + } + + decimal? roi = totalStaked > 0m + ? Math.Round(((bankroll - strategy.StartingBankroll) / totalStaked) * 100m, 2, + MidpointRounding.AwayFromZero) + : null; + + var totalSkipped = skippedByThreshold + skippedByDataQuality + skippedByBankroll; + + return new BacktestResult( + StartingBankroll: strategy.StartingBankroll, + FinalBankroll: Math.Round(bankroll, 2, MidpointRounding.AwayFromZero), + NetProfit: Math.Round(bankroll - strategy.StartingBankroll, 2, MidpointRounding.AwayFromZero), + RoiPercent: roi, + TotalStaked: Math.Round(totalStaked, 2, MidpointRounding.AwayFromZero), + TotalReturned: Math.Round(totalReturned, 2, MidpointRounding.AwayFromZero), + MaxDrawdown: Math.Round(maxDrawdown, 2, MidpointRounding.AwayFromZero), + MaxDrawdownPercent: maxDrawdownPct, + BetsPlaced: trace.Count, + Wins: wins, + Losses: losses, + Skipped: totalSkipped, + SkippedByThreshold: skippedByThreshold, + SkippedByDataQuality: skippedByDataQuality, + SkippedByBankroll: skippedByBankroll, + MaxWinStreak: maxWinStreak, + MaxLossStreak: maxLossStreak, + Trace: trace, + EventTitles: eventTitles + ?? new Dictionary()); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static (decimal? Rate, decimal? Probability) ExtractPostFlipRateAndProbability( + AnomalyDetection.AnomalyEvidenceData evidence, + Side favourite) + { + var post = evidence.PostSuspension; + return favourite switch + { + Side.Side1 => (post.Rate1, post.P1), + Side.Side2 => (post.Rate2, post.P2), + Side.Draw => (post.RateDraw, post.PDraw), + _ => (null, null), + }; + } + + private static decimal SizeStake( + BacktestStrategy strategy, + decimal bankroll, + decimal postRate, + decimal postProb) + { + if (bankroll <= 0m) return 0m; + + return strategy.StakeRule switch + { + StakeRule.Flat => strategy.FlatStake, + StakeRule.PercentOfBankroll => bankroll * strategy.PercentOfBankroll, + StakeRule.Kelly => ComputeKellyStake( + bankroll: bankroll, + postRate: postRate, + postProb: postProb, + fraction: strategy.KellyFraction), + _ => 0m, + }; + } + + private static decimal ComputeKellyStake( + decimal bankroll, + decimal postRate, + decimal postProb, + decimal fraction) + { + // Kelly: f* = (b·p − q) / b where b = rate − 1, p = win prob, q = 1 − p. + // Skip non-positive edge (no bet rather than betting "negative size"). + var b = postRate - 1m; + if (b <= 0m) return 0m; + + var p = postProb; + var q = 1m - p; + var fullKelly = ((b * p) - q) / b; + + if (fullKelly <= 0m) return 0m; + + // Quarter / half / etc.-Kelly: scale full edge by the configured fraction. + var stakeFraction = fullKelly * fraction; + return bankroll * stakeFraction; + } +} diff --git a/src/Marathon.Domain/Backtesting/BacktestStrategy.cs b/src/Marathon.Domain/Backtesting/BacktestStrategy.cs new file mode 100644 index 0000000..ecf5bff --- /dev/null +++ b/src/Marathon.Domain/Backtesting/BacktestStrategy.cs @@ -0,0 +1,72 @@ +namespace Marathon.Domain.Backtesting; + +/// +/// Parameters fed to . The strategy is "for every +/// SuspensionFlip anomaly with score ≥ , stake +/// according to on the post-flip favourite at the +/// post-flip rate, then settle against the actual EventResult." +/// +/// +/// Initial bankroll for compounding stake rules. Must be positive. +/// +/// +/// Lower bound on Anomaly.Score — only anomalies at or above this +/// threshold are bet on. Must be in [0, 1]. +/// +/// How to size each bet — see the enum docs. +/// +/// Used when is . +/// Must be positive. +/// +/// +/// Used when is +/// . Expressed as a +/// fraction in (0, 1]. e.g. 0.02 = 2 % of bankroll. +/// +/// +/// Used when is . +/// Multiplier on the raw Kelly fraction; in (0, 1]. 0.25 (quarter-Kelly) is +/// the conservative default. +/// +public sealed record BacktestStrategy( + decimal StartingBankroll, + decimal MinScore, + StakeRule StakeRule, + decimal FlatStake, + decimal PercentOfBankroll, + decimal KellyFraction) +{ + public decimal StartingBankroll { get; } = StartingBankroll > 0m + ? StartingBankroll + : throw new ArgumentOutOfRangeException(nameof(StartingBankroll), + StartingBankroll, "StartingBankroll must be positive."); + + public decimal MinScore { get; } = MinScore is >= 0m and <= 1m + ? MinScore + : throw new ArgumentOutOfRangeException(nameof(MinScore), + MinScore, "MinScore must be in [0, 1]."); + + public decimal FlatStake { get; } = FlatStake > 0m + ? FlatStake + : throw new ArgumentOutOfRangeException(nameof(FlatStake), + FlatStake, "FlatStake must be positive."); + + public decimal PercentOfBankroll { get; } = PercentOfBankroll is > 0m and <= 1m + ? PercentOfBankroll + : throw new ArgumentOutOfRangeException(nameof(PercentOfBankroll), + PercentOfBankroll, "PercentOfBankroll must be in (0, 1]."); + + public decimal KellyFraction { get; } = KellyFraction is > 0m and <= 1m + ? KellyFraction + : throw new ArgumentOutOfRangeException(nameof(KellyFraction), + KellyFraction, "KellyFraction must be in (0, 1]."); + + /// Sensible defaults — flat-stake, score ≥ 0.45, ¼-Kelly waiting in the wings. + public static BacktestStrategy Default { get; } = new( + StartingBankroll: 1000m, + MinScore: 0.45m, + StakeRule: StakeRule.Flat, + FlatStake: 50m, + PercentOfBankroll: 0.02m, + KellyFraction: 0.25m); +} diff --git a/src/Marathon.Domain/Backtesting/StakeRule.cs b/src/Marathon.Domain/Backtesting/StakeRule.cs new file mode 100644 index 0000000..b5ee9e7 --- /dev/null +++ b/src/Marathon.Domain/Backtesting/StakeRule.cs @@ -0,0 +1,28 @@ +namespace Marathon.Domain.Backtesting; + +/// +/// How the simulator decides how much to stake on each bet during a backtest. +/// +public enum StakeRule +{ + /// + /// Same fixed amount every bet, independent of bankroll. + /// Suitable for "flat-betting" historical analysis — the simplest baseline. + /// + Flat, + + /// + /// A fixed percentage of the current bankroll every bet. Compounds: a + /// winning streak grows stake size; losses shrink it. Equivalent to + /// proportional betting. + /// + PercentOfBankroll, + + /// + /// Fractional Kelly using the post-flip implied probability as the edge + /// estimate: f = ((b·p) − q) / b, scaled by the configured + /// . Negative-expectation bets + /// stake zero (and are skipped). Half/quarter-Kelly is the usual practice. + /// + Kelly, +} diff --git a/src/Marathon.UI/Components/NavBody.razor b/src/Marathon.UI/Components/NavBody.razor index f3eb6ff..ae99beb 100644 --- a/src/Marathon.UI/Components/NavBody.razor +++ b/src/Marathon.UI/Components/NavBody.razor @@ -47,6 +47,10 @@ @L["Nav.MyBets"] + + + @L["Nav.Backtest"] + diff --git a/src/Marathon.UI/Pages/Anomalies/Backtest.razor b/src/Marathon.UI/Pages/Anomalies/Backtest.razor new file mode 100644 index 0000000..81fb91d --- /dev/null +++ b/src/Marathon.UI/Pages/Anomalies/Backtest.razor @@ -0,0 +1,867 @@ +@* + Backtest — historical strategy replayer. + + Picks a confidence threshold and a staking rule, runs the simulator over + every graded anomaly, and reports the P&L story: equity curve, KPI strip, + per-bet trade trace. Same editorial-quant tone as Insights / Journal — + accent kicker (not anomaly-red), staged m-rise reveal, m-card form, + inline SVG equity curve, mono table. +*@ + +@page "/anomalies/backtest" +@using Marathon.Domain.Backtesting +@implements IDisposable +@inject IStringLocalizer L +@inject IBacktestService Service +@inject NavigationManager Nav +@inject ISnackbar Snackbar +@inject ILogger Logger + +@L["App.Title"] · @L["Nav.Backtest"] + +
+
+
+ @L["Backtest.Kicker"] +

@L["Backtest.Title"]

+

@L["Backtest.Lede"]

+
+
+ + @* ---------- Strategy form ---------- *@ +
+
+ @L["Backtest.Section.Strategy"] +
+ +
+
+
+ + +
+ +
+ + + @L["Backtest.Field.MinScore.Hint"] +
+ +
+ + + @foreach (var rule in _stakeRules) + { + @StakeRuleLabel(rule) + } + +
+ + @switch (_form.StakeRule) + { + case StakeRule.Flat: +
+ + +
+ break; + case StakeRule.PercentOfBankroll: +
+ + +
+ break; + case StakeRule.Kelly: +
+ + + @L["Backtest.Field.KellyFraction.Hint"] +
+ break; + } +
+ + @if (!string.IsNullOrEmpty(_formError)) + { +

@_formError

+ } + +
+ +
+
+
+ + @if (_vm is { } vm) + { +
+ + @* ---------- Result headline ---------- *@ +
+
+ @L["Backtest.Section.Headline"] +
+ +
+
+ @L["Backtest.Stat.FinalBankroll"] + @FormatDecimal(vm.FinalBankroll) +
+
+ @L["Backtest.Stat.NetProfit"] + @FormatSignedDecimal(vm.NetProfit, vm.BetsPlaced) +
+
+ @L["Backtest.Stat.Roi"] + @FormatSignedPercent(vm.RoiPercent) +
+
+ @L["Backtest.Stat.MaxDrawdown"] + @if (vm.MaxDrawdown == 0m && vm.MaxDrawdownPercent is null) + { + + } + else + { + @FormatSignedDecimal(-vm.MaxDrawdown, 1) + @FormatSignedPercent(vm.MaxDrawdownPercent is null ? null : -vm.MaxDrawdownPercent.Value) + } +
+
+ +
+ @L["Backtest.Stat.BetsPlaced"] @vm.BetsPlaced + + @L["Backtest.Stat.Wins"] @vm.Wins + + @L["Backtest.Stat.Losses"] @vm.Losses + + @L["Backtest.Stat.Skipped"] @vm.Skipped + + @L["Backtest.Stat.MaxWinStreak"] @vm.MaxWinStreak + + @L["Backtest.Stat.MaxLossStreak"] @vm.MaxLossStreak +
+
+ + @if (vm.BetsPlaced == 0 && vm.Trace.Count == 0 && vm.Skipped == 0) + { +
+ + @L["Common.Empty"] + +

+ @L["Backtest.Empty.NoData"] +

+
+ } + else if (vm.BetsPlaced == 0) + { +
+ + @L["Common.Empty"] + +

+ @L["Backtest.Empty.NoBetsPlaced"] +

+
+ } + else + { +
+ + @* ---------- Equity curve ---------- *@ +
+
+ @L["Backtest.Section.Equity"] +
+ +
+ @if (vm.EquityCurve.Count == 0) + { +
+

@L["Backtest.Empty.NoBetsPlaced"]

+
+ } + else + { + @RenderEquityCurve(vm) + } +
+
+ +
+ + @* ---------- Trade trace ---------- *@ +
+
+ @L["Backtest.Section.Trace"] + @vm.Trace.Count +
+ +
+ + + + + + + + + + + + + + + + + @foreach (var row in vm.Trace) + { + var local = row; + var trace = local.Trace; + + + + + + + + + + + + + } + +
@L["Backtest.Column.DetectedAt"]@L["Backtest.Column.Match"]@L["Backtest.Column.Score"]@L["Backtest.Column.Pick"]@L["Backtest.Column.Rate"]@L["Backtest.Column.Stake"]@L["Backtest.Column.Payout"]@L["Backtest.Column.Bankroll"]@L["Backtest.Column.Outcome"]
@trace.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)@local.EventTitle@trace.Score.ToString("0.00", CultureInfo.InvariantCulture)@SideLabel(trace.PostFlipFavourite)@trace.TakenRate.ToString("0.00", CultureInfo.InvariantCulture)@trace.Stake.ToString("0.00", CultureInfo.InvariantCulture) + @trace.Payout.ToString("0.00", CultureInfo.InvariantCulture) + @trace.BankrollAfter.ToString("0.00", CultureInfo.InvariantCulture) + + @(trace.IsWin ? L["Backtest.Outcome.Win"] : L["Backtest.Outcome.Loss"]) + + + + @L["Insights.Action.OpenAnomaly"] + + +
+
+
+ } + } +
+ + + +@code { + private static readonly StakeRule[] _stakeRules = + { StakeRule.Flat, StakeRule.PercentOfBankroll, StakeRule.Kelly }; + + private BacktestForm _form = new(); + private BacktestVm? _vm; + private bool _running; + private string? _formError; + private CancellationTokenSource? _runCts; + + private async Task RunAsync() + { + if (_running) return; + _formError = null; + + if (!_form.IsValid(out var err)) + { + _formError = err; + StateHasChanged(); + return; + } + + _runCts?.Cancel(); + _runCts = new CancellationTokenSource(); + var ct = _runCts.Token; + + _running = true; + StateHasChanged(); + + try + { + var result = await Service.RunAsync(_form, ct); + if (ct.IsCancellationRequested) return; + _vm = result; + } + catch (OperationCanceledException) { /* superseded */ } + catch (ArgumentException ex) + { + _formError = ex.Message; + } + catch (Exception ex) + { + Logger.LogError(ex, "Backtest simulation failed."); + Snackbar.Add(L["Backtest.Error.Generic"].Value, Severity.Error); + } + finally + { + _running = false; + StateHasChanged(); + } + } + + private void OnStakeRuleChanged(StakeRule next) + { + _form.StakeRule = next; + _formError = null; + } + + private void OpenAnomaly(MouseEventArgs e, Guid anomalyId) + { + Nav.NavigateTo("/anomalies/" + anomalyId.ToString()); + } + + // ---- Equity curve rendering -------------------------------------------- + + private RenderFragment RenderEquityCurve(BacktestVm vm) => builder => + { + var points = vm.EquityCurve; + var pointCount = points.Count; + + // Y-axis bounds: include starting bankroll + min/max bankroll, 5% padding. + decimal minB = vm.StartingBankroll; + decimal maxB = vm.StartingBankroll; + foreach (var p in points) + { + if (p.Bankroll < minB) minB = p.Bankroll; + if (p.Bankroll > maxB) maxB = p.Bankroll; + } + var rawRange = maxB - minB; + if (rawRange <= 0m) rawRange = Math.Max(1m, Math.Abs(vm.StartingBankroll) * 0.1m); + var pad = rawRange * 0.05m; + var yMin = minB - pad; + var yMax = maxB + pad; + var yRange = yMax - yMin; + if (yRange <= 0m) yRange = 1m; + + // SVG canvas (viewBox 0..1000 x 0..200). + const int vbW = 1000; + const int vbH = 200; + const int padL = 56; + const int padR = 16; + const int padT = 12; + const int padB = 22; + var plotW = vbW - padL - padR; + var plotH = vbH - padT - padB; + + double XAt(int i) + { + if (pointCount <= 1) return padL + plotW / 2.0; + return padL + (plotW * (double)i) / (pointCount - 1); + } + double YAt(decimal bankroll) + { + var t = (double)((bankroll - yMin) / yRange); + // Flip — SVG y grows downward. + return padT + (1.0 - t) * plotH; + } + + // Baseline (StartingBankroll) y. + var baselineY = YAt(vm.StartingBankroll); + + // Build polyline points string. + var sb = new System.Text.StringBuilder(); + for (var i = 0; i < pointCount; i++) + { + if (i > 0) sb.Append(' '); + sb.Append(XAt(i).ToString("0.##", CultureInfo.InvariantCulture)); + sb.Append(','); + sb.Append(YAt(points[i].Bankroll).ToString("0.##", CultureInfo.InvariantCulture)); + } + + var pathTone = vm.FinalBankroll >= vm.StartingBankroll ? "positive" : "negative"; + + builder.OpenElement(0, "svg"); + builder.AddAttribute(1, "class", "m-backtest__equity-svg"); + builder.AddAttribute(2, "viewBox", "0 0 " + vbW.ToString(CultureInfo.InvariantCulture) + " " + vbH.ToString(CultureInfo.InvariantCulture)); + builder.AddAttribute(3, "preserveAspectRatio", "none"); + builder.AddAttribute(4, "role", "img"); + builder.AddAttribute(5, "aria-label", L["Backtest.Section.Equity"].Value); + builder.AddAttribute(6, "data-test", "backtest-equity-svg"); + + // Baseline (dotted horizontal at starting bankroll). + builder.OpenElement(10, "line"); + builder.AddAttribute(11, "class", "m-backtest__equity-baseline"); + builder.AddAttribute(12, "x1", padL.ToString(CultureInfo.InvariantCulture)); + builder.AddAttribute(13, "y1", baselineY.ToString("0.##", CultureInfo.InvariantCulture)); + builder.AddAttribute(14, "x2", (vbW - padR).ToString(CultureInfo.InvariantCulture)); + builder.AddAttribute(15, "y2", baselineY.ToString("0.##", CultureInfo.InvariantCulture)); + builder.CloseElement(); + + // Polyline. + builder.OpenElement(20, "polyline"); + builder.AddAttribute(21, "class", "m-backtest__equity-path m-backtest__equity-path--" + pathTone); + builder.AddAttribute(22, "points", sb.ToString()); + builder.CloseElement(); + + // Y-axis ticks: yMax (top), starting bankroll (middle), yMin (bottom). + var topLabel = FormatTickValue(yMax); + var midLabel = FormatTickValue(vm.StartingBankroll); + var botLabel = FormatTickValue(yMin); + + // Top tick + builder.OpenElement(30, "text"); + builder.AddAttribute(31, "class", "m-backtest__equity-tick"); + builder.AddAttribute(32, "x", (padL - 6).ToString(CultureInfo.InvariantCulture)); + builder.AddAttribute(33, "y", (padT + 8).ToString(CultureInfo.InvariantCulture)); + builder.AddAttribute(34, "text-anchor", "end"); + builder.AddContent(35, topLabel); + builder.CloseElement(); + + // Mid tick (starting bankroll anchor) + builder.OpenElement(40, "text"); + builder.AddAttribute(41, "class", "m-backtest__equity-tick m-backtest__equity-tick--anchor"); + builder.AddAttribute(42, "x", (padL - 6).ToString(CultureInfo.InvariantCulture)); + builder.AddAttribute(43, "y", (baselineY + 3).ToString("0.##", CultureInfo.InvariantCulture)); + builder.AddAttribute(44, "text-anchor", "end"); + builder.AddContent(45, midLabel); + builder.CloseElement(); + + // Bottom tick + builder.OpenElement(50, "text"); + builder.AddAttribute(51, "class", "m-backtest__equity-tick"); + builder.AddAttribute(52, "x", (padL - 6).ToString(CultureInfo.InvariantCulture)); + builder.AddAttribute(53, "y", (vbH - padB + 12).ToString(CultureInfo.InvariantCulture)); + builder.AddAttribute(54, "text-anchor", "end"); + builder.AddContent(55, botLabel); + builder.CloseElement(); + + // Final-value end label (on far right at end point). + if (pointCount > 0) + { + var endY = YAt(points[pointCount - 1].Bankroll); + builder.OpenElement(60, "text"); + builder.AddAttribute(61, "class", "m-backtest__equity-tick m-backtest__equity-tick--anchor"); + builder.AddAttribute(62, "x", (vbW - padR - 4).ToString(CultureInfo.InvariantCulture)); + builder.AddAttribute(63, "y", (endY - 6).ToString("0.##", CultureInfo.InvariantCulture)); + builder.AddAttribute(64, "text-anchor", "end"); + builder.AddContent(65, FormatTickValue(points[pointCount - 1].Bankroll)); + builder.CloseElement(); + } + + builder.CloseElement(); // svg + }; + + // ---- Formatting / labels ----------------------------------------------- + + private string StakeRuleLabel(StakeRule rule) => rule switch + { + StakeRule.Flat => L["Backtest.StakeRule.Flat"], + StakeRule.PercentOfBankroll => L["Backtest.StakeRule.PercentOfBankroll"], + StakeRule.Kelly => L["Backtest.StakeRule.Kelly"], + _ => rule.ToString(), + }; + + private string SideLabel(Side side) => side switch + { + Side.Side1 => L["Journal.Side.Side1"], + Side.Side2 => L["Journal.Side.Side2"], + Side.Draw => L["Journal.Side.Draw"], + Side.Less => L["Journal.Side.Less"], + Side.More => L["Journal.Side.More"], + _ => side.ToString(), + }; + + private static string FormatDecimal(decimal value) => + value.ToString("0.00", CultureInfo.InvariantCulture); + + private static string FormatSignedDecimal(decimal value, int betsPlaced) + { + if (betsPlaced == 0) return "—"; + var sign = value > 0m ? "+" : (value < 0m ? "-" : ""); + var abs = Math.Abs(value); + return sign + abs.ToString("0.00", CultureInfo.InvariantCulture); + } + + private static string FormatSignedPercent(decimal? value) + { + if (value is null) return "—"; + var v = value.Value; + var sign = v > 0m ? "+" : (v < 0m ? "-" : ""); + var abs = Math.Abs(v); + return sign + abs.ToString("0.0", CultureInfo.InvariantCulture) + "%"; + } + + private static string FormatTickValue(decimal value) => + value.ToString("0", CultureInfo.InvariantCulture); + + private static string BankrollTone(BacktestVm vm) + { + if (vm.BetsPlaced == 0) return "neutral"; + if (vm.FinalBankroll > vm.StartingBankroll) return "positive"; + if (vm.FinalBankroll < vm.StartingBankroll) return "negative"; + return "neutral"; + } + + private static string ProfitTone(BacktestVm vm) + { + if (vm.BetsPlaced == 0) return "neutral"; + if (vm.NetProfit > 0m) return "positive"; + if (vm.NetProfit < 0m) return "negative"; + return "neutral"; + } + + private static string RoiTone(decimal? roi) => roi switch + { + null => "neutral", + > 0m => "positive", + < 0m => "negative", + _ => "neutral", + }; + + public void Dispose() + { + _runCts?.Cancel(); + _runCts?.Dispose(); + } +} diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 172ab34..721dc92 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -410,4 +410,52 @@ No pending bets needed grading. Graded {0} pending bet(s). Delete this bet permanently? + + Backtest + Simulator + Replay the detector against history + Run a hypothetical strategy over every anomaly the detector has flagged. Choose a confidence threshold and a staking rule — the simulator settles every bet against the actual event result, compounds bankroll, and reports the headline numbers you need to judge edge. + Strategy + Result + Equity curve + Trade trace + Starting bankroll + Min anomaly score + Only bet anomalies at or above this confidence. + Staking rule + Flat stake + Percent of bankroll + Kelly fraction + 0.25 (quarter-Kelly) is the conservative default. + Flat + % of bankroll + Kelly + Run simulation + Simulating… + Final bankroll + Net profit + ROI + Max drawdown + Bets placed + Wins + Losses + Skipped + Max win streak + Max loss streak + Total staked + Total returned + Detected + Match + Score + Pick + Rate + Stake + Payout + Bankroll + Outcome + Win + Loss + No graded anomalies to simulate yet. Run the results loader so the detector has outcomes to replay against. + The strategy placed zero bets — try lowering the score threshold, or switch staking rule. + Simulation failed — check the form values and try again. diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 38e4371..6c3bccd 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -423,4 +423,52 @@ Ожидающих ставок к расчёту нет. Рассчитано ожидающих: {0}. Удалить эту ставку безвозвратно? + + Бэктест + Симулятор + Прогон детектора по истории + Запустите гипотетическую стратегию на всех зафиксированных аномалиях. Выберите порог уверенности и правило стейкинга — симулятор разыграет каждую ставку против реального исхода, нарастит банк и покажет ключевые метрики для оценки преимущества. + Стратегия + Результат + Кривая банка + Хронология ставок + Стартовый банк + Мин. score аномалии + Ставим только при уверенности не ниже этого порога. + Правило стейкинга + Фикс. ставка + Процент от банка + Доля Келли + 0,25 (четверть-Келли) — консервативный дефолт. + Фиксированная + % от банка + Келли + Запустить + Симуляция… + Итоговый банк + Чистая прибыль + ROI + Макс. просадка + Поставлено + Победы + Поражения + Пропущено + Макс. серия побед + Макс. серия пораж. + Всего поставлено + Всего возвращено + Замечено + Матч + Score + Выбор + Кэф + Ставка + Выплата + Банк + Исход + Победа + Проигрыш + Аномалий с результатом ещё нет. Запустите загрузчик результатов, чтобы симулятору было на чём прогоняться. + Стратегия не сделала ни одной ставки — снизьте порог score или поменяйте правило стейкинга. + Симуляция упала — проверьте параметры формы и повторите. diff --git a/src/Marathon.UI/Services/BacktestService.cs b/src/Marathon.UI/Services/BacktestService.cs new file mode 100644 index 0000000..ff13f71 --- /dev/null +++ b/src/Marathon.UI/Services/BacktestService.cs @@ -0,0 +1,61 @@ +using Marathon.Application.UseCases; + +namespace Marathon.UI.Services; + +/// +/// Page-facing implementation of . The use case +/// hands back per-event titles inside the result so the service does no +/// repository I/O of its own. +/// +public sealed class BacktestService : IBacktestService +{ + private readonly RunBacktestUseCase _useCase; + + public BacktestService(RunBacktestUseCase useCase) + { + _useCase = useCase ?? throw new ArgumentNullException(nameof(useCase)); + } + + public async Task RunAsync(BacktestForm form, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(form); + + if (!form.IsValid(out var err)) + throw new ArgumentException(err ?? "Invalid form.", nameof(form)); + + var result = await _useCase.ExecuteAsync(form.ToStrategy(), ct).ConfigureAwait(false); + + var rows = result.Trace + .Select(t => new BacktestTraceRow( + Trace: t, + EventTitle: result.EventTitles.TryGetValue(t.EventId, out var title) + ? title + : t.EventId.Value)) + .ToList(); + + var curve = result.Trace + .Select(t => new EquityPoint(t.DetectedAt, t.BankrollAfter)) + .ToList(); + + return new BacktestVm( + StartingBankroll: result.StartingBankroll, + FinalBankroll: result.FinalBankroll, + NetProfit: result.NetProfit, + RoiPercent: result.RoiPercent, + TotalStaked: result.TotalStaked, + TotalReturned: result.TotalReturned, + MaxDrawdown: result.MaxDrawdown, + MaxDrawdownPercent: result.MaxDrawdownPercent, + BetsPlaced: result.BetsPlaced, + Wins: result.Wins, + Losses: result.Losses, + Skipped: result.Skipped, + SkippedByThreshold: result.SkippedByThreshold, + SkippedByDataQuality: result.SkippedByDataQuality, + SkippedByBankroll: result.SkippedByBankroll, + MaxWinStreak: result.MaxWinStreak, + MaxLossStreak: result.MaxLossStreak, + Trace: rows, + EquityCurve: curve); + } +} diff --git a/src/Marathon.UI/Services/BacktestViewModels.cs b/src/Marathon.UI/Services/BacktestViewModels.cs new file mode 100644 index 0000000..057f8a7 --- /dev/null +++ b/src/Marathon.UI/Services/BacktestViewModels.cs @@ -0,0 +1,89 @@ +using Marathon.Domain.Backtesting; +using Marathon.Domain.Enums; + +namespace Marathon.UI.Services; + +/// +/// Form bound by the Backtest page. Loose-typed so MudBlazor fields can bind +/// raw numerics; the service translates this into a domain +/// after validation. +/// +public sealed class BacktestForm +{ + public decimal StartingBankroll { get; set; } = 1000m; + public decimal MinScore { get; set; } = 0.45m; + public StakeRule StakeRule { get; set; } = StakeRule.Flat; + public decimal FlatStake { get; set; } = 50m; + + /// Bound to the UI as a percentage 0–100; converted to a fraction before sim. + public decimal PercentOfBankrollPercent { get; set; } = 2m; + + /// Bound to the UI as a percentage 0–100; converted to a fraction before sim. + public decimal KellyFractionPercent { get; set; } = 25m; + + public bool IsValid(out string? error) + { + if (StartingBankroll <= 0m) { error = "Bankroll must be positive."; return false; } + if (MinScore is < 0m or > 1m) { error = "Min score must be in [0, 1]."; return false; } + switch (StakeRule) + { + case StakeRule.Flat: + if (FlatStake <= 0m) { error = "Flat stake must be positive."; return false; } + if (FlatStake > StartingBankroll) { error = "Flat stake exceeds starting bankroll."; return false; } + break; + case StakeRule.PercentOfBankroll: + if (PercentOfBankrollPercent is <= 0m or > 100m) + { error = "Percent of bankroll must be in (0, 100]."; return false; } + break; + case StakeRule.Kelly: + if (KellyFractionPercent is <= 0m or > 100m) + { error = "Kelly fraction must be in (0, 100]."; return false; } + break; + } + error = null; + return true; + } + + public BacktestStrategy ToStrategy() => + new( + StartingBankroll: StartingBankroll, + MinScore: MinScore, + StakeRule: StakeRule, + FlatStake: FlatStake, + PercentOfBankroll: PercentOfBankrollPercent / 100m, + KellyFraction: KellyFractionPercent / 100m); +} + +/// UI-facing projection of . +public sealed record BacktestVm( + decimal StartingBankroll, + decimal FinalBankroll, + decimal NetProfit, + decimal? RoiPercent, + decimal TotalStaked, + decimal TotalReturned, + decimal MaxDrawdown, + decimal? MaxDrawdownPercent, + int BetsPlaced, + int Wins, + int Losses, + int Skipped, + int SkippedByThreshold, + int SkippedByDataQuality, + int SkippedByBankroll, + int MaxWinStreak, + int MaxLossStreak, + IReadOnlyList Trace, + IReadOnlyList EquityCurve); + +/// +/// Trace row plus pre-shaped event title for the link-back affordance. +/// +public sealed record BacktestTraceRow( + BacktestTrace Trace, + string EventTitle); + +/// One point on the equity curve — bankroll over time. +/// When the bet would have been placed. +/// Bankroll after this bet settled. +public sealed record EquityPoint(DateTimeOffset DetectedAt, decimal Bankroll); diff --git a/src/Marathon.UI/Services/IBacktestService.cs b/src/Marathon.UI/Services/IBacktestService.cs new file mode 100644 index 0000000..852e999 --- /dev/null +++ b/src/Marathon.UI/Services/IBacktestService.cs @@ -0,0 +1,13 @@ +namespace Marathon.UI.Services; + +/// +/// Browsing facade in front of . +/// The Backtest page binds to this — view-model shaping and event-title +/// joining live here so the page stays declarative. +/// +public interface IBacktestService +{ + /// Validates the form, runs the simulator, projects for the UI. + /// Form fails its own validation. + Task RunAsync(BacktestForm form, CancellationToken ct); +} diff --git a/src/Marathon.UI/Services/UiServicesExtensions.cs b/src/Marathon.UI/Services/UiServicesExtensions.cs index e0b4fb8..ae990d0 100644 --- a/src/Marathon.UI/Services/UiServicesExtensions.cs +++ b/src/Marathon.UI/Services/UiServicesExtensions.cs @@ -60,6 +60,7 @@ public static class UiServicesExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Settings writer — file path is host-resolved. services.AddSingleton(_ => new JsonSettingsWriter(settingsLocalPath)); diff --git a/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs new file mode 100644 index 0000000..980c294 --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs @@ -0,0 +1,130 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +using Marathon.Application.UseCases; +using Marathon.Domain.Backtesting; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace Marathon.Application.Tests.UseCases; + +/// +/// Tests the orchestration: anomaly + event + result join + parse + delegate to +/// the simulator. The simulator's own correctness is covered in +/// Marathon.Domain.Tests. +/// +public sealed class RunBacktestUseCaseTests +{ + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + private static readonly DateTimeOffset BaseTime = + new(2026, 5, 10, 18, 0, 0, MoscowOffset); + + private readonly IAnomalyRepository _anomalies = Substitute.For(); + private readonly IEventRepository _events = Substitute.For(); + private readonly IResultRepository _results = Substitute.For(); + + private RunBacktestUseCase CreateSut() => + new(_anomalies, _events, _results, NullLogger.Instance); + + private const string FlipEvidence = """ + { + "suspensionGapSeconds": 90, + "preSuspension": { + "capturedAt": "2026-05-10T18:00:00+03:00", + "p1": 0.55, "pDraw": 0.20, "p2": 0.25, + "rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0 + }, + "postSuspension": { + "capturedAt": "2026-05-10T18:02:30+03:00", + "p1": 0.25, "pDraw": 0.20, "p2": 0.55, + "rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8 + } + } + """; + + private static Anomaly MakeAnomaly(EventId eventId, decimal score = 0.55m, string? evidence = null) => + new(Guid.NewGuid(), eventId, BaseTime, AnomalyKind.SuspensionFlip, + score, evidence ?? FlipEvidence); + + private static Event MakeEvent(EventId id) => + new(id, new SportCode(11), "BY", "L1", "Cat", BaseTime, "Team A", "Team B"); + + private static BacktestStrategy DefaultStrategy(decimal minScore = 0.30m) => + new(StartingBankroll: 1000m, MinScore: minScore, + StakeRule: StakeRule.Flat, + FlatStake: 100m, PercentOfBankroll: 0.02m, KellyFraction: 0.25m); + + [Fact] + public async Task Should_ReturnEmptyResult_When_NoAnomaliesExist() + { + _anomalies.ListAsync(Arg.Any()) + .Returns(Array.Empty().ToList().AsReadOnly()); + + var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None); + + result.BetsPlaced.Should().Be(0); + result.FinalBankroll.Should().Be(1000m); + } + + [Fact] + public async Task Should_SimulateBet_When_AnomalyHasResult() + { + // Anomaly with result, Side2 (post-flip favourite) wins → +100. + var id = new EventId("event-1"); + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] { MakeAnomaly(id, score: 0.55m) }.ToList().AsReadOnly()); + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id)); + _results.GetAsync(id, Arg.Any()) + .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + + var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None); + + result.BetsPlaced.Should().Be(1); + result.Wins.Should().Be(1); + result.NetProfit.Should().Be(80m, + "stake 100 at rate 1.8 — win pays 180, profit 80"); + } + + [Fact] + public async Task Should_FilterOut_AnomaliesWithoutResults() + { + var graded = new EventId("graded"); + var ungraded = new EventId("ungraded"); + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] { MakeAnomaly(graded), MakeAnomaly(ungraded) }.ToList().AsReadOnly()); + + _events.GetAsync(graded, Arg.Any()).Returns(MakeEvent(graded)); + _events.GetAsync(ungraded, Arg.Any()).Returns(MakeEvent(ungraded)); + + _results.GetAsync(graded, Arg.Any()) + .Returns(new EventResult(graded, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + _results.GetAsync(ungraded, Arg.Any()) + .Returns((EventResult?)null); + + var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None); + + // Only the graded anomaly should reach the simulator — the ungraded one is filtered out + // before the simulator, so it does NOT appear in result.Skipped. + (result.BetsPlaced + result.Skipped).Should().Be(1); + } + + [Fact] + public async Task Should_FilterOut_AnomaliesWithMalformedEvidence() + { + var id = new EventId("bad-evidence"); + _anomalies.ListAsync(Arg.Any()) + .Returns(new[] { MakeAnomaly(id, evidence: "{not json") }.ToList().AsReadOnly()); + + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id)); + _results.GetAsync(id, Arg.Any()) + .Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); + + var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None); + + result.BetsPlaced.Should().Be(0); + result.Skipped.Should().Be(0, + "malformed evidence is filtered before the simulator — not counted as a strategy skip"); + } +} diff --git a/tests/Marathon.Domain.Tests/Backtesting/BacktestSimulatorTests.cs b/tests/Marathon.Domain.Tests/Backtesting/BacktestSimulatorTests.cs new file mode 100644 index 0000000..92356a7 --- /dev/null +++ b/tests/Marathon.Domain.Tests/Backtesting/BacktestSimulatorTests.cs @@ -0,0 +1,386 @@ +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(); + } +}