feat(backtest): historical strategy backtester

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>
This commit is contained in:
2026-05-16 18:34:42 +03:00
parent 1ad896b07e
commit 0d52b7beff
17 changed files with 2249 additions and 0 deletions
@@ -37,6 +37,8 @@ public static class ApplicationModule
services.AddScoped<BuildBetJournalReportUseCase>(); services.AddScoped<BuildBetJournalReportUseCase>();
services.AddScoped<DeletePlacedBetUseCase>(); services.AddScoped<DeletePlacedBetUseCase>();
services.AddScoped<RunBacktestUseCase>();
return services; return services;
} }
} }
@@ -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;
/// <summary>
/// Loads every persisted anomaly paired with its event metadata and result,
/// constructs <see cref="BacktestCandidate"/> rows, and runs the pure
/// <see cref="BacktestSimulator"/> with the supplied strategy.
/// </summary>
/// <remarks>
/// <para>
/// 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).
/// </para>
/// <para>
/// 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 <see cref="BacktestResult.Skipped"/> counter only reflects
/// runs the strategy chose not to bet on (below threshold, no edge, etc.).
/// </para>
/// </remarks>
public sealed class RunBacktestUseCase
{
private readonly IAnomalyRepository _anomalies;
private readonly IEventRepository _events;
private readonly IResultRepository _results;
private readonly ILogger<RunBacktestUseCase> _logger;
public RunBacktestUseCase(
IAnomalyRepository anomalies,
IEventRepository events,
IResultRepository results,
ILogger<RunBacktestUseCase> 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<BacktestResult> 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<BacktestCandidate>());
}
// 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<DomainEventId, Event>(distinctEventIds.Count);
var resultLookup = new Dictionary<DomainEventId, EventResult>(distinctEventIds.Count);
var titles = new Dictionary<DomainEventId, string>(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<BacktestCandidate>(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;
}
}
@@ -0,0 +1,24 @@
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Input row for <see cref="BacktestSimulator"/> — 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.
/// </summary>
/// <param name="Anomaly">The flagged anomaly being simulated.</param>
/// <param name="Evidence">
/// Parsed evidence payload (pre- and post-suspension snapshots). The simulator
/// reads the post-suspension favourite and rate from here.
/// </param>
/// <param name="Result">Final event result — drives the win/loss verdict.</param>
/// <param name="Sport">Sport metadata, optional, surfaced into the trace row.</param>
public sealed record BacktestCandidate(
Anomaly Anomaly,
AnomalyEvidenceData Evidence,
EventResult Result,
SportCode? Sport);
@@ -0,0 +1,113 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// 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.
/// </summary>
/// <param name="StartingBankroll">Echoed from the strategy for the UI.</param>
/// <param name="FinalBankroll">Bankroll after the last simulated bet settled.</param>
/// <param name="NetProfit"><c>FinalBankroll StartingBankroll</c>.</param>
/// <param name="RoiPercent">
/// <c>NetProfit / TotalStaked × 100</c>. Null when no bets were placed
/// (no anomaly met the threshold, or the bankroll went to zero before any
/// stake could be sized).
/// </param>
/// <param name="TotalStaked">Sum of stake sizes across every settled bet.</param>
/// <param name="TotalReturned">Sum of gross returns across every settled bet.</param>
/// <param name="MaxDrawdown">
/// Largest peak-to-trough drop in bankroll observed during the run, as an
/// absolute amount. Always ≥ 0.
/// </param>
/// <param name="MaxDrawdownPercent">
/// <see cref="MaxDrawdown"/> as a percentage of the peak that preceded it.
/// Null when there were no draws (no bets or no losses).
/// </param>
/// <param name="BetsPlaced">Total bets the strategy actually placed.</param>
/// <param name="Wins">Settled bets whose post-flip favourite won.</param>
/// <param name="Losses">Settled bets whose post-flip favourite lost.</param>
/// <param name="Skipped">
/// Total anomalies inspected but skipped. Equals
/// <see cref="SkippedByThreshold"/> + <see cref="SkippedByDataQuality"/> +
/// <see cref="SkippedByBankroll"/>. 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.
/// </param>
/// <param name="SkippedByThreshold">
/// Skipped because <c>Anomaly.Score &lt; strategy.MinScore</c> — pure strategy choice.
/// </param>
/// <param name="SkippedByDataQuality">
/// 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.
/// </param>
/// <param name="SkippedByBankroll">
/// Skipped because the sized stake was non-positive (Kelly returned no edge,
/// or bankroll was depleted) or exceeded the current bankroll.
/// </param>
/// <param name="MaxWinStreak">Longest run of consecutive wins.</param>
/// <param name="MaxLossStreak">Longest run of consecutive losses.</param>
/// <param name="Trace">
/// Per-bet records in chronological order — drives the equity curve.
/// </param>
/// <param name="EventTitles">
/// Pre-shaped <c>"Side1Name vs Side2Name"</c> strings keyed by event id, for
/// every event in <see cref="Trace"/>. Carried alongside the result so the UI
/// projection does not need a second pass over <c>IEventRepository</c>.
/// Missing events (pruned by retention) are absent from the map; consumers
/// fall back to <c>EventId.Value</c>.
/// </param>
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<BacktestTrace> Trace,
IReadOnlyDictionary<Marathon.Domain.ValueObjects.EventId, string> EventTitles);
/// <summary>
/// One settled simulated bet. Carries enough metadata to surface a
/// drill-down row and a point on the equity curve.
/// </summary>
/// <param name="AnomalyId">Source anomaly for the link-back affordance.</param>
/// <param name="EventId">Event being bet on.</param>
/// <param name="DetectedAt">When the anomaly was originally detected.</param>
/// <param name="Score">Confidence score of the anomaly.</param>
/// <param name="Sport">Sport metadata if available — null when the event is missing.</param>
/// <param name="PostFlipFavourite">Side bet on (the post-suspension favourite).</param>
/// <param name="TakenRate">Rate at which the simulator "bought" the bet (post-flip rate).</param>
/// <param name="Stake">Stake sized for this bet.</param>
/// <param name="WinnerSide">Actual winner of the event.</param>
/// <param name="IsWin"><c>true</c> if the post-flip favourite was the winner.</param>
/// <param name="Payout">Gross return — <c>Stake × Rate</c> for a win, 0 for a loss.</param>
/// <param name="BankrollAfter">Bankroll after this bet settled — equity-curve y-axis.</param>
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);
@@ -0,0 +1,248 @@
using Marathon.Domain.Enums;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Pure simulator that replays a <see cref="BacktestStrategy"/> over a
/// chronological list of <see cref="BacktestCandidate"/> rows and returns the
/// resulting <see cref="BacktestResult"/>. No I/O, no DI — safe to call in
/// hot loops or property tests.
/// </summary>
/// <remarks>
/// <para>
/// Loop body per candidate:
/// <list type="number">
/// <item>Skip if <c>Anomaly.Score &lt; strategy.MinScore</c>.</item>
/// <item>
/// Skip if the evidence is two-way and the actual winner is <c>Draw</c>:
/// this mirrors <c>AnomalyOutcomeEvaluator</c> — we refuse to grade
/// selections that are structurally impossible for the market.
/// </item>
/// <item>Compute stake from the chosen <see cref="StakeRule"/>.</item>
/// <item>Skip when the stake is non-positive (Kelly returned no edge, or bankroll empty).</item>
/// <item>Settle: payout = stake × rate when the post-flip favourite won, 0 otherwise.</item>
/// <item>Update bankroll, streaks, and running peak-to-trough drawdown.</item>
/// </list>
/// </para>
/// </remarks>
public static class BacktestSimulator
{
public static BacktestResult Run(
BacktestStrategy strategy,
IReadOnlyList<BacktestCandidate> candidates,
IReadOnlyDictionary<Marathon.Domain.ValueObjects.EventId, string>? 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<BacktestTrace>();
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<Marathon.Domain.ValueObjects.EventId, string>());
}
// ── 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;
}
}
@@ -0,0 +1,72 @@
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Parameters fed to <see cref="BacktestSimulator"/>. The strategy is "for every
/// SuspensionFlip anomaly with score ≥ <see cref="MinScore"/>, stake
/// according to <see cref="StakeRule"/> on the post-flip favourite at the
/// post-flip rate, then settle against the actual <c>EventResult</c>."
/// </summary>
/// <param name="StartingBankroll">
/// Initial bankroll for compounding stake rules. Must be positive.
/// </param>
/// <param name="MinScore">
/// Lower bound on <c>Anomaly.Score</c> — only anomalies at or above this
/// threshold are bet on. Must be in [0, 1].
/// </param>
/// <param name="StakeRule">How to size each bet — see the enum docs.</param>
/// <param name="FlatStake">
/// Used when <see cref="StakeRule"/> is <see cref="Backtesting.StakeRule.Flat"/>.
/// Must be positive.
/// </param>
/// <param name="PercentOfBankroll">
/// Used when <see cref="StakeRule"/> is
/// <see cref="Backtesting.StakeRule.PercentOfBankroll"/>. Expressed as a
/// fraction in (0, 1]. e.g. 0.02 = 2 % of bankroll.
/// </param>
/// <param name="KellyFraction">
/// Used when <see cref="StakeRule"/> is <see cref="Backtesting.StakeRule.Kelly"/>.
/// Multiplier on the raw Kelly fraction; in (0, 1]. 0.25 (quarter-Kelly) is
/// the conservative default.
/// </param>
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].");
/// <summary>Sensible defaults — flat-stake, score ≥ 0.45, ¼-Kelly waiting in the wings.</summary>
public static BacktestStrategy Default { get; } = new(
StartingBankroll: 1000m,
MinScore: 0.45m,
StakeRule: StakeRule.Flat,
FlatStake: 50m,
PercentOfBankroll: 0.02m,
KellyFraction: 0.25m);
}
@@ -0,0 +1,28 @@
namespace Marathon.Domain.Backtesting;
/// <summary>
/// How the simulator decides how much to stake on each bet during a backtest.
/// </summary>
public enum StakeRule
{
/// <summary>
/// Same fixed amount every bet, independent of bankroll.
/// Suitable for "flat-betting" historical analysis — the simplest baseline.
/// </summary>
Flat,
/// <summary>
/// A fixed percentage of the current bankroll every bet. Compounds: a
/// winning streak grows stake size; losses shrink it. Equivalent to
/// proportional betting.
/// </summary>
PercentOfBankroll,
/// <summary>
/// Fractional Kelly using the post-flip implied probability as the edge
/// estimate: <c>f = ((b·p) q) / b</c>, scaled by the configured
/// <see cref="BacktestStrategy.KellyFraction"/>. Negative-expectation bets
/// stake zero (and are skipped). Half/quarter-Kelly is the usual practice.
/// </summary>
Kelly,
}
+4
View File
@@ -47,6 +47,10 @@
<MudIcon Icon="@Icons.Material.Outlined.Receipt" Size="Size.Small" /> <MudIcon Icon="@Icons.Material.Outlined.Receipt" Size="Size.Small" />
<span>@L["Nav.MyBets"]</span> <span>@L["Nav.MyBets"]</span>
</NavLink> </NavLink>
<NavLink class="m-nav__link" href="anomalies/backtest">
<MudIcon Icon="@Icons.Material.Outlined.QueryStats" Size="Size.Small" />
<span>@L["Nav.Backtest"]</span>
</NavLink>
<div class="m-nav__group" style="margin-top: var(--m-space-5);">@L["Nav.Section.System"]</div> <div class="m-nav__group" style="margin-top: var(--m-space-5);">@L["Nav.Section.System"]</div>
<NavLink class="m-nav__link" href="settings"> <NavLink class="m-nav__link" href="settings">
@@ -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<SharedResource> L
@inject IBacktestService Service
@inject NavigationManager Nav
@inject ISnackbar Snackbar
@inject ILogger<Backtest> Logger
<PageTitle>@L["App.Title"] · @L["Nav.Backtest"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1 m-backtest__header" data-test="backtest-header">
<div class="m-backtest__header-text">
<span class="m-kicker">@L["Backtest.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Backtest.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Backtest.Lede"]</p>
</div>
</header>
@* ---------- Strategy form ---------- *@
<section class="m-backtest__section m-rise m-rise-2" data-test="backtest-form-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Strategy"]</span>
</header>
<article class="m-card m-card--accented m-backtest__form-card">
<div class="m-backtest__form-grid">
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.Bankroll"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.StartingBankroll"
Min="1m"
Step="100m"
Variant="Variant.Outlined"
data-test="backtest-bankroll" />
</div>
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.MinScore"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.MinScore"
Min="0m"
Max="1m"
Step="0.05m"
Variant="Variant.Outlined"
data-test="backtest-min-score" />
<span class="m-backtest__form-hint">@L["Backtest.Field.MinScore.Hint"]</span>
</div>
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.StakeRule"]</label>
<MudSelect T="StakeRule"
Value="_form.StakeRule"
ValueChanged="OnStakeRuleChanged"
Variant="Variant.Outlined"
data-test="backtest-stake-rule">
@foreach (var rule in _stakeRules)
{
<MudSelectItem T="StakeRule" Value="@rule">@StakeRuleLabel(rule)</MudSelectItem>
}
</MudSelect>
</div>
@switch (_form.StakeRule)
{
case StakeRule.Flat:
<div class="m-backtest__form-field" data-test="backtest-flat-stake-field">
<label class="m-backtest__form-label">@L["Backtest.Field.FlatStake"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.FlatStake"
Min="0.01m"
Step="10m"
Variant="Variant.Outlined"
data-test="backtest-flat-stake" />
</div>
break;
case StakeRule.PercentOfBankroll:
<div class="m-backtest__form-field" data-test="backtest-percent-field">
<label class="m-backtest__form-label">@L["Backtest.Field.PercentOfBankroll"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.PercentOfBankrollPercent"
Min="0.01m"
Max="100m"
Step="0.5m"
Variant="Variant.Outlined"
data-test="backtest-percent" />
</div>
break;
case StakeRule.Kelly:
<div class="m-backtest__form-field" data-test="backtest-kelly-field">
<label class="m-backtest__form-label">@L["Backtest.Field.KellyFraction"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.KellyFractionPercent"
Min="1m"
Max="100m"
Step="5m"
Variant="Variant.Outlined"
data-test="backtest-kelly" />
<span class="m-backtest__form-hint">@L["Backtest.Field.KellyFraction.Hint"]</span>
</div>
break;
}
</div>
@if (!string.IsNullOrEmpty(_formError))
{
<p class="m-backtest__form-error" data-test="backtest-form-error">@_formError</p>
}
<div class="m-backtest__form-actions">
<button type="button"
class="m-chip m-backtest__submit"
@onclick="RunAsync"
disabled="@_running"
data-test="backtest-run">
<span class="m-backtest__submit-glyph @(_running ? "is-spinning" : null)" aria-hidden="true">▶</span>
<span>@(_running ? L["Backtest.Action.Running"] : L["Backtest.Action.Run"])</span>
</button>
</div>
</article>
</section>
@if (_vm is { } vm)
{
<hr class="m-rule--double" />
@* ---------- Result headline ---------- *@
<section class="m-backtest__section m-rise m-rise-3" data-test="backtest-result-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Headline"]</span>
</header>
<div class="m-backtest__kpis" data-test="backtest-kpis">
<article class="m-backtest__kpi m-backtest__kpi--@BankrollTone(vm)" data-test="backtest-kpi-final">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.FinalBankroll"]</span>
<span class="m-backtest__kpi-value">@FormatDecimal(vm.FinalBankroll)</span>
</article>
<article class="m-backtest__kpi m-backtest__kpi--@ProfitTone(vm)" data-test="backtest-kpi-profit">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.NetProfit"]</span>
<span class="m-backtest__kpi-value">@FormatSignedDecimal(vm.NetProfit, vm.BetsPlaced)</span>
</article>
<article class="m-backtest__kpi m-backtest__kpi--@RoiTone(vm.RoiPercent)" data-test="backtest-kpi-roi">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.Roi"]</span>
<span class="m-backtest__kpi-value">@FormatSignedPercent(vm.RoiPercent)</span>
</article>
<article class="m-backtest__kpi m-backtest__kpi--drawdown" data-test="backtest-kpi-drawdown">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.MaxDrawdown"]</span>
@if (vm.MaxDrawdown == 0m && vm.MaxDrawdownPercent is null)
{
<span class="m-backtest__kpi-value" style="color: var(--m-c-ink-soft);">—</span>
}
else
{
<span class="m-backtest__kpi-value">@FormatSignedDecimal(-vm.MaxDrawdown, 1)</span>
<span class="m-backtest__kpi-sub">@FormatSignedPercent(vm.MaxDrawdownPercent is null ? null : -vm.MaxDrawdownPercent.Value)</span>
}
</article>
</div>
<div class="m-backtest__counts m-mono" data-test="backtest-counts">
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.BetsPlaced"]</span> <strong>@vm.BetsPlaced</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.Wins"]</span> <strong style="color: var(--m-c-positive);">@vm.Wins</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.Losses"]</span> <strong style="color: var(--m-c-anomaly);">@vm.Losses</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.Skipped"]</span> <strong>@vm.Skipped</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.MaxWinStreak"]</span> <strong>@vm.MaxWinStreak</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.MaxLossStreak"]</span> <strong>@vm.MaxLossStreak</strong></span>
</div>
</section>
@if (vm.BetsPlaced == 0 && vm.Trace.Count == 0 && vm.Skipped == 0)
{
<div class="m-list-empty m-rise m-rise-4" data-test="backtest-empty-no-data">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
@L["Backtest.Empty.NoData"]
</p>
</div>
}
else if (vm.BetsPlaced == 0)
{
<div class="m-list-empty m-rise m-rise-4" data-test="backtest-empty-no-bets">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
@L["Backtest.Empty.NoBetsPlaced"]
</p>
</div>
}
else
{
<hr class="m-rule--double" />
@* ---------- Equity curve ---------- *@
<section class="m-backtest__section m-rise m-rise-4" data-test="backtest-equity-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Equity"]</span>
</header>
<article class="m-backtest__equity">
@if (vm.EquityCurve.Count == 0)
{
<div class="m-list-empty" data-test="backtest-equity-empty">
<p style="color: var(--m-c-ink-soft); max-width: 50ch;">@L["Backtest.Empty.NoBetsPlaced"]</p>
</div>
}
else
{
@RenderEquityCurve(vm)
}
</article>
</section>
<hr class="m-rule--double" />
@* ---------- Trade trace ---------- *@
<section class="m-backtest__section m-rise m-rise-5" data-test="backtest-trace-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Trace"]</span>
<span class="m-backtest__section-count m-mono">@vm.Trace.Count</span>
</header>
<div class="m-backtest__table-wrap">
<table class="m-backtest__table" data-test="backtest-trace-table">
<thead>
<tr>
<th scope="col">@L["Backtest.Column.DetectedAt"]</th>
<th scope="col">@L["Backtest.Column.Match"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Score"]</th>
<th scope="col">@L["Backtest.Column.Pick"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Rate"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Stake"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Payout"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Bankroll"]</th>
<th scope="col">@L["Backtest.Column.Outcome"]</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
@foreach (var row in vm.Trace)
{
var local = row;
var trace = local.Trace;
<tr class="m-backtest__row m-backtest__row--@(trace.IsWin ? "win" : "loss")"
data-test="backtest-trace-row"
data-anomaly-id="@trace.AnomalyId">
<td class="m-mono">@trace.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td style="font-weight: 500;">@local.EventTitle</td>
<td class="m-mono" style="text-align: right; font-weight: 600;">@trace.Score.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td style="font-weight: 600;">@SideLabel(trace.PostFlipFavourite)</td>
<td class="m-mono" style="text-align: right;">@trace.TakenRate.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td class="m-mono" style="text-align: right;">@trace.Stake.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td class="m-mono m-backtest__payout m-backtest__payout--@(trace.IsWin ? "win" : "loss")" style="text-align: right;">
@trace.Payout.ToString("0.00", CultureInfo.InvariantCulture)
</td>
<td class="m-mono" style="text-align: right; font-weight: 600;">@trace.BankrollAfter.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td>
<span class="m-backtest__verdict m-backtest__verdict--@(trace.IsWin ? "win" : "loss")">
@(trace.IsWin ? L["Backtest.Outcome.Win"] : L["Backtest.Outcome.Loss"])
</span>
</td>
<td>
<a href="@($"/anomalies/{trace.AnomalyId}")"
class="m-backtest__open"
data-test="backtest-trace-open"
@onclick="@(e => OpenAnomaly(e, trace.AnomalyId))"
@onclick:preventDefault>
@L["Insights.Action.OpenAnomaly"]
<span aria-hidden="true">→</span>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
}
</section>
<style>
/* ---- Header ---- */
.m-backtest__header {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--m-space-3);
max-width: 880px;
}
.m-backtest__header-text { display: grid; gap: var(--m-space-3); }
/* ---- Sections ---- */
.m-backtest__section { display: grid; gap: var(--m-space-4); }
.m-backtest__section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--m-space-3);
}
.m-backtest__section-count {
font-size: 0.6875rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
/* ---- Form ---- */
.m-backtest__form-card {
display: grid;
gap: var(--m-space-4);
padding: var(--m-space-5);
}
.m-backtest__form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--m-space-4);
}
.m-backtest__form-field { display: grid; gap: var(--m-space-2); }
.m-backtest__form-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
.m-backtest__form-hint {
font-size: 0.75rem;
color: var(--m-c-ink-soft);
}
.m-backtest__form-error {
margin: 0;
padding: var(--m-space-3) var(--m-space-4);
border: 1px solid var(--m-c-anomaly);
border-left-width: 3px;
background: rgba(220, 38, 38, 0.06);
color: var(--m-c-anomaly);
font-family: var(--m-font-mono);
font-size: 0.8125rem;
line-height: 1.5;
}
[data-theme="dark"] .m-backtest__form-error {
background: rgba(248, 113, 113, 0.10);
}
.m-backtest__form-actions {
display: flex;
justify-content: flex-end;
gap: var(--m-space-3);
}
.m-backtest__submit {
gap: var(--m-space-2);
padding: 8px 16px;
border-color: var(--m-c-accent);
color: var(--m-c-accent);
font-family: var(--m-font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.m-backtest__submit:not(:disabled):hover {
background: var(--m-c-accent);
color: var(--m-c-paper);
}
.m-backtest__submit:disabled { opacity: 0.6; cursor: progress; }
.m-backtest__submit-glyph {
display: inline-block;
font-size: 0.7rem;
line-height: 1;
}
.m-backtest__submit-glyph.is-spinning { animation: m-backtest-spin 1.1s linear infinite; }
@@keyframes m-backtest-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@media (prefers-reduced-motion: reduce) {
.m-backtest__submit-glyph.is-spinning { animation: none; }
}
/* ---- KPI strip ---- */
.m-backtest__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--m-space-4);
}
.m-backtest__kpi {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
border-left: 3px solid var(--m-c-rule);
padding: var(--m-space-4) var(--m-space-5);
display: flex;
flex-direction: column;
gap: var(--m-space-2);
position: relative;
}
.m-backtest__kpi--positive { border-left-color: var(--m-c-positive); }
.m-backtest__kpi--negative { border-left-color: var(--m-c-anomaly); }
.m-backtest__kpi--neutral { border-left-color: var(--m-c-accent); }
.m-backtest__kpi--drawdown { border-left-color: var(--m-c-anomaly); }
.m-backtest__kpi-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
.m-backtest__kpi-value {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: clamp(1.85rem, 3.4vw, 2.5rem);
font-weight: 500;
line-height: 1;
letter-spacing: -0.02em;
color: var(--m-c-ink);
}
.m-backtest__kpi--positive .m-backtest__kpi-value { color: var(--m-c-positive); }
.m-backtest__kpi--negative .m-backtest__kpi-value { color: var(--m-c-anomaly); }
.m-backtest__kpi--drawdown .m-backtest__kpi-value { color: var(--m-c-anomaly); }
.m-backtest__kpi-sub {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: 0.8125rem;
color: var(--m-c-anomaly);
letter-spacing: 0.02em;
}
/* ---- Counts row ---- */
.m-backtest__counts {
display: flex;
gap: var(--m-space-3);
flex-wrap: wrap;
align-items: baseline;
padding: var(--m-space-2) 0;
font-size: 0.8125rem;
color: var(--m-c-ink-soft);
font-feature-settings: var(--m-num-feature);
}
.m-backtest__counts strong {
color: var(--m-c-ink);
font-weight: 600;
}
.m-backtest__counts-label {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.6875rem;
}
/* ---- Equity curve ---- */
.m-backtest__equity {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
padding: var(--m-space-4) var(--m-space-5);
position: relative;
}
.m-backtest__equity-svg {
display: block;
width: 100%;
height: 200px;
}
.m-backtest__equity-baseline {
stroke: var(--m-c-rule);
stroke-width: 1;
stroke-dasharray: 3 4;
fill: none;
}
.m-backtest__equity-path {
fill: none;
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.m-backtest__equity-path--positive { stroke: var(--m-c-positive); }
.m-backtest__equity-path--negative { stroke: var(--m-c-anomaly); }
.m-backtest__equity-tick {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: 10px;
fill: var(--m-c-ink-soft);
}
.m-backtest__equity-tick--anchor { fill: var(--m-c-ink); font-weight: 600; }
/* ---- Trace table ---- */
.m-backtest__table-wrap {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
overflow-x: auto;
}
.m-backtest__table {
width: 100%;
border-collapse: collapse;
font-family: var(--m-font-body);
}
.m-backtest__table thead th {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
text-align: left;
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
color: var(--m-c-ink-soft);
background: var(--m-c-paper-2);
white-space: nowrap;
}
.m-backtest__table tbody td {
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
vertical-align: middle;
font-size: 0.9375rem;
}
.m-backtest__table tbody tr:last-child td { border-bottom: 0; }
.m-backtest__row { transition: background 120ms ease; }
.m-backtest__row:hover { background: var(--m-c-paper-2); }
.m-backtest__row--win { box-shadow: inset 2px 0 0 0 var(--m-c-positive); }
.m-backtest__row--loss { box-shadow: inset 2px 0 0 0 var(--m-c-anomaly); }
@@media (prefers-reduced-motion: reduce) {
.m-backtest__row { transition: none; }
}
.m-backtest__payout { font-feature-settings: var(--m-num-feature); font-weight: 600; }
.m-backtest__payout--win { color: var(--m-c-positive); }
.m-backtest__payout--loss { color: var(--m-c-anomaly); }
.m-backtest__verdict {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
border: 1px solid currentColor;
border-radius: var(--m-radius-xs);
background: rgba(0, 0, 0, 0);
}
.m-backtest__verdict--win {
color: var(--m-c-positive);
background: rgba(21, 128, 61, 0.10);
}
.m-backtest__verdict--loss {
color: var(--m-c-anomaly);
background: rgba(220, 38, 38, 0.10);
}
[data-theme="dark"] .m-backtest__verdict--win {
color: var(--m-c-positive);
background: rgba(34, 197, 94, 0.15);
}
[data-theme="dark"] .m-backtest__verdict--loss {
color: var(--m-c-anomaly);
background: rgba(248, 113, 113, 0.15);
}
.m-backtest__open {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--m-font-mono);
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
text-decoration: none;
color: var(--m-c-ink);
border-bottom: 1px solid var(--m-c-accent);
padding-bottom: 1px;
transition: color 120ms ease, border-color 120ms ease;
}
.m-backtest__open:hover {
color: var(--m-c-accent);
border-bottom-color: var(--m-c-ink);
}
/* ---- Empty-state ---- */
.m-list-empty {
display: grid;
place-content: center;
gap: var(--m-space-3);
padding: var(--m-space-7);
text-align: center;
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
}
</style>
@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();
}
}
@@ -410,4 +410,52 @@
<data name="Journal.Resolve.None"><value>No pending bets needed grading.</value></data> <data name="Journal.Resolve.None"><value>No pending bets needed grading.</value></data>
<data name="Journal.Resolve.Done"><value>Graded {0} pending bet(s).</value></data> <data name="Journal.Resolve.Done"><value>Graded {0} pending bet(s).</value></data>
<data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data> <data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data>
<data name="Nav.Backtest"><value>Backtest</value></data>
<data name="Backtest.Kicker"><value>Simulator</value></data>
<data name="Backtest.Title"><value>Replay the detector against history</value></data>
<data name="Backtest.Lede"><value>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.</value></data>
<data name="Backtest.Section.Strategy"><value>Strategy</value></data>
<data name="Backtest.Section.Headline"><value>Result</value></data>
<data name="Backtest.Section.Equity"><value>Equity curve</value></data>
<data name="Backtest.Section.Trace"><value>Trade trace</value></data>
<data name="Backtest.Field.Bankroll"><value>Starting bankroll</value></data>
<data name="Backtest.Field.MinScore"><value>Min anomaly score</value></data>
<data name="Backtest.Field.MinScore.Hint"><value>Only bet anomalies at or above this confidence.</value></data>
<data name="Backtest.Field.StakeRule"><value>Staking rule</value></data>
<data name="Backtest.Field.FlatStake"><value>Flat stake</value></data>
<data name="Backtest.Field.PercentOfBankroll"><value>Percent of bankroll</value></data>
<data name="Backtest.Field.KellyFraction"><value>Kelly fraction</value></data>
<data name="Backtest.Field.KellyFraction.Hint"><value>0.25 (quarter-Kelly) is the conservative default.</value></data>
<data name="Backtest.StakeRule.Flat"><value>Flat</value></data>
<data name="Backtest.StakeRule.PercentOfBankroll"><value>% of bankroll</value></data>
<data name="Backtest.StakeRule.Kelly"><value>Kelly</value></data>
<data name="Backtest.Action.Run"><value>Run simulation</value></data>
<data name="Backtest.Action.Running"><value>Simulating…</value></data>
<data name="Backtest.Stat.FinalBankroll"><value>Final bankroll</value></data>
<data name="Backtest.Stat.NetProfit"><value>Net profit</value></data>
<data name="Backtest.Stat.Roi"><value>ROI</value></data>
<data name="Backtest.Stat.MaxDrawdown"><value>Max drawdown</value></data>
<data name="Backtest.Stat.BetsPlaced"><value>Bets placed</value></data>
<data name="Backtest.Stat.Wins"><value>Wins</value></data>
<data name="Backtest.Stat.Losses"><value>Losses</value></data>
<data name="Backtest.Stat.Skipped"><value>Skipped</value></data>
<data name="Backtest.Stat.MaxWinStreak"><value>Max win streak</value></data>
<data name="Backtest.Stat.MaxLossStreak"><value>Max loss streak</value></data>
<data name="Backtest.Stat.TotalStaked"><value>Total staked</value></data>
<data name="Backtest.Stat.TotalReturned"><value>Total returned</value></data>
<data name="Backtest.Column.DetectedAt"><value>Detected</value></data>
<data name="Backtest.Column.Match"><value>Match</value></data>
<data name="Backtest.Column.Score"><value>Score</value></data>
<data name="Backtest.Column.Pick"><value>Pick</value></data>
<data name="Backtest.Column.Rate"><value>Rate</value></data>
<data name="Backtest.Column.Stake"><value>Stake</value></data>
<data name="Backtest.Column.Payout"><value>Payout</value></data>
<data name="Backtest.Column.Bankroll"><value>Bankroll</value></data>
<data name="Backtest.Column.Outcome"><value>Outcome</value></data>
<data name="Backtest.Outcome.Win"><value>Win</value></data>
<data name="Backtest.Outcome.Loss"><value>Loss</value></data>
<data name="Backtest.Empty.NoData"><value>No graded anomalies to simulate yet. Run the results loader so the detector has outcomes to replay against.</value></data>
<data name="Backtest.Empty.NoBetsPlaced"><value>The strategy placed zero bets — try lowering the score threshold, or switch staking rule.</value></data>
<data name="Backtest.Error.Generic"><value>Simulation failed — check the form values and try again.</value></data>
</root> </root>
@@ -423,4 +423,52 @@
<data name="Journal.Resolve.None"><value>Ожидающих ставок к расчёту нет.</value></data> <data name="Journal.Resolve.None"><value>Ожидающих ставок к расчёту нет.</value></data>
<data name="Journal.Resolve.Done"><value>Рассчитано ожидающих: {0}.</value></data> <data name="Journal.Resolve.Done"><value>Рассчитано ожидающих: {0}.</value></data>
<data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data> <data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data>
<data name="Nav.Backtest"><value>Бэктест</value></data>
<data name="Backtest.Kicker"><value>Симулятор</value></data>
<data name="Backtest.Title"><value>Прогон детектора по истории</value></data>
<data name="Backtest.Lede"><value>Запустите гипотетическую стратегию на всех зафиксированных аномалиях. Выберите порог уверенности и правило стейкинга — симулятор разыграет каждую ставку против реального исхода, нарастит банк и покажет ключевые метрики для оценки преимущества.</value></data>
<data name="Backtest.Section.Strategy"><value>Стратегия</value></data>
<data name="Backtest.Section.Headline"><value>Результат</value></data>
<data name="Backtest.Section.Equity"><value>Кривая банка</value></data>
<data name="Backtest.Section.Trace"><value>Хронология ставок</value></data>
<data name="Backtest.Field.Bankroll"><value>Стартовый банк</value></data>
<data name="Backtest.Field.MinScore"><value>Мин. score аномалии</value></data>
<data name="Backtest.Field.MinScore.Hint"><value>Ставим только при уверенности не ниже этого порога.</value></data>
<data name="Backtest.Field.StakeRule"><value>Правило стейкинга</value></data>
<data name="Backtest.Field.FlatStake"><value>Фикс. ставка</value></data>
<data name="Backtest.Field.PercentOfBankroll"><value>Процент от банка</value></data>
<data name="Backtest.Field.KellyFraction"><value>Доля Келли</value></data>
<data name="Backtest.Field.KellyFraction.Hint"><value>0,25 (четверть-Келли) — консервативный дефолт.</value></data>
<data name="Backtest.StakeRule.Flat"><value>Фиксированная</value></data>
<data name="Backtest.StakeRule.PercentOfBankroll"><value>% от банка</value></data>
<data name="Backtest.StakeRule.Kelly"><value>Келли</value></data>
<data name="Backtest.Action.Run"><value>Запустить</value></data>
<data name="Backtest.Action.Running"><value>Симуляция…</value></data>
<data name="Backtest.Stat.FinalBankroll"><value>Итоговый банк</value></data>
<data name="Backtest.Stat.NetProfit"><value>Чистая прибыль</value></data>
<data name="Backtest.Stat.Roi"><value>ROI</value></data>
<data name="Backtest.Stat.MaxDrawdown"><value>Макс. просадка</value></data>
<data name="Backtest.Stat.BetsPlaced"><value>Поставлено</value></data>
<data name="Backtest.Stat.Wins"><value>Победы</value></data>
<data name="Backtest.Stat.Losses"><value>Поражения</value></data>
<data name="Backtest.Stat.Skipped"><value>Пропущено</value></data>
<data name="Backtest.Stat.MaxWinStreak"><value>Макс. серия побед</value></data>
<data name="Backtest.Stat.MaxLossStreak"><value>Макс. серия пораж.</value></data>
<data name="Backtest.Stat.TotalStaked"><value>Всего поставлено</value></data>
<data name="Backtest.Stat.TotalReturned"><value>Всего возвращено</value></data>
<data name="Backtest.Column.DetectedAt"><value>Замечено</value></data>
<data name="Backtest.Column.Match"><value>Матч</value></data>
<data name="Backtest.Column.Score"><value>Score</value></data>
<data name="Backtest.Column.Pick"><value>Выбор</value></data>
<data name="Backtest.Column.Rate"><value>Кэф</value></data>
<data name="Backtest.Column.Stake"><value>Ставка</value></data>
<data name="Backtest.Column.Payout"><value>Выплата</value></data>
<data name="Backtest.Column.Bankroll"><value>Банк</value></data>
<data name="Backtest.Column.Outcome"><value>Исход</value></data>
<data name="Backtest.Outcome.Win"><value>Победа</value></data>
<data name="Backtest.Outcome.Loss"><value>Проигрыш</value></data>
<data name="Backtest.Empty.NoData"><value>Аномалий с результатом ещё нет. Запустите загрузчик результатов, чтобы симулятору было на чём прогоняться.</value></data>
<data name="Backtest.Empty.NoBetsPlaced"><value>Стратегия не сделала ни одной ставки — снизьте порог score или поменяйте правило стейкинга.</value></data>
<data name="Backtest.Error.Generic"><value>Симуляция упала — проверьте параметры формы и повторите.</value></data>
</root> </root>
@@ -0,0 +1,61 @@
using Marathon.Application.UseCases;
namespace Marathon.UI.Services;
/// <summary>
/// Page-facing implementation of <see cref="IBacktestService"/>. The use case
/// hands back per-event titles inside the result so the service does no
/// repository I/O of its own.
/// </summary>
public sealed class BacktestService : IBacktestService
{
private readonly RunBacktestUseCase _useCase;
public BacktestService(RunBacktestUseCase useCase)
{
_useCase = useCase ?? throw new ArgumentNullException(nameof(useCase));
}
public async Task<BacktestVm> 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);
}
}
@@ -0,0 +1,89 @@
using Marathon.Domain.Backtesting;
using Marathon.Domain.Enums;
namespace Marathon.UI.Services;
/// <summary>
/// Form bound by the Backtest page. Loose-typed so MudBlazor fields can bind
/// raw numerics; the service translates this into a domain
/// <see cref="BacktestStrategy"/> after validation.
/// </summary>
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;
/// <summary>Bound to the UI as a percentage 0100; converted to a fraction before sim.</summary>
public decimal PercentOfBankrollPercent { get; set; } = 2m;
/// <summary>Bound to the UI as a percentage 0100; converted to a fraction before sim.</summary>
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);
}
/// <summary>UI-facing projection of <see cref="BacktestResult"/>.</summary>
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<BacktestTraceRow> Trace,
IReadOnlyList<EquityPoint> EquityCurve);
/// <summary>
/// Trace row plus pre-shaped event title for the link-back affordance.
/// </summary>
public sealed record BacktestTraceRow(
BacktestTrace Trace,
string EventTitle);
/// <summary>One point on the equity curve — bankroll over time.</summary>
/// <param name="DetectedAt">When the bet would have been placed.</param>
/// <param name="Bankroll">Bankroll after this bet settled.</param>
public sealed record EquityPoint(DateTimeOffset DetectedAt, decimal Bankroll);
@@ -0,0 +1,13 @@
namespace Marathon.UI.Services;
/// <summary>
/// Browsing facade in front of <see cref="Marathon.Application.UseCases.RunBacktestUseCase"/>.
/// The Backtest page binds to this — view-model shaping and event-title
/// joining live here so the page stays declarative.
/// </summary>
public interface IBacktestService
{
/// <summary>Validates the form, runs the simulator, projects for the UI.</summary>
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
Task<BacktestVm> RunAsync(BacktestForm form, CancellationToken ct);
}
@@ -60,6 +60,7 @@ public static class UiServicesExtensions
services.AddScoped<IAnomalyInsightsService, AnomalyInsightsService>(); services.AddScoped<IAnomalyInsightsService, AnomalyInsightsService>();
services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>(); services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>();
services.AddScoped<IBetJournalService, BetJournalService>(); services.AddScoped<IBetJournalService, BetJournalService>();
services.AddScoped<IBacktestService, BacktestService>();
// Settings writer — file path is host-resolved. // Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath)); services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
@@ -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;
/// <summary>
/// Tests the orchestration: anomaly + event + result join + parse + delegate to
/// the simulator. The simulator's own correctness is covered in
/// Marathon.Domain.Tests.
/// </summary>
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<IAnomalyRepository>();
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
private RunBacktestUseCase CreateSut() =>
new(_anomalies, _events, _results, NullLogger<RunBacktestUseCase>.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<CancellationToken>())
.Returns(Array.Empty<Anomaly>().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<CancellationToken>())
.Returns(new[] { MakeAnomaly(id, score: 0.55m) }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.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<CancellationToken>())
.Returns(new[] { MakeAnomaly(graded), MakeAnomaly(ungraded) }.ToList().AsReadOnly());
_events.GetAsync(graded, Arg.Any<CancellationToken>()).Returns(MakeEvent(graded));
_events.GetAsync(ungraded, Arg.Any<CancellationToken>()).Returns(MakeEvent(ungraded));
_results.GetAsync(graded, Arg.Any<CancellationToken>())
.Returns(new EventResult(graded, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
_results.GetAsync(ungraded, Arg.Any<CancellationToken>())
.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<CancellationToken>())
.Returns(new[] { MakeAnomaly(id, evidence: "{not json") }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.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");
}
}
@@ -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;
/// <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();
}
}