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:
@@ -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 < 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 < 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,
|
||||||
|
}
|
||||||
@@ -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 0–100; converted to a fraction before sim.</summary>
|
||||||
|
public decimal PercentOfBankrollPercent { get; set; } = 2m;
|
||||||
|
|
||||||
|
/// <summary>Bound to the UI as a percentage 0–100; 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user