Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e53dff853 | |||
| e5cd2ab30c | |||
| d9d92ea8fd | |||
| 2b1025cae3 | |||
| 4dae9e8d0d | |||
| 0e3c4b8d47 | |||
| 250a93e718 | |||
| 0501f9c39c | |||
| f294255f10 | |||
| 0d52b7beff | |||
| 1ad896b07e | |||
| 292223174c |
@@ -5,4 +5,22 @@ namespace Marathon.Application.Abstractions;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Repository for <see cref="Anomaly"/> domain entities.
|
/// Repository for <see cref="Anomaly"/> domain entities.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAnomalyRepository : IRepository<Guid, Anomaly>;
|
public interface IAnomalyRepository : IRepository<Guid, Anomaly>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side count of anomalies detected strictly after <paramref name="since"/>.
|
||||||
|
/// Backs the unread badge without materialising the table.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anomalies whose <see cref="Anomaly.DetectedAt"/> falls in the inclusive
|
||||||
|
/// [<paramref name="from"/>..<paramref name="to"/>] window (either bound may be
|
||||||
|
/// null for open-ended), ordered newest-first. Pushes the temporal filter to SQL;
|
||||||
|
/// severity / sport filtering remains a service concern (needs the event join).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<Anomaly>> ListByDateRangeAsync(
|
||||||
|
DateTimeOffset? from,
|
||||||
|
DateTimeOffset? to,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,8 +11,27 @@ public interface IEventRepository : IRepository<EventId, Event>
|
|||||||
{
|
{
|
||||||
Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
|
Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date-range + sport-filtered query pushed to the database. Replaces the
|
||||||
|
/// "load the whole date range then filter sports in memory" path on the list
|
||||||
|
/// pages. Locale-sensitive search and sort remain a service-layer concern.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batched point-lookup: loads many events in a single query, keyed by
|
||||||
|
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
|
||||||
|
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
|
||||||
|
IReadOnlyCollection<EventId> ids,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default);
|
Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Server-side total event count (dashboard summary).</summary>
|
||||||
|
Task<int> CountAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Distinct sport codes across the events table. Projects in the database
|
/// Distinct sport codes across the events table. Projects in the database
|
||||||
/// rather than materialising every <see cref="Event"/> on the client.
|
/// rather than materialising every <see cref="Event"/> on the client.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Marathon.Application.Storage;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository for <see cref="PlacedBet"/> domain entities — the user-tracked
|
||||||
|
/// betting journal.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPlacedBetRepository : IRepository<Guid, PlacedBet>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bets matching <paramref name="outcome"/>. Used by the resolver use case
|
||||||
|
/// to scan only <see cref="BetOutcome.Pending"/> rows on each pass.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<PlacedBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bets whose <see cref="PlacedBet.PlacedAt"/> falls within
|
||||||
|
/// <paramref name="range"/>. Used by the journal page when the user filters
|
||||||
|
/// by date.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<PlacedBet>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every bet recorded against <paramref name="eventId"/>. Used by the event
|
||||||
|
/// detail page to show "you have N bets on this match".
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<PlacedBet>> ListByEventAsync(EventId eventId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -6,4 +6,14 @@ namespace Marathon.Application.Abstractions;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Repository for <see cref="EventResult"/> domain entities.
|
/// Repository for <see cref="EventResult"/> domain entities.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IResultRepository : IRepository<EventId, EventResult>;
|
public interface IResultRepository : IRepository<EventId, EventResult>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Batched point-lookup: loads many results in a single query, keyed by
|
||||||
|
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
|
||||||
|
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<EventId, EventResult>> GetManyAsync(
|
||||||
|
IReadOnlyCollection<EventId> ids,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public interface ISnapshotRepository
|
|||||||
{
|
{
|
||||||
Task<IReadOnlyList<OddsSnapshot>> ListAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<OddsSnapshot>> ListAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side count of snapshots captured at or after <paramref name="since"/>.
|
||||||
|
/// Backs the dashboard "snapshots today" stat without materialising rows.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
||||||
EventId eventId,
|
EventId eventId,
|
||||||
DateTimeOffset from,
|
DateTimeOffset from,
|
||||||
@@ -36,4 +42,19 @@ public interface ISnapshotRepository
|
|||||||
Task AddAsync(OddsSnapshot entity, CancellationToken ct = default);
|
Task AddAsync(OddsSnapshot entity, CancellationToken ct = default);
|
||||||
|
|
||||||
Task SaveChangesAsync(CancellationToken ct = default);
|
Task SaveChangesAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the latest pre-match snapshot for <paramref name="eventId"/> whose
|
||||||
|
/// <see cref="OddsSnapshot.CapturedAt"/> is at or before
|
||||||
|
/// <paramref name="atOrBefore"/>, or <c>null</c> if none exists. Used by the
|
||||||
|
/// bet-journal use case as the "closing line" reference for CLV.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Pushes the ORDER BY + LIMIT 1 down to SQLite so we do not materialise
|
||||||
|
/// every snapshot in the 30-day pre-match window just to pick one.
|
||||||
|
/// </remarks>
|
||||||
|
Task<OddsSnapshot?> GetLatestPreMatchAsync(
|
||||||
|
EventId eventId,
|
||||||
|
DateTimeOffset atOrBefore,
|
||||||
|
CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ public static class ApplicationModule
|
|||||||
services.AddScoped<PullResultsUseCase>();
|
services.AddScoped<PullResultsUseCase>();
|
||||||
services.AddScoped<ExportToExcelUseCase>();
|
services.AddScoped<ExportToExcelUseCase>();
|
||||||
services.AddScoped<DetectAnomaliesUseCase>();
|
services.AddScoped<DetectAnomaliesUseCase>();
|
||||||
|
services.AddScoped<EvaluateAnomalyOutcomesUseCase>();
|
||||||
|
|
||||||
|
services.AddScoped<RecordPlacedBetUseCase>();
|
||||||
|
services.AddScoped<ResolvePendingBetsUseCase>();
|
||||||
|
services.AddScoped<BuildBetJournalReportUseCase>();
|
||||||
|
services.AddScoped<DeletePlacedBetUseCase>();
|
||||||
|
|
||||||
|
services.AddScoped<RunBacktestUseCase>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Betting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate report on the user's bet-tracking journal — totals, P&L, and
|
||||||
|
/// per-bet CLV. Consumed by the Journal page; built by
|
||||||
|
/// <see cref="UseCases.BuildBetJournalReportUseCase"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Stats">Roll-up of stake / profit / hit rate / CLV across all bets in scope.</param>
|
||||||
|
/// <param name="Bets">
|
||||||
|
/// Every bet paired with its computed CLV (null when no closing snapshot was
|
||||||
|
/// available). Ordered most-recent <see cref="PlacedBet.PlacedAt"/> first.
|
||||||
|
/// </param>
|
||||||
|
public sealed record BetJournalReport(
|
||||||
|
BetJournalStats Stats,
|
||||||
|
IReadOnlyList<BetJournalRow> Bets);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row in the journal — a domain <see cref="PlacedBet"/> plus the CLV
|
||||||
|
/// computed against the closing pre-match snapshot.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Bet">The domain bet exactly as persisted.</param>
|
||||||
|
/// <param name="ClvProbabilityDelta">
|
||||||
|
/// Closing-line value as an implied-probability delta in roughly [-1, 1].
|
||||||
|
/// Positive means the user took a better price than the closing line; null
|
||||||
|
/// when no matching bet existed in the closing snapshot.
|
||||||
|
/// </param>
|
||||||
|
public sealed record BetJournalRow(
|
||||||
|
PlacedBet Bet,
|
||||||
|
decimal? ClvProbabilityDelta);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate statistics across a set of <see cref="PlacedBet"/>.
|
||||||
|
/// All money values share the user's currency — the domain does not encode one.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TotalBets">Every bet in scope, regardless of outcome.</param>
|
||||||
|
/// <param name="PendingCount">Bets still awaiting settlement.</param>
|
||||||
|
/// <param name="WonCount">Settled wins.</param>
|
||||||
|
/// <param name="LostCount">Settled losses.</param>
|
||||||
|
/// <param name="VoidCount">Settled pushes / void grades.</param>
|
||||||
|
/// <param name="TotalStaked">
|
||||||
|
/// Turnover that contributes to ROI: sum of <see cref="PlacedBet.Stake"/> across
|
||||||
|
/// <b>Won and Lost</b> bets only. Void (push) and Pending bets are excluded — a
|
||||||
|
/// returned stake is not real turnover and counting it would dilute ROI.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="TotalReturned">
|
||||||
|
/// Sum of <see cref="PlacedBet.GrossReturn"/> across the same Won + Lost subset
|
||||||
|
/// that feeds <see cref="TotalStaked"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="NetProfit"><c>TotalReturned − TotalStaked</c>.</param>
|
||||||
|
/// <param name="RoiPercent">
|
||||||
|
/// <c>NetProfit / TotalStaked × 100</c>. Null when no bets have resolved yet.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="StrikeRatePercent">
|
||||||
|
/// <c>WonCount / (WonCount + LostCount) × 100</c> — excludes voids and pendings.
|
||||||
|
/// Null when no settled win/loss exists yet.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="AverageClvProbabilityDelta">
|
||||||
|
/// Mean CLV across bets where CLV was computable. Null when no comparable
|
||||||
|
/// closing snapshot was available for any bet.
|
||||||
|
/// </param>
|
||||||
|
public sealed record BetJournalStats(
|
||||||
|
int TotalBets,
|
||||||
|
int PendingCount,
|
||||||
|
int WonCount,
|
||||||
|
int LostCount,
|
||||||
|
int VoidCount,
|
||||||
|
decimal TotalStaked,
|
||||||
|
decimal TotalReturned,
|
||||||
|
decimal NetProfit,
|
||||||
|
decimal? RoiPercent,
|
||||||
|
decimal? StrikeRatePercent,
|
||||||
|
decimal? AverageClvProbabilityDelta)
|
||||||
|
{
|
||||||
|
/// <summary>Convenience: WonCount + LostCount + VoidCount.</summary>
|
||||||
|
public int ResolvedCount => WonCount + LostCount + VoidCount;
|
||||||
|
|
||||||
|
public static BetJournalStats Empty { get; } = new(
|
||||||
|
TotalBets: 0,
|
||||||
|
PendingCount: 0,
|
||||||
|
WonCount: 0,
|
||||||
|
LostCount: 0,
|
||||||
|
VoidCount: 0,
|
||||||
|
TotalStaked: 0m,
|
||||||
|
TotalReturned: 0m,
|
||||||
|
NetProfit: 0m,
|
||||||
|
RoiPercent: null,
|
||||||
|
StrikeRatePercent: null,
|
||||||
|
AverageClvProbabilityDelta: null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Betting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure helper that computes Closing Line Value (CLV) for a placed bet.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// CLV measures how much better (or worse) the rate the user took was compared
|
||||||
|
/// with the bookmaker's last pre-match price on the same selection. It is the
|
||||||
|
/// single best long-run indicator of betting skill — positive CLV correlates
|
||||||
|
/// with positive expected value regardless of any individual bet's outcome.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Formula (implied-probability delta):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Taken implied probability: <c>p_t = 1 / takenRate</c></item>
|
||||||
|
/// <item>Closing implied probability: <c>p_c = 1 / closeRate</c></item>
|
||||||
|
/// <item><c>CLV = p_c − p_t</c></item>
|
||||||
|
/// </list>
|
||||||
|
/// Positive CLV means the closing price implied higher probability for the
|
||||||
|
/// selection than the price the user took — i.e. the line moved in the user's
|
||||||
|
/// favour after they placed the bet.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Returns <c>null</c> when no matching bet (same Scope / Type / Side / Value)
|
||||||
|
/// can be found in the closing snapshot — typically because the market closed
|
||||||
|
/// before the bookmaker exposed a comparable line, or the snapshot store has
|
||||||
|
/// gaps. UI consumers must distinguish "no data" from "0% CLV".
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ClosingLineValueCalculator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Computes CLV (implied-probability delta) given the rate the user took
|
||||||
|
/// and the rate present in the closing pre-match snapshot for the same
|
||||||
|
/// selection. Both must be positive — invariants on <see cref="OddsRate"/>
|
||||||
|
/// already guarantee this for inputs sourced from the domain.
|
||||||
|
/// </summary>
|
||||||
|
public static decimal Compute(decimal takenRate, decimal closingRate)
|
||||||
|
{
|
||||||
|
if (takenRate <= 0m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(takenRate), takenRate, "Must be positive.");
|
||||||
|
if (closingRate <= 0m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(closingRate), closingRate, "Must be positive.");
|
||||||
|
|
||||||
|
var takenProb = 1m / takenRate;
|
||||||
|
var closingProb = 1m / closingRate;
|
||||||
|
|
||||||
|
// Round to 6 decimals — beyond that is noise from the round-trip.
|
||||||
|
return Math.Round(closingProb - takenProb, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience overload: finds the matching <see cref="Bet"/> in
|
||||||
|
/// <paramref name="closingSnapshot"/> by Scope / Type / Side / Value, then
|
||||||
|
/// computes CLV against <paramref name="takenRate"/>. Returns <c>null</c>
|
||||||
|
/// when no comparable bet is present.
|
||||||
|
/// </summary>
|
||||||
|
public static decimal? TryCompute(
|
||||||
|
decimal takenRate,
|
||||||
|
Bet placedSelection,
|
||||||
|
OddsSnapshot? closingSnapshot)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(placedSelection);
|
||||||
|
if (closingSnapshot is null) return null;
|
||||||
|
|
||||||
|
var match = closingSnapshot.Bets.FirstOrDefault(b =>
|
||||||
|
b.Scope.Equals(placedSelection.Scope) &&
|
||||||
|
b.Type == placedSelection.Type &&
|
||||||
|
b.Side == placedSelection.Side &&
|
||||||
|
NullableValuesEqual(b.Value, placedSelection.Value));
|
||||||
|
|
||||||
|
return match is null ? null : Compute(takenRate, match.Rate.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool NullableValuesEqual(OddsValue? a, OddsValue? b)
|
||||||
|
{
|
||||||
|
if (a is null && b is null) return true;
|
||||||
|
if (a is null || b is null) return false;
|
||||||
|
return a.Value == b.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,4 +32,16 @@ public sealed class AnomalyOptions
|
|||||||
/// in seconds. Default: 60 s.
|
/// in seconds. Default: 60 s.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int DetectionIntervalSeconds { get; init; } = 60;
|
public int DetectionIntervalSeconds { get; init; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trailing window, in seconds, over which the steam-move detector measures a
|
||||||
|
/// continuous one-directional probability drift. Default: 120 s.
|
||||||
|
/// </summary>
|
||||||
|
public int SteamMoveWindowSeconds { get; init; } = 120;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum one-directional normalised implied-probability rise within the window
|
||||||
|
/// to flag a steam move. Must be in (0, 1). Default: 0.20 (20 percentage points).
|
||||||
|
/// </summary>
|
||||||
|
public decimal SteamMoveDriftThreshold { get; init; } = 0.20m;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Reporting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate report answering the question "is the SuspensionFlip detector right?".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TotalAnomalies">Every persisted anomaly considered by this report.</param>
|
||||||
|
/// <param name="ResolvedCount">Anomalies whose source events now have a final result.</param>
|
||||||
|
/// <param name="UnresolvedCount">Anomalies still waiting for an event result.</param>
|
||||||
|
/// <param name="HitCount">Resolved anomalies where the post-flip favourite won.</param>
|
||||||
|
/// <param name="MissCount">Resolved anomalies where the post-flip favourite lost.</param>
|
||||||
|
/// <param name="HitRate">
|
||||||
|
/// <see cref="HitCount"/> ÷ <see cref="ResolvedCount"/> in [0, 1]. Null when no anomalies
|
||||||
|
/// have been resolved yet — the UI must distinguish "0% hit rate" from "no data".
|
||||||
|
/// </param>
|
||||||
|
/// <param name="BySeverity">Breakdown by Low / Medium / High severity buckets.</param>
|
||||||
|
/// <param name="BySport">Breakdown by sport code.</param>
|
||||||
|
/// <param name="ByScoreBin">Breakdown across [0.30, 0.40), [0.40, 0.50), …, [0.90, 1.00].</param>
|
||||||
|
/// <param name="Resolved">All resolved anomalies, newest first. Drives the drill-down table.</param>
|
||||||
|
/// <param name="Unresolved">All unresolved anomalies, newest first.</param>
|
||||||
|
/// <param name="EventTitles">
|
||||||
|
/// Pre-shaped <c>"Side1Name vs Side2Name"</c> strings keyed by event id. Carried
|
||||||
|
/// alongside the report so UI projections do not need a second pass over
|
||||||
|
/// <c>IEventRepository</c> — every event in <see cref="Resolved"/> /
|
||||||
|
/// <see cref="Unresolved"/> appears as a key. Missing events (e.g. pruned) are
|
||||||
|
/// absent; consumers fall back to <c>EventId.Value</c>.
|
||||||
|
/// </param>
|
||||||
|
public sealed record AnomalyOutcomeReport(
|
||||||
|
int TotalAnomalies,
|
||||||
|
int ResolvedCount,
|
||||||
|
int UnresolvedCount,
|
||||||
|
int HitCount,
|
||||||
|
int MissCount,
|
||||||
|
decimal? HitRate,
|
||||||
|
IReadOnlyList<OutcomeBucket> BySeverity,
|
||||||
|
IReadOnlyList<OutcomeBucket> BySport,
|
||||||
|
IReadOnlyList<OutcomeBucket> ByScoreBin,
|
||||||
|
IReadOnlyList<ResolvedAnomaly> Resolved,
|
||||||
|
IReadOnlyList<ResolvedAnomaly> Unresolved,
|
||||||
|
IReadOnlyDictionary<DomainEventId, string> EventTitles);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row in a breakdown table — e.g. "High severity", "Tennis", "[0.60, 0.70)".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Key">
|
||||||
|
/// Stable, culture-invariant identifier used by the UI to localise the label
|
||||||
|
/// (e.g. <c>"Severity.High"</c>, <c>"Sport.22723"</c>, <c>"Bin.0.60-0.70"</c>).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Total">Resolved anomalies in this bucket.</param>
|
||||||
|
/// <param name="Hits">Subset of <see cref="Total"/> where post-flip favourite won.</param>
|
||||||
|
/// <param name="HitRate">
|
||||||
|
/// <see cref="Hits"/> ÷ <see cref="Total"/>, or null when <see cref="Total"/> is 0.
|
||||||
|
/// </param>
|
||||||
|
public sealed record OutcomeBucket(
|
||||||
|
string Key,
|
||||||
|
int Total,
|
||||||
|
int Hits,
|
||||||
|
decimal? HitRate);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Marathon.Application.Reporting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical, culture-invariant <see cref="OutcomeBucket.Key"/> prefixes and
|
||||||
|
/// literals. Used by the use case to emit keys and by the UI to localise them
|
||||||
|
/// — both sides reference these constants so a rename can never produce silent
|
||||||
|
/// "key not found" rendering on the page.
|
||||||
|
/// </summary>
|
||||||
|
public static class OutcomeBucketKeys
|
||||||
|
{
|
||||||
|
/// <summary>Prefix for sport-grouped buckets, e.g. <c>Sport.6</c>.</summary>
|
||||||
|
public const string SportPrefix = "Sport.";
|
||||||
|
|
||||||
|
/// <summary>Prefix for score-bin buckets, e.g. <c>Bin.0.30-0.40</c>.</summary>
|
||||||
|
public const string BinPrefix = "Bin.";
|
||||||
|
|
||||||
|
/// <summary>Prefix for severity buckets, e.g. <c>Severity.High</c>.</summary>
|
||||||
|
public const string SeverityPrefix = "Severity.";
|
||||||
|
|
||||||
|
public const string SeverityLow = SeverityPrefix + "Low";
|
||||||
|
public const string SeverityMedium = SeverityPrefix + "Medium";
|
||||||
|
public const string SeverityHigh = SeverityPrefix + "High";
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Marathon.Application.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Database-pushdown query for the event list pages: an inclusive date range plus
|
||||||
|
/// an optional sport-code filter. Locale-sensitive search and sort are deliberately
|
||||||
|
/// NOT part of this contract — they stay in the service layer where Cyrillic
|
||||||
|
/// ordinal semantics are preserved (SQLite BINARY collation would change them).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Dates">Inclusive scheduled-at window.</param>
|
||||||
|
/// <param name="SportCodes">When non-empty, restricts to these sport codes. Null/empty = all sports.</param>
|
||||||
|
public sealed record EventQuery(
|
||||||
|
DateRange Dates,
|
||||||
|
IReadOnlyCollection<int>? SportCodes = null);
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Betting;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="BetJournalReport"/>: every persisted bet paired with its
|
||||||
|
/// Closing-Line-Value, plus aggregate <see cref="BetJournalStats"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Closing-line lookup: for each distinct event in the journal, this use case
|
||||||
|
/// queries pre-match snapshots within a window that ends at the event's
|
||||||
|
/// <see cref="Event.ScheduledAt"/> and picks the latest snapshot whose
|
||||||
|
/// <see cref="OddsSnapshot.CapturedAt"/> is still before kickoff. That snapshot
|
||||||
|
/// is the "close" for CLV purposes.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// If the snapshot store has nothing within the lookback window, the bet
|
||||||
|
/// receives a null CLV. Stats then exclude it from the average.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class BuildBetJournalReportUseCase
|
||||||
|
{
|
||||||
|
private readonly IPlacedBetRepository _bets;
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly ISnapshotRepository _snapshots;
|
||||||
|
private readonly ILogger<BuildBetJournalReportUseCase> _logger;
|
||||||
|
|
||||||
|
public BuildBetJournalReportUseCase(
|
||||||
|
IPlacedBetRepository bets,
|
||||||
|
IEventRepository events,
|
||||||
|
ISnapshotRepository snapshots,
|
||||||
|
ILogger<BuildBetJournalReportUseCase> logger)
|
||||||
|
{
|
||||||
|
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BetJournalReport> ExecuteAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var bets = await _bets.ListAsync(ct).ConfigureAwait(false);
|
||||||
|
if (bets.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("BuildBetJournalReportUseCase: no bets — empty report");
|
||||||
|
return new BetJournalReport(BetJournalStats.Empty, Array.Empty<BetJournalRow>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var distinctEventIds = bets.Select(b => b.EventId).Distinct().ToList();
|
||||||
|
|
||||||
|
// Batch the event loads (was N+1). The closing-snapshot lookup stays per-event
|
||||||
|
// because it pushes ORDER BY / LIMIT 1 down to SQLite (one indexed row each)
|
||||||
|
// and is parameterised by that event's ScheduledAt.
|
||||||
|
var events = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var closingByEvent = new Dictionary<DomainEventId, OddsSnapshot?>(distinctEventIds.Count);
|
||||||
|
foreach (var eventId in distinctEventIds)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!events.TryGetValue(eventId, out var ev))
|
||||||
|
{
|
||||||
|
closingByEvent[eventId] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var closing = await _snapshots
|
||||||
|
.GetLatestPreMatchAsync(eventId, ev.ScheduledAt, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
closingByEvent[eventId] = closing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<BetJournalRow>(bets.Count);
|
||||||
|
foreach (var bet in bets)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
closingByEvent.TryGetValue(bet.EventId, out var closing);
|
||||||
|
|
||||||
|
var clv = ClosingLineValueCalculator.TryCompute(
|
||||||
|
takenRate: bet.Selection.Rate.Value,
|
||||||
|
placedSelection: bet.Selection,
|
||||||
|
closingSnapshot: closing);
|
||||||
|
|
||||||
|
rows.Add(new BetJournalRow(bet, clv));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Sort((a, b) => b.Bet.PlacedAt.CompareTo(a.Bet.PlacedAt));
|
||||||
|
|
||||||
|
var stats = ComputeStats(rows);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"BuildBetJournalReportUseCase: report built — {Total} bets, {Resolved} resolved, ROI={Roi:0.##}%",
|
||||||
|
stats.TotalBets, stats.ResolvedCount, stats.RoiPercent ?? 0m);
|
||||||
|
|
||||||
|
return new BetJournalReport(stats, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BetJournalStats ComputeStats(IReadOnlyList<BetJournalRow> rows)
|
||||||
|
{
|
||||||
|
if (rows.Count == 0) return BetJournalStats.Empty;
|
||||||
|
|
||||||
|
var pending = 0;
|
||||||
|
var won = 0;
|
||||||
|
var lost = 0;
|
||||||
|
var voided = 0;
|
||||||
|
// Industry-standard ROI excludes pushes from turnover — staking on a Void
|
||||||
|
// bet returns the stake and is functionally a no-op, so counting it as
|
||||||
|
// turnover dilutes the ROI denominator and understates the user's edge.
|
||||||
|
// Only Won + Lost contribute to TotalStaked / TotalReturned.
|
||||||
|
var totalStaked = 0m;
|
||||||
|
var totalReturned = 0m;
|
||||||
|
decimal clvSum = 0m;
|
||||||
|
var clvCount = 0;
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
switch (row.Bet.Outcome)
|
||||||
|
{
|
||||||
|
case BetOutcome.Pending: pending++; break;
|
||||||
|
case BetOutcome.Won: won++; break;
|
||||||
|
case BetOutcome.Lost: lost++; break;
|
||||||
|
case BetOutcome.Void: voided++; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.Bet.Outcome is BetOutcome.Won or BetOutcome.Lost)
|
||||||
|
{
|
||||||
|
totalStaked += row.Bet.Stake;
|
||||||
|
totalReturned += row.Bet.GrossReturn ?? 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.ClvProbabilityDelta is { } clv)
|
||||||
|
{
|
||||||
|
clvSum += clv;
|
||||||
|
clvCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var netProfit = totalReturned - totalStaked;
|
||||||
|
var winLoss = won + lost;
|
||||||
|
|
||||||
|
decimal? roi = totalStaked > 0m
|
||||||
|
? Math.Round((netProfit / totalStaked) * 100m, 2)
|
||||||
|
: null;
|
||||||
|
decimal? strikeRate = winLoss > 0
|
||||||
|
? Math.Round(((decimal)won / winLoss) * 100m, 2)
|
||||||
|
: null;
|
||||||
|
// CLV inputs are already 6-decimal-rounded by ClosingLineValueCalculator;
|
||||||
|
// round the mean only at the display boundary to avoid compounding bias.
|
||||||
|
decimal? avgClv = clvCount > 0
|
||||||
|
? clvSum / clvCount
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new BetJournalStats(
|
||||||
|
TotalBets: rows.Count,
|
||||||
|
PendingCount: pending,
|
||||||
|
WonCount: won,
|
||||||
|
LostCount: lost,
|
||||||
|
VoidCount: voided,
|
||||||
|
TotalStaked: totalStaked,
|
||||||
|
TotalReturned: totalReturned,
|
||||||
|
NetProfit: netProfit,
|
||||||
|
RoiPercent: roi,
|
||||||
|
StrikeRatePercent: strikeRate,
|
||||||
|
AverageClvProbabilityDelta: avgClv);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a <see cref="Marathon.Domain.Entities.PlacedBet"/> from the journal
|
||||||
|
/// by its identifier. Silent no-op when the id does not exist.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeletePlacedBetUseCase
|
||||||
|
{
|
||||||
|
private readonly IPlacedBetRepository _bets;
|
||||||
|
private readonly ILogger<DeletePlacedBetUseCase> _logger;
|
||||||
|
|
||||||
|
public DeletePlacedBetUseCase(
|
||||||
|
IPlacedBetRepository bets,
|
||||||
|
ILogger<DeletePlacedBetUseCase> logger)
|
||||||
|
{
|
||||||
|
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(Guid betId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _bets.DeleteAsync(betId, ct).ConfigureAwait(false);
|
||||||
|
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("DeletePlacedBetUseCase: removed bet {BetId}", betId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,10 +59,18 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
|
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
|
||||||
|
|
||||||
var detector = new AnomalyDetector(
|
var detectors = new IAnomalyDetector[]
|
||||||
|
{
|
||||||
|
new AnomalyDetector(
|
||||||
_options.SuspensionGapSeconds,
|
_options.SuspensionGapSeconds,
|
||||||
_options.OddsFlipThreshold,
|
_options.OddsFlipThreshold,
|
||||||
_options.MinSnapshotCount);
|
_options.MinSnapshotCount),
|
||||||
|
new SteamMoveDetector(
|
||||||
|
_options.SteamMoveWindowSeconds,
|
||||||
|
_options.SteamMoveDriftThreshold,
|
||||||
|
_options.MinSnapshotCount,
|
||||||
|
_options.SuspensionGapSeconds),
|
||||||
|
};
|
||||||
|
|
||||||
var events = await _eventRepo.ListAsync(ct);
|
var events = await _eventRepo.ListAsync(ct);
|
||||||
int newAnomalyCount = 0;
|
int newAnomalyCount = 0;
|
||||||
@@ -71,9 +79,12 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
var from = now - SnapshotLookback;
|
var from = now - SnapshotLookback;
|
||||||
|
|
||||||
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle
|
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle
|
||||||
// and slice per-event in the loop. Previously this was reloaded per event
|
// and index them by event so dedup is O(1) per event instead of scanning the
|
||||||
// (O(N_events) round-trips). Reviewer W1, Phase 7.
|
// whole list each time (was O(events × anomalies)). Reviewer W1, Phase 7.
|
||||||
var existingAnomalies = await _anomalyRepo.ListAsync(ct);
|
var existingAnomalies = await _anomalyRepo.ListAsync(ct);
|
||||||
|
var existingByEvent = existingAnomalies
|
||||||
|
.GroupBy(a => a.EventId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
// Single batched query for all events' snapshots — replaces the prior
|
// Single batched query for all events' snapshots — replaces the prior
|
||||||
// per-event ListByEventAsync round-trip (O(N) SQLite hits + N Include(Bets)
|
// per-event ListByEventAsync round-trip (O(N) SQLite hits + N Include(Bets)
|
||||||
@@ -90,7 +101,10 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found)
|
var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found)
|
||||||
? found
|
? found
|
||||||
: Array.Empty<OddsSnapshot>();
|
: Array.Empty<OddsSnapshot>();
|
||||||
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingAnomalies, ct);
|
var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
|
||||||
|
? slice
|
||||||
|
: new List<Anomaly>();
|
||||||
|
newAnomalyCount += await ProcessEventAsync(detectors, ev, snapshots, existingForEvent, ct);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -114,22 +128,21 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
// ── Private helpers ───────────────────────────────────────────────────────
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task<int> ProcessEventAsync(
|
private async Task<int> ProcessEventAsync(
|
||||||
AnomalyDetector detector,
|
IReadOnlyList<IAnomalyDetector> detectors,
|
||||||
Event ev,
|
Event ev,
|
||||||
IReadOnlyList<OddsSnapshot> snapshots,
|
IReadOnlyList<OddsSnapshot> snapshots,
|
||||||
IReadOnlyList<Anomaly> existingAnomalies,
|
List<Anomaly> existingForEvent,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var detected = detector.Detect(ev.Id, snapshots);
|
// Fan out over every detector kind; dedup below keys on EventId + Kind so the
|
||||||
|
// flip and steam signals for one event persist independently.
|
||||||
|
var detected = detectors
|
||||||
|
.SelectMany(d => d.Detect(ev.Id, snapshots))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (detected.Count == 0)
|
if (detected.Count == 0)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
// Slice the cycle-wide existing-anomaly list to just this event for dedup.
|
|
||||||
var existingForEvent = existingAnomalies
|
|
||||||
.Where(a => a.EventId == ev.Id)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
int persisted = 0;
|
int persisted = 0;
|
||||||
foreach (var anomaly in detected)
|
foreach (var anomaly in detected)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Reporting;
|
||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an <see cref="AnomalyOutcomeReport"/> by joining every persisted
|
||||||
|
/// <see cref="Anomaly"/> with the originating event and its
|
||||||
|
/// <see cref="EventResult"/>, then running the pure
|
||||||
|
/// <see cref="AnomalyOutcomeEvaluator"/> over each pair.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the answer to "does the SuspensionFlip detector actually predict the
|
||||||
|
/// right side?" The report is the validator for the entire anomaly-detection
|
||||||
|
/// premise of the product — without it, the algorithm's confidence score is
|
||||||
|
/// just a number with no calibration.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The use case loads all three collections in one pass each and performs the
|
||||||
|
/// join in memory. Anomaly volumes are small (one per suspension interval per
|
||||||
|
/// event) so this is well within budget. If volumes grow significantly the
|
||||||
|
/// repository layer can later add a SQL-side join — the public shape of the
|
||||||
|
/// report does not change.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class EvaluateAnomalyOutcomesUseCase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lowest score bin shown in the histogram. Score values below this never
|
||||||
|
/// appear because the detector enforces a configurable threshold (default
|
||||||
|
/// 0.30) — but the constant is repeated here so the bucketer is independent
|
||||||
|
/// of any specific configuration value.
|
||||||
|
/// </summary>
|
||||||
|
public const decimal MinScore = 0.30m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bin width for the score histogram. Yields 7 buckets:
|
||||||
|
/// [0.30, 0.40), [0.40, 0.50), [0.50, 0.60), [0.60, 0.70), [0.70, 0.80),
|
||||||
|
/// [0.80, 0.90), [0.90, 1.00]. The last bin is closed on the right.
|
||||||
|
/// </summary>
|
||||||
|
public const decimal BinWidth = 0.10m;
|
||||||
|
|
||||||
|
private readonly IAnomalyRepository _anomalies;
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly IResultRepository _results;
|
||||||
|
private readonly ILogger<EvaluateAnomalyOutcomesUseCase> _logger;
|
||||||
|
|
||||||
|
public EvaluateAnomalyOutcomesUseCase(
|
||||||
|
IAnomalyRepository anomalies,
|
||||||
|
IEventRepository events,
|
||||||
|
IResultRepository results,
|
||||||
|
ILogger<EvaluateAnomalyOutcomesUseCase> 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<AnomalyOutcomeReport> ExecuteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("EvaluateAnomalyOutcomesUseCase: report build started");
|
||||||
|
|
||||||
|
var anomalies = await _anomalies.ListAsync(ct).ConfigureAwait(false);
|
||||||
|
if (anomalies.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"EvaluateAnomalyOutcomesUseCase: no anomalies — empty report");
|
||||||
|
return EmptyReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batched lookups — a single query each, replacing the prior per-event
|
||||||
|
// GetAsync round-trip (N+1 against SQLite).
|
||||||
|
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
|
||||||
|
|
||||||
|
var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
|
||||||
|
var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var eventTitles = new Dictionary<DomainEventId, string>(eventLookup.Count);
|
||||||
|
foreach (var (id, ev) in eventLookup)
|
||||||
|
eventTitles[id] = ev.Title;
|
||||||
|
|
||||||
|
// Evaluate every anomaly through the pure domain function.
|
||||||
|
var resolved = new List<ResolvedAnomaly>();
|
||||||
|
var unresolved = new List<ResolvedAnomaly>();
|
||||||
|
foreach (var anomaly in anomalies)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
eventLookup.TryGetValue(anomaly.EventId, out var ev);
|
||||||
|
resultLookup.TryGetValue(anomaly.EventId, out var result);
|
||||||
|
|
||||||
|
var evaluated = AnomalyOutcomeEvaluator.Evaluate(anomaly, ev?.Sport, result);
|
||||||
|
|
||||||
|
if (evaluated.Outcome == AnomalyOutcomeKind.Unresolved)
|
||||||
|
unresolved.Add(evaluated);
|
||||||
|
else
|
||||||
|
resolved.Add(evaluated);
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedOrdered = resolved
|
||||||
|
.OrderByDescending(r => r.DetectedAt)
|
||||||
|
.ToList();
|
||||||
|
var unresolvedOrdered = unresolved
|
||||||
|
.OrderByDescending(r => r.DetectedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var hitCount = resolvedOrdered.Count(r => r.Outcome == AnomalyOutcomeKind.Hit);
|
||||||
|
var missCount = resolvedOrdered.Count - hitCount;
|
||||||
|
|
||||||
|
var report = new AnomalyOutcomeReport(
|
||||||
|
TotalAnomalies: anomalies.Count,
|
||||||
|
ResolvedCount: resolvedOrdered.Count,
|
||||||
|
UnresolvedCount: unresolvedOrdered.Count,
|
||||||
|
HitCount: hitCount,
|
||||||
|
MissCount: missCount,
|
||||||
|
HitRate: ComputeRate(hitCount, resolvedOrdered.Count),
|
||||||
|
BySeverity: BuildSeverityBuckets(resolvedOrdered),
|
||||||
|
BySport: BuildSportBuckets(resolvedOrdered),
|
||||||
|
ByScoreBin: BuildScoreBins(resolvedOrdered),
|
||||||
|
Resolved: resolvedOrdered,
|
||||||
|
Unresolved: unresolvedOrdered,
|
||||||
|
EventTitles: eventTitles);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"EvaluateAnomalyOutcomesUseCase: report ready — total={Total}, resolved={Resolved}, hits={Hits}",
|
||||||
|
report.TotalAnomalies, report.ResolvedCount, report.HitCount);
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bucketers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static IReadOnlyList<OutcomeBucket> BuildSeverityBuckets(
|
||||||
|
IReadOnlyCollection<ResolvedAnomaly> resolved)
|
||||||
|
{
|
||||||
|
// Thresholds sourced from the Domain so the UI's severity badge and
|
||||||
|
// this report cannot drift out of sync — single source of truth.
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
BuildBucket(OutcomeBucketKeys.SeverityLow,
|
||||||
|
resolved.Where(r => r.Score < AnomalySeverityThresholds.Medium)),
|
||||||
|
BuildBucket(OutcomeBucketKeys.SeverityMedium,
|
||||||
|
resolved.Where(r => r.Score >= AnomalySeverityThresholds.Medium
|
||||||
|
&& r.Score < AnomalySeverityThresholds.High)),
|
||||||
|
BuildBucket(OutcomeBucketKeys.SeverityHigh,
|
||||||
|
resolved.Where(r => r.Score >= AnomalySeverityThresholds.High)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<OutcomeBucket> BuildSportBuckets(
|
||||||
|
IReadOnlyCollection<ResolvedAnomaly> resolved)
|
||||||
|
{
|
||||||
|
return resolved
|
||||||
|
.Where(r => r.Sport is not null)
|
||||||
|
.GroupBy(r => r.Sport!.Value)
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g => BuildBucket(
|
||||||
|
key: string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}{1}",
|
||||||
|
OutcomeBucketKeys.SportPrefix,
|
||||||
|
g.Key),
|
||||||
|
items: g))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<OutcomeBucket> BuildScoreBins(
|
||||||
|
IReadOnlyCollection<ResolvedAnomaly> resolved)
|
||||||
|
{
|
||||||
|
// Default range is the canonical [0.30, 1.00] with seven 0.10-wide bins.
|
||||||
|
// If the operator has lowered the detector's flip threshold and we have
|
||||||
|
// resolved anomalies below 0.30, prepend additional bins so every row in
|
||||||
|
// the report shows up in exactly one bucket — the histogram total must
|
||||||
|
// equal ResolvedCount no matter how the detector is tuned.
|
||||||
|
var floor = MinScore;
|
||||||
|
if (resolved.Count > 0)
|
||||||
|
{
|
||||||
|
var lowest = resolved.Min(r => r.Score);
|
||||||
|
if (lowest < MinScore)
|
||||||
|
{
|
||||||
|
var binsBelow = Math.Ceiling((MinScore - lowest) / BinWidth);
|
||||||
|
floor = MinScore - binsBelow * BinWidth;
|
||||||
|
if (floor < 0m) floor = 0m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bins = new List<OutcomeBucket>();
|
||||||
|
for (var start = floor; start < 1.0m; start += BinWidth)
|
||||||
|
{
|
||||||
|
var binStart = start;
|
||||||
|
var binEnd = start + BinWidth;
|
||||||
|
var isLast = binEnd >= 1.0m;
|
||||||
|
|
||||||
|
// Last bin is closed on the right so 1.00 lands in [0.90, 1.00].
|
||||||
|
var inBin = resolved.Where(r =>
|
||||||
|
r.Score >= binStart &&
|
||||||
|
(isLast ? r.Score <= 1.0m : r.Score < binEnd));
|
||||||
|
|
||||||
|
var key = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}{1:0.00}-{2:0.00}",
|
||||||
|
OutcomeBucketKeys.BinPrefix,
|
||||||
|
binStart,
|
||||||
|
Math.Min(binEnd, 1.0m));
|
||||||
|
|
||||||
|
bins.Add(BuildBucket(key, inBin));
|
||||||
|
}
|
||||||
|
return bins;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OutcomeBucket BuildBucket(string key, IEnumerable<ResolvedAnomaly> items)
|
||||||
|
{
|
||||||
|
var list = items as IReadOnlyCollection<ResolvedAnomaly> ?? items.ToList();
|
||||||
|
var total = list.Count;
|
||||||
|
var hits = list.Count(r => r.Outcome == AnomalyOutcomeKind.Hit);
|
||||||
|
return new OutcomeBucket(key, total, hits, ComputeRate(hits, total));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? ComputeRate(int numerator, int denominator) =>
|
||||||
|
denominator == 0
|
||||||
|
? null
|
||||||
|
: Math.Round(numerator / (decimal)denominator, 4);
|
||||||
|
|
||||||
|
private static AnomalyOutcomeReport EmptyReport() =>
|
||||||
|
new(
|
||||||
|
TotalAnomalies: 0,
|
||||||
|
ResolvedCount: 0,
|
||||||
|
UnresolvedCount: 0,
|
||||||
|
HitCount: 0,
|
||||||
|
MissCount: 0,
|
||||||
|
HitRate: null,
|
||||||
|
BySeverity: Array.Empty<OutcomeBucket>(),
|
||||||
|
BySport: Array.Empty<OutcomeBucket>(),
|
||||||
|
ByScoreBin: Array.Empty<OutcomeBucket>(),
|
||||||
|
Resolved: Array.Empty<ResolvedAnomaly>(),
|
||||||
|
Unresolved: Array.Empty<ResolvedAnomaly>(),
|
||||||
|
EventTitles: new Dictionary<DomainEventId, string>());
|
||||||
|
}
|
||||||
@@ -149,12 +149,13 @@ public sealed class PullResultsUseCase
|
|||||||
{
|
{
|
||||||
if (selection is { Count: > 0 })
|
if (selection is { Count: > 0 })
|
||||||
{
|
{
|
||||||
|
// Batched load (was N+1); preserve the caller's selection order and
|
||||||
|
// silently drop ids with no stored event.
|
||||||
|
var events = await _eventRepo.GetManyAsync(selection, ct).ConfigureAwait(false);
|
||||||
var resolved = new List<Event>(selection.Count);
|
var resolved = new List<Event>(selection.Count);
|
||||||
foreach (var id in selection)
|
foreach (var id in selection)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
if (events.TryGetValue(id, out var ev))
|
||||||
var ev = await _eventRepo.GetAsync(id, ct).ConfigureAwait(false);
|
|
||||||
if (ev is not null)
|
|
||||||
resolved.Add(ev);
|
resolved.Add(ev);
|
||||||
}
|
}
|
||||||
return resolved;
|
return resolved;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a new <see cref="PlacedBet"/> entered manually via the Journal UI.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The use case validates that the referenced event exists, then persists the
|
||||||
|
/// bet. If the event already has a final result the bet is graded on the spot
|
||||||
|
/// via <see cref="Marathon.Domain.Betting.BetOutcomeResolver"/> — saves the
|
||||||
|
/// user a round-trip to the resolver page when entering historical wagers.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class RecordPlacedBetUseCase
|
||||||
|
{
|
||||||
|
private readonly IPlacedBetRepository _bets;
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly IResultRepository _results;
|
||||||
|
private readonly ILogger<RecordPlacedBetUseCase> _logger;
|
||||||
|
|
||||||
|
public RecordPlacedBetUseCase(
|
||||||
|
IPlacedBetRepository bets,
|
||||||
|
IEventRepository events,
|
||||||
|
IResultRepository results,
|
||||||
|
ILogger<RecordPlacedBetUseCase> logger)
|
||||||
|
{
|
||||||
|
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists <paramref name="bet"/>. Returns the bet as stored — if the
|
||||||
|
/// event already has a result, the returned instance reflects the graded
|
||||||
|
/// <see cref="BetOutcome"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// The bet references an unknown event. The journal does not allow free-form
|
||||||
|
/// event codes — wagers must be on events the scraper has captured so the
|
||||||
|
/// CLV calculator can compare against the closing snapshot.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<PlacedBet> ExecuteAsync(PlacedBet bet, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bet);
|
||||||
|
|
||||||
|
// Confirm the event exists in the local store.
|
||||||
|
var ev = await _events.GetAsync(bet.EventId, ct).ConfigureAwait(false);
|
||||||
|
if (ev is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Cannot record a bet on unknown event '{bet.EventId.Value}'. " +
|
||||||
|
"The event must already be present in the scrape store.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var toPersist = bet;
|
||||||
|
|
||||||
|
// Auto-grade if a result is already available.
|
||||||
|
if (bet.Outcome == BetOutcome.Pending)
|
||||||
|
{
|
||||||
|
var result = await _results.GetAsync(bet.EventId, ct).ConfigureAwait(false);
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(bet.Selection, result);
|
||||||
|
if (graded is not null)
|
||||||
|
{
|
||||||
|
toPersist = bet.WithOutcome(graded.Value);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"RecordPlacedBetUseCase: bet {BetId} on event {EventId} auto-graded as {Outcome}",
|
||||||
|
toPersist.Id, ((DomainEventId)toPersist.EventId).Value, graded.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _bets.AddAsync(toPersist, ct).ConfigureAwait(false);
|
||||||
|
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"RecordPlacedBetUseCase: persisted bet {BetId} on event {EventId} stake={Stake} rate={Rate}",
|
||||||
|
toPersist.Id, ((DomainEventId)toPersist.EventId).Value, toPersist.Stake, toPersist.Selection.Rate.Value);
|
||||||
|
|
||||||
|
return toPersist;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Domain.Betting;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sweeps the journal for <see cref="BetOutcome.Pending"/> bets whose events
|
||||||
|
/// have been graded, and updates them in bulk via
|
||||||
|
/// <see cref="BetOutcomeResolver"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Called on demand from the Journal page's "Resolve pending" button. The
|
||||||
|
/// design is idempotent — bets that cannot be auto-graded (period-scope, or
|
||||||
|
/// no result yet) are left untouched and surface again on the next pass.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ResolvePendingBetsUseCase
|
||||||
|
{
|
||||||
|
private readonly IPlacedBetRepository _bets;
|
||||||
|
private readonly IResultRepository _results;
|
||||||
|
private readonly ILogger<ResolvePendingBetsUseCase> _logger;
|
||||||
|
|
||||||
|
public ResolvePendingBetsUseCase(
|
||||||
|
IPlacedBetRepository bets,
|
||||||
|
IResultRepository results,
|
||||||
|
ILogger<ResolvePendingBetsUseCase> logger)
|
||||||
|
{
|
||||||
|
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
|
||||||
|
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the number of bets that were transitioned out of Pending in this pass.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> ExecuteAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var pending = await _bets.ListByOutcomeAsync(BetOutcome.Pending, ct).ConfigureAwait(false);
|
||||||
|
if (pending.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("ResolvePendingBetsUseCase: no pending bets");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache results per event so we do not re-query for each bet on the same event.
|
||||||
|
var resultCache = new Dictionary<DomainEventId, EventResult?>();
|
||||||
|
var resolvedCount = 0;
|
||||||
|
|
||||||
|
foreach (var bet in pending)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!resultCache.TryGetValue(bet.EventId, out var result))
|
||||||
|
{
|
||||||
|
result = await _results.GetAsync(bet.EventId, ct).ConfigureAwait(false);
|
||||||
|
resultCache[bet.EventId] = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result is null) continue;
|
||||||
|
|
||||||
|
var graded = BetOutcomeResolver.Resolve(bet.Selection, result);
|
||||||
|
if (graded is null) continue;
|
||||||
|
|
||||||
|
var updated = bet.WithOutcome(graded.Value);
|
||||||
|
await _bets.UpdateAsync(updated, ct).ConfigureAwait(false);
|
||||||
|
resolvedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save before logging — if the batch fails, an exception bubbles out and
|
||||||
|
// the success-count log is never emitted; we never report a graded count
|
||||||
|
// that was rolled back.
|
||||||
|
if (resolvedCount > 0)
|
||||||
|
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"ResolvePendingBetsUseCase: graded {Resolved} of {Pending} pending bets",
|
||||||
|
resolvedCount, pending.Count);
|
||||||
|
|
||||||
|
return resolvedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs the backtest over every graded anomaly (no date filter).</summary>
|
||||||
|
public Task<BacktestResult> ExecuteAsync(
|
||||||
|
BacktestStrategy strategy,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> ExecuteAsync(strategy, dateRange: null, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the backtest over anomalies detected within <paramref name="dateRange"/>
|
||||||
|
/// (inclusive); pass <c>null</c> to include every graded anomaly. The date filter
|
||||||
|
/// is pushed to SQL via <see cref="IAnomalyRepository.ListByDateRangeAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<BacktestResult> ExecuteAsync(
|
||||||
|
BacktestStrategy strategy,
|
||||||
|
DateRange? dateRange,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(strategy);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"RunBacktestUseCase: started — bankroll={Bankroll}, minScore={MinScore}, stakeRule={Rule}, range={Range}",
|
||||||
|
strategy.StartingBankroll, strategy.MinScore, strategy.StakeRule,
|
||||||
|
dateRange is null ? "all" : $"{dateRange.From:O}..{dateRange.To:O}");
|
||||||
|
|
||||||
|
var anomalies = dateRange is null
|
||||||
|
? await _anomalies.ListAsync(ct).ConfigureAwait(false)
|
||||||
|
: await _anomalies.ListByDateRangeAsync(dateRange.From, dateRange.To, ct).ConfigureAwait(false);
|
||||||
|
if (anomalies.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("RunBacktestUseCase: no anomalies — empty result");
|
||||||
|
return BacktestSimulator.Run(strategy, Array.Empty<BacktestCandidate>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batched lookups — a single query each, replacing the prior per-event
|
||||||
|
// GetAsync round-trip (N+1 against SQLite).
|
||||||
|
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
|
||||||
|
|
||||||
|
var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
|
||||||
|
var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var titles = new Dictionary<DomainEventId, string>(eventLookup.Count);
|
||||||
|
foreach (var (id, ev) in eventLookup)
|
||||||
|
titles[id] = ev.Title;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using Marathon.Domain.Entities;
|
using Marathon.Domain.Entities;
|
||||||
using Marathon.Domain.Enums;
|
using Marathon.Domain.Enums;
|
||||||
using Marathon.Domain.ValueObjects;
|
using Marathon.Domain.ValueObjects;
|
||||||
@@ -23,20 +21,15 @@ namespace Marathon.Domain.AnomalyDetection;
|
|||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// This class is stateless and deterministic — identical inputs always produce identical output.
|
/// This class is stateless and deterministic — identical inputs always produce identical output.
|
||||||
/// It has no I/O or DI dependencies.
|
/// It has no I/O or DI dependencies. Evidence formatting is delegated to
|
||||||
|
/// <see cref="MatchWinEvidence"/> so every detector kind writes the identical shape.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AnomalyDetector
|
public sealed class AnomalyDetector : IAnomalyDetector
|
||||||
{
|
{
|
||||||
private readonly int _suspensionGapSeconds;
|
private readonly int _suspensionGapSeconds;
|
||||||
private readonly decimal _oddsFlipThreshold;
|
private readonly decimal _oddsFlipThreshold;
|
||||||
private readonly int _minSnapshotCount;
|
private readonly int _minSnapshotCount;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
WriteIndented = false,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <param name="suspensionGapSeconds">
|
/// <param name="suspensionGapSeconds">
|
||||||
/// Minimum gap between adjacent live snapshots (in seconds) to classify as a suspension.
|
/// Minimum gap between adjacent live snapshots (in seconds) to classify as a suspension.
|
||||||
/// Default per spec: 60.
|
/// Default per spec: 60.
|
||||||
@@ -68,16 +61,7 @@ public sealed class AnomalyDetector
|
|||||||
_minSnapshotCount = minSnapshotCount;
|
_minSnapshotCount = minSnapshotCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Analyses <paramref name="snapshots"/> for the given <paramref name="eventId"/> and
|
|
||||||
/// returns 0 or more anomalies detected in this timeline.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="eventId">The event being analysed.</param>
|
|
||||||
/// <param name="snapshots">All snapshots for this event (any source, any order).</param>
|
|
||||||
/// <returns>
|
|
||||||
/// An <see cref="IReadOnlyList{T}"/> of <see cref="Anomaly"/> records, one per qualifying
|
|
||||||
/// suspension interval. May be empty.
|
|
||||||
/// </returns>
|
|
||||||
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(eventId);
|
ArgumentNullException.ThrowIfNull(eventId);
|
||||||
@@ -119,9 +103,9 @@ public sealed class AnomalyDetector
|
|||||||
|
|
||||||
private Anomaly? TryDetectFlip(EventId eventId, SuspensionInterval interval)
|
private Anomaly? TryDetectFlip(EventId eventId, SuspensionInterval interval)
|
||||||
{
|
{
|
||||||
// Extract Match-Win bets from each snapshot.
|
// Extract Match-Win implied probabilities from each snapshot.
|
||||||
var preProbs = ExtractMatchWinProbabilities(interval.PreSuspension);
|
var preProbs = MatchWinEvidence.Extract(interval.PreSuspension);
|
||||||
var postProbs = ExtractMatchWinProbabilities(interval.PostSuspension);
|
var postProbs = MatchWinEvidence.Extract(interval.PostSuspension);
|
||||||
|
|
||||||
// Cannot compute flip if either snapshot lacks Win bets.
|
// Cannot compute flip if either snapshot lacks Win bets.
|
||||||
if (preProbs is null || postProbs is null)
|
if (preProbs is null || postProbs is null)
|
||||||
@@ -129,10 +113,8 @@ public sealed class AnomalyDetector
|
|||||||
|
|
||||||
// Step 4 — compute flip score = max(|p_post[i] − p_pre[i]|) across common sides.
|
// Step 4 — compute flip score = max(|p_post[i] − p_pre[i]|) across common sides.
|
||||||
decimal flipScore = 0m;
|
decimal flipScore = 0m;
|
||||||
flipScore = Math.Max(flipScore,
|
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P1 - preProbs.P1));
|
||||||
Math.Abs(postProbs.P1 - preProbs.P1));
|
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P2 - preProbs.P2));
|
||||||
flipScore = Math.Max(flipScore,
|
|
||||||
Math.Abs(postProbs.P2 - preProbs.P2));
|
|
||||||
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
|
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
|
||||||
{
|
{
|
||||||
flipScore = Math.Max(flipScore,
|
flipScore = Math.Max(flipScore,
|
||||||
@@ -140,7 +122,8 @@ public sealed class AnomalyDetector
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 5 — favourite-changed test: argmax of implied probability must differ.
|
// Step 5 — favourite-changed test: argmax of implied probability must differ.
|
||||||
bool favouriteChanged = DetermineFavourite(preProbs) != DetermineFavourite(postProbs);
|
bool favouriteChanged =
|
||||||
|
MatchWinEvidence.Favourite(preProbs) != MatchWinEvidence.Favourite(postProbs);
|
||||||
|
|
||||||
if (flipScore < _oddsFlipThreshold || !favouriteChanged)
|
if (flipScore < _oddsFlipThreshold || !favouriteChanged)
|
||||||
return null;
|
return null;
|
||||||
@@ -148,8 +131,11 @@ public sealed class AnomalyDetector
|
|||||||
// Clamp score to [0, 1] before constructing the Anomaly (domain invariant).
|
// Clamp score to [0, 1] before constructing the Anomaly (domain invariant).
|
||||||
var clampedScore = Math.Min(1m, flipScore);
|
var clampedScore = Math.Min(1m, flipScore);
|
||||||
|
|
||||||
// Step 6 — build evidence JSON.
|
// Step 6 — build evidence JSON via the shared formatter.
|
||||||
var evidenceJson = BuildEvidenceJson(interval, preProbs, postProbs);
|
var evidenceJson = MatchWinEvidence.BuildJson(
|
||||||
|
(int)interval.Gap.TotalSeconds,
|
||||||
|
interval.PreSuspension, preProbs,
|
||||||
|
interval.PostSuspension, postProbs);
|
||||||
|
|
||||||
return new Anomaly(
|
return new Anomaly(
|
||||||
Id: Guid.NewGuid(),
|
Id: Guid.NewGuid(),
|
||||||
@@ -159,100 +145,4 @@ public sealed class AnomalyDetector
|
|||||||
Score: clampedScore,
|
Score: clampedScore,
|
||||||
EvidenceJson: evidenceJson);
|
EvidenceJson: evidenceJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MatchWinProbabilities? ExtractMatchWinProbabilities(OddsSnapshot snapshot)
|
|
||||||
{
|
|
||||||
// Find Match-scope Win bets.
|
|
||||||
var matchWinBets = snapshot.Bets
|
|
||||||
.Where(b => b.Scope is MatchScope && b.Type == BetType.Win)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var win1 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side1);
|
|
||||||
var win2 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side2);
|
|
||||||
|
|
||||||
if (win1 is null || win2 is null)
|
|
||||||
return null; // Not enough data.
|
|
||||||
|
|
||||||
// Find optional Draw bet (MatchScope, BetType.Draw).
|
|
||||||
var drawBet = snapshot.Bets
|
|
||||||
.FirstOrDefault(b => b.Scope is MatchScope && b.Type == BetType.Draw);
|
|
||||||
|
|
||||||
// Raw implied probabilities: p = 1 / rate.
|
|
||||||
decimal rawP1 = 1m / win1.Rate.Value;
|
|
||||||
decimal rawP2 = 1m / win2.Rate.Value;
|
|
||||||
decimal rawDraw = drawBet is not null ? 1m / drawBet.Rate.Value : 0m;
|
|
||||||
decimal total = rawP1 + rawP2 + rawDraw;
|
|
||||||
|
|
||||||
// Normalise so they sum to 1.
|
|
||||||
decimal p1 = rawP1 / total;
|
|
||||||
decimal p2 = rawP2 / total;
|
|
||||||
decimal pDraw = drawBet is not null ? rawDraw / total : 0m;
|
|
||||||
|
|
||||||
return new MatchWinProbabilities(
|
|
||||||
P1: p1,
|
|
||||||
PDraw: drawBet is not null ? pDraw : null,
|
|
||||||
P2: p2,
|
|
||||||
Rate1: win1.Rate.Value,
|
|
||||||
RateDraw: drawBet?.Rate.Value,
|
|
||||||
Rate2: win2.Rate.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string DetermineFavourite(MatchWinProbabilities probs)
|
|
||||||
{
|
|
||||||
// The favourite is the side with the highest normalised implied probability.
|
|
||||||
if (probs.PDraw.HasValue && probs.PDraw.Value > probs.P1 && probs.PDraw.Value > probs.P2)
|
|
||||||
return "Draw";
|
|
||||||
return probs.P1 >= probs.P2 ? "Side1" : "Side2";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string BuildEvidenceJson(
|
|
||||||
SuspensionInterval interval,
|
|
||||||
MatchWinProbabilities preProbs,
|
|
||||||
MatchWinProbabilities postProbs)
|
|
||||||
{
|
|
||||||
var payload = new EvidencePayload(
|
|
||||||
SuspensionGapSeconds: (int)interval.Gap.TotalSeconds,
|
|
||||||
PreSuspension: new SnapshotEvidence(
|
|
||||||
CapturedAt: interval.PreSuspension.CapturedAt.ToString("O"),
|
|
||||||
P1: preProbs.P1,
|
|
||||||
PDraw: preProbs.PDraw,
|
|
||||||
P2: preProbs.P2,
|
|
||||||
Rate1: preProbs.Rate1,
|
|
||||||
RateDraw: preProbs.RateDraw,
|
|
||||||
Rate2: preProbs.Rate2),
|
|
||||||
PostSuspension: new SnapshotEvidence(
|
|
||||||
CapturedAt: interval.PostSuspension.CapturedAt.ToString("O"),
|
|
||||||
P1: postProbs.P1,
|
|
||||||
PDraw: postProbs.PDraw,
|
|
||||||
P2: postProbs.P2,
|
|
||||||
Rate1: postProbs.Rate1,
|
|
||||||
RateDraw: postProbs.RateDraw,
|
|
||||||
Rate2: postProbs.Rate2));
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(payload, JsonOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Nested types ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private sealed record MatchWinProbabilities(
|
|
||||||
decimal P1,
|
|
||||||
decimal? PDraw,
|
|
||||||
decimal P2,
|
|
||||||
decimal Rate1,
|
|
||||||
decimal? RateDraw,
|
|
||||||
decimal Rate2);
|
|
||||||
|
|
||||||
private sealed record EvidencePayload(
|
|
||||||
[property: JsonPropertyName("suspensionGapSeconds")] int SuspensionGapSeconds,
|
|
||||||
[property: JsonPropertyName("preSuspension")] SnapshotEvidence PreSuspension,
|
|
||||||
[property: JsonPropertyName("postSuspension")] SnapshotEvidence PostSuspension);
|
|
||||||
|
|
||||||
private sealed record SnapshotEvidence(
|
|
||||||
[property: JsonPropertyName("capturedAt")] string CapturedAt,
|
|
||||||
[property: JsonPropertyName("p1")] decimal P1,
|
|
||||||
[property: JsonPropertyName("pDraw")] decimal? PDraw,
|
|
||||||
[property: JsonPropertyName("p2")] decimal P2,
|
|
||||||
[property: JsonPropertyName("rate1")] decimal Rate1,
|
|
||||||
[property: JsonPropertyName("rateDraw")] decimal? RateDraw,
|
|
||||||
[property: JsonPropertyName("rate2")] decimal Rate2);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strongly typed projection of the JSON payload written by <see cref="AnomalyDetector"/>
|
||||||
|
/// into <see cref="Anomaly.EvidenceJson"/>. Captures pre- and post-suspension snapshots
|
||||||
|
/// of normalised implied probabilities and raw rates for the Match-Win market.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The evaluator and any reader that needs to inspect an anomaly's evidence should
|
||||||
|
/// parse via <see cref="AnomalyEvidenceParser.TryParse"/> rather than re-implement
|
||||||
|
/// the JSON shape — the detector owns the schema.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record AnomalyEvidenceData(
|
||||||
|
int SuspensionGapSeconds,
|
||||||
|
AnomalyEvidenceSide PreSuspension,
|
||||||
|
AnomalyEvidenceSide PostSuspension);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One side (pre or post) of a suspension interval. Probabilities are normalised
|
||||||
|
/// so that <c>P1 + (PDraw ?? 0) + P2 == 1</c>. Two-way markets (e.g. tennis)
|
||||||
|
/// leave <see cref="PDraw"/> and <see cref="RateDraw"/> null.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AnomalyEvidenceSide(
|
||||||
|
DateTimeOffset CapturedAt,
|
||||||
|
decimal P1,
|
||||||
|
decimal? PDraw,
|
||||||
|
decimal P2,
|
||||||
|
decimal Rate1,
|
||||||
|
decimal? RateDraw,
|
||||||
|
decimal Rate2)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The side carrying the highest normalised implied probability — i.e.,
|
||||||
|
/// the bookmaker's favourite at this point in time.
|
||||||
|
/// </summary>
|
||||||
|
public Side Favourite
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// Three-way: include Draw in the argmax.
|
||||||
|
var best = Side.Side1;
|
||||||
|
var bestValue = P1;
|
||||||
|
if (PDraw is { } pd && pd > bestValue)
|
||||||
|
{
|
||||||
|
best = Side.Draw;
|
||||||
|
bestValue = pd;
|
||||||
|
}
|
||||||
|
if (P2 > bestValue)
|
||||||
|
{
|
||||||
|
best = Side.Side2;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the <see cref="Anomaly.EvidenceJson"/> string emitted by
|
||||||
|
/// <see cref="AnomalyDetector"/>. Tolerant of malformed payloads — returns false
|
||||||
|
/// rather than throwing so callers can skip un-parseable anomalies silently.
|
||||||
|
/// </summary>
|
||||||
|
public static class AnomalyEvidenceParser
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to deserialise the evidence JSON. Returns <c>true</c> only when
|
||||||
|
/// both pre- and post-suspension snapshots are present.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParse(string? evidenceJson, out AnomalyEvidenceData data)
|
||||||
|
{
|
||||||
|
data = default!;
|
||||||
|
if (string.IsNullOrWhiteSpace(evidenceJson)) return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dto = JsonSerializer.Deserialize<EvidenceDto>(evidenceJson, JsonOptions);
|
||||||
|
if (dto is null || dto.PreSuspension is null || dto.PostSuspension is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
data = new AnomalyEvidenceData(
|
||||||
|
SuspensionGapSeconds: dto.SuspensionGapSeconds,
|
||||||
|
PreSuspension: ToSide(dto.PreSuspension),
|
||||||
|
PostSuspension: ToSide(dto.PostSuspension));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnomalyEvidenceSide ToSide(EvidenceSideDto dto) =>
|
||||||
|
new(
|
||||||
|
CapturedAt: dto.CapturedAt,
|
||||||
|
P1: dto.P1 ?? 0m,
|
||||||
|
PDraw: dto.PDraw,
|
||||||
|
P2: dto.P2 ?? 0m,
|
||||||
|
Rate1: dto.Rate1 ?? 0m,
|
||||||
|
RateDraw: dto.RateDraw,
|
||||||
|
Rate2: dto.Rate2 ?? 0m);
|
||||||
|
|
||||||
|
private sealed class EvidenceDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("suspensionGapSeconds")]
|
||||||
|
public int SuspensionGapSeconds { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("preSuspension")]
|
||||||
|
public EvidenceSideDto? PreSuspension { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("postSuspension")]
|
||||||
|
public EvidenceSideDto? PostSuspension { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EvidenceSideDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("capturedAt")]
|
||||||
|
public DateTimeOffset CapturedAt { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("p1")]
|
||||||
|
public decimal? P1 { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("pDraw")]
|
||||||
|
public decimal? PDraw { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("p2")]
|
||||||
|
public decimal? P2 { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("rate1")]
|
||||||
|
public decimal? Rate1 { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("rateDraw")]
|
||||||
|
public decimal? RateDraw { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("rate2")]
|
||||||
|
public decimal? Rate2 { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verdict produced by comparing an anomaly's predicted post-flip favourite
|
||||||
|
/// against the actual <see cref="EventResult.WinnerSide"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum AnomalyOutcomeKind
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The post-flip favourite (the side the bookmaker shortened odds on AFTER
|
||||||
|
/// the suspension) ended up winning. The flip was directionally correct.
|
||||||
|
/// </summary>
|
||||||
|
Hit,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The post-flip favourite did NOT win. The flip pointed at the wrong side.
|
||||||
|
/// </summary>
|
||||||
|
Miss,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No <see cref="EventResult"/> is available yet — outcome cannot be judged.
|
||||||
|
/// </summary>
|
||||||
|
Unresolved,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One anomaly paired with its evaluated outcome. Surfaced to the UI so each
|
||||||
|
/// resolved anomaly can be reviewed individually (e.g., when investigating
|
||||||
|
/// why the algorithm got a specific event wrong).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="PreFlipFavourite"/> and <see cref="PostFlipFavourite"/> are null
|
||||||
|
/// when the anomaly's evidence JSON could not be parsed — the outcome will be
|
||||||
|
/// <see cref="AnomalyOutcomeKind.Unresolved"/> in that case. Encoding the
|
||||||
|
/// absence keeps consumers from being shown a fabricated side.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record ResolvedAnomaly(
|
||||||
|
Guid AnomalyId,
|
||||||
|
EventId EventId,
|
||||||
|
DateTimeOffset DetectedAt,
|
||||||
|
decimal Score,
|
||||||
|
AnomalyKind Kind,
|
||||||
|
SportCode? Sport,
|
||||||
|
Side? PreFlipFavourite,
|
||||||
|
Side? PostFlipFavourite,
|
||||||
|
Side? ActualWinner,
|
||||||
|
AnomalyOutcomeKind Outcome);
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure domain function that evaluates whether a <see cref="AnomalyKind.SuspensionFlip"/>
|
||||||
|
/// anomaly's prediction (the post-suspension favourite) matched the actual
|
||||||
|
/// <see cref="EventResult.WinnerSide"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A "hit" is recorded when the side carrying the highest implied probability
|
||||||
|
/// in <see cref="AnomalyEvidenceData.PostSuspension"/> equals
|
||||||
|
/// <see cref="EventResult.WinnerSide"/>. For two-way markets (tennis), Draw is
|
||||||
|
/// not a possible favourite — the evaluator naturally never emits Draw there.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Stateless, deterministic, no I/O. Safe to call in tight loops.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class AnomalyOutcomeEvaluator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates one anomaly against its event (optional metadata) and its result
|
||||||
|
/// (optional — null when the match hasn't been graded yet).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="anomaly">The persisted anomaly.</param>
|
||||||
|
/// <param name="sport">
|
||||||
|
/// The event's sport — surfaced into <see cref="ResolvedAnomaly"/> so the UI
|
||||||
|
/// can group by sport. Null when the originating event row is missing.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="result">The event's final result, if known.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <see cref="ResolvedAnomaly"/> with <see cref="AnomalyOutcomeKind.Unresolved"/>
|
||||||
|
/// when <paramref name="result"/> is null or the evidence JSON cannot be parsed,
|
||||||
|
/// otherwise <see cref="AnomalyOutcomeKind.Hit"/> / <see cref="AnomalyOutcomeKind.Miss"/>.
|
||||||
|
/// </returns>
|
||||||
|
public static ResolvedAnomaly Evaluate(
|
||||||
|
Anomaly anomaly,
|
||||||
|
SportCode? sport,
|
||||||
|
EventResult? result)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(anomaly);
|
||||||
|
|
||||||
|
if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var data))
|
||||||
|
{
|
||||||
|
// Cannot determine favourite without evidence; treat as unresolved.
|
||||||
|
return new ResolvedAnomaly(
|
||||||
|
AnomalyId: anomaly.Id,
|
||||||
|
EventId: anomaly.EventId,
|
||||||
|
DetectedAt: anomaly.DetectedAt,
|
||||||
|
Score: anomaly.Score,
|
||||||
|
Kind: anomaly.Kind,
|
||||||
|
Sport: sport,
|
||||||
|
PreFlipFavourite: null,
|
||||||
|
PostFlipFavourite: null,
|
||||||
|
ActualWinner: result?.WinnerSide,
|
||||||
|
Outcome: AnomalyOutcomeKind.Unresolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
var preFav = data.PreSuspension.Favourite;
|
||||||
|
var postFav = data.PostSuspension.Favourite;
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return new ResolvedAnomaly(
|
||||||
|
AnomalyId: anomaly.Id,
|
||||||
|
EventId: anomaly.EventId,
|
||||||
|
DetectedAt: anomaly.DetectedAt,
|
||||||
|
Score: anomaly.Score,
|
||||||
|
Kind: anomaly.Kind,
|
||||||
|
Sport: sport,
|
||||||
|
PreFlipFavourite: preFav,
|
||||||
|
PostFlipFavourite: postFav,
|
||||||
|
ActualWinner: null,
|
||||||
|
Outcome: AnomalyOutcomeKind.Unresolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard rail for sport-specific impossibilities. A two-way market
|
||||||
|
// (e.g. tennis) cannot produce a Draw outcome — if one shows up the
|
||||||
|
// EventResult disagrees with the evidence schema, so we refuse to
|
||||||
|
// grade it instead of silently counting it as a Miss.
|
||||||
|
var isTwoWay = data.PreSuspension.PDraw is null && data.PostSuspension.PDraw is null;
|
||||||
|
if (isTwoWay && result.WinnerSide == Side.Draw)
|
||||||
|
{
|
||||||
|
return new ResolvedAnomaly(
|
||||||
|
AnomalyId: anomaly.Id,
|
||||||
|
EventId: anomaly.EventId,
|
||||||
|
DetectedAt: anomaly.DetectedAt,
|
||||||
|
Score: anomaly.Score,
|
||||||
|
Kind: anomaly.Kind,
|
||||||
|
Sport: sport,
|
||||||
|
PreFlipFavourite: preFav,
|
||||||
|
PostFlipFavourite: postFav,
|
||||||
|
ActualWinner: result.WinnerSide,
|
||||||
|
Outcome: AnomalyOutcomeKind.Unresolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
var outcome = postFav == result.WinnerSide
|
||||||
|
? AnomalyOutcomeKind.Hit
|
||||||
|
: AnomalyOutcomeKind.Miss;
|
||||||
|
|
||||||
|
return new ResolvedAnomaly(
|
||||||
|
AnomalyId: anomaly.Id,
|
||||||
|
EventId: anomaly.EventId,
|
||||||
|
DetectedAt: anomaly.DetectedAt,
|
||||||
|
Score: anomaly.Score,
|
||||||
|
Kind: anomaly.Kind,
|
||||||
|
Sport: sport,
|
||||||
|
PreFlipFavourite: preFav,
|
||||||
|
PostFlipFavourite: postFav,
|
||||||
|
ActualWinner: result.WinnerSide,
|
||||||
|
Outcome: outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for the severity bucket boundaries that the UI
|
||||||
|
/// pill / badge, the Insights breakdowns, and any future reporter share.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Buckets are inclusive on the left, exclusive on the right (except High
|
||||||
|
/// which extends to 1.00 inclusive):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Low [<see cref="Low"/>, <see cref="Medium"/>)</item>
|
||||||
|
/// <item>Medium [<see cref="Medium"/>, <see cref="High"/>)</item>
|
||||||
|
/// <item>High [<see cref="High"/>, 1.00]</item>
|
||||||
|
/// </list>
|
||||||
|
/// Defined at the Domain layer so both the Application reporter and the
|
||||||
|
/// Marathon.UI severity rules consume the same numbers — re-tuning happens
|
||||||
|
/// in one place.
|
||||||
|
/// </remarks>
|
||||||
|
public static class AnomalySeverityThresholds
|
||||||
|
{
|
||||||
|
/// <summary>Lower bound of the Low bucket. Matches the detector's default flip threshold.</summary>
|
||||||
|
public const decimal Low = 0.30m;
|
||||||
|
|
||||||
|
/// <summary>Lower bound of the Medium bucket.</summary>
|
||||||
|
public const decimal Medium = 0.45m;
|
||||||
|
|
||||||
|
/// <summary>Lower bound of the High bucket.</summary>
|
||||||
|
public const decimal High = 0.60m;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pure, stateless detector that scans one event's snapshot timeline and returns
|
||||||
|
/// any anomalies it finds. Implementations are deterministic and free of I/O so they
|
||||||
|
/// can be composed (fanned out) and unit-tested in isolation.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAnomalyDetector
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Analyses <paramref name="snapshots"/> for <paramref name="eventId"/> and returns
|
||||||
|
/// 0 or more anomalies. May be empty; never null.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared helper for the match-win implied-probability extraction and the canonical
|
||||||
|
/// pre/post evidence-JSON shape used by every <see cref="IAnomalyDetector"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Centralising the evidence format here guarantees that all detector kinds write the
|
||||||
|
/// identical on-disk shape, so the UI parser (<c>AnomalyEvidenceParser</c>) and the
|
||||||
|
/// outcome evaluator (<see cref="AnomalyOutcomeEvaluator"/>) work for every kind
|
||||||
|
/// without branching. The <c>suspensionGapSeconds</c> field carries the elapsed
|
||||||
|
/// seconds between the two snapshots — a suspension gap for flips, a drift window for
|
||||||
|
/// steam moves.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class MatchWinEvidence
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = false,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Normalised match-win implied probabilities + raw rates for a snapshot.</summary>
|
||||||
|
public sealed record Probabilities(
|
||||||
|
decimal P1,
|
||||||
|
decimal? PDraw,
|
||||||
|
decimal P2,
|
||||||
|
decimal Rate1,
|
||||||
|
decimal? RateDraw,
|
||||||
|
decimal Rate2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts normalised match-win implied probabilities, or null when the snapshot
|
||||||
|
/// lacks both Side1 and Side2 Match-Win bets.
|
||||||
|
/// </summary>
|
||||||
|
public static Probabilities? Extract(OddsSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var matchWinBets = snapshot.Bets
|
||||||
|
.Where(b => b.Scope is MatchScope && b.Type == BetType.Win)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var win1 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side1);
|
||||||
|
var win2 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side2);
|
||||||
|
if (win1 is null || win2 is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var drawBet = snapshot.Bets
|
||||||
|
.FirstOrDefault(b => b.Scope is MatchScope && b.Type == BetType.Draw);
|
||||||
|
|
||||||
|
// Raw implied probabilities: p = 1 / rate; normalise so they sum to 1.
|
||||||
|
decimal rawP1 = 1m / win1.Rate.Value;
|
||||||
|
decimal rawP2 = 1m / win2.Rate.Value;
|
||||||
|
decimal rawDraw = drawBet is not null ? 1m / drawBet.Rate.Value : 0m;
|
||||||
|
decimal total = rawP1 + rawP2 + rawDraw;
|
||||||
|
|
||||||
|
return new Probabilities(
|
||||||
|
P1: rawP1 / total,
|
||||||
|
PDraw: drawBet is not null ? rawDraw / total : null,
|
||||||
|
P2: rawP2 / total,
|
||||||
|
Rate1: win1.Rate.Value,
|
||||||
|
RateDraw: drawBet?.Rate.Value,
|
||||||
|
Rate2: win2.Rate.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Label of the side carrying the highest normalised implied probability.</summary>
|
||||||
|
public static string Favourite(Probabilities p)
|
||||||
|
{
|
||||||
|
if (p.PDraw.HasValue && p.PDraw.Value > p.P1 && p.PDraw.Value > p.P2)
|
||||||
|
return "Draw";
|
||||||
|
return p.P1 >= p.P2 ? "Side1" : "Side2";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serialises the canonical pre/post evidence payload.</summary>
|
||||||
|
public static string BuildJson(
|
||||||
|
int gapSeconds,
|
||||||
|
OddsSnapshot pre,
|
||||||
|
Probabilities preProbs,
|
||||||
|
OddsSnapshot post,
|
||||||
|
Probabilities postProbs)
|
||||||
|
{
|
||||||
|
var payload = new EvidencePayload(
|
||||||
|
SuspensionGapSeconds: gapSeconds,
|
||||||
|
PreSuspension: ToEvidence(pre, preProbs),
|
||||||
|
PostSuspension: ToEvidence(post, postProbs));
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(payload, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SnapshotEvidence ToEvidence(OddsSnapshot snapshot, Probabilities p) =>
|
||||||
|
new(
|
||||||
|
CapturedAt: snapshot.CapturedAt.ToString("O"),
|
||||||
|
P1: p.P1,
|
||||||
|
PDraw: p.PDraw,
|
||||||
|
P2: p.P2,
|
||||||
|
Rate1: p.Rate1,
|
||||||
|
RateDraw: p.RateDraw,
|
||||||
|
Rate2: p.Rate2);
|
||||||
|
|
||||||
|
private sealed record EvidencePayload(
|
||||||
|
[property: JsonPropertyName("suspensionGapSeconds")] int SuspensionGapSeconds,
|
||||||
|
[property: JsonPropertyName("preSuspension")] SnapshotEvidence PreSuspension,
|
||||||
|
[property: JsonPropertyName("postSuspension")] SnapshotEvidence PostSuspension);
|
||||||
|
|
||||||
|
private sealed record SnapshotEvidence(
|
||||||
|
[property: JsonPropertyName("capturedAt")] string CapturedAt,
|
||||||
|
[property: JsonPropertyName("p1")] decimal P1,
|
||||||
|
[property: JsonPropertyName("pDraw")] decimal? PDraw,
|
||||||
|
[property: JsonPropertyName("p2")] decimal P2,
|
||||||
|
[property: JsonPropertyName("rate1")] decimal Rate1,
|
||||||
|
[property: JsonPropertyName("rateDraw")] decimal? RateDraw,
|
||||||
|
[property: JsonPropertyName("rate2")] decimal Rate2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects a "steam move": a rapid, one-directional rise in a side's normalised
|
||||||
|
/// implied probability over a short CONTINUOUS window — money moving the line.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A window is only considered when it contains no suspension-sized gap between
|
||||||
|
/// consecutive snapshots (controlled by <c>maxStepGapSeconds</c>); drift across a
|
||||||
|
/// suspension is the <see cref="AnomalyDetector"/>'s (SuspensionFlip) territory, so
|
||||||
|
/// the two detectors never double-flag the same interval.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Emits an <see cref="AnomalyKind.SteamMove"/> anomaly whose pre/post evidence
|
||||||
|
/// brackets the drift, written in the shared <see cref="MatchWinEvidence"/> shape so
|
||||||
|
/// the UI and <see cref="AnomalyOutcomeEvaluator"/> handle it without branching.
|
||||||
|
/// A sustained steam may cross the threshold at several consecutive snapshots; those
|
||||||
|
/// are collapsed to one persisted row by the detection use case's dedup window.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SteamMoveDetector : IAnomalyDetector
|
||||||
|
{
|
||||||
|
private readonly int _windowSeconds;
|
||||||
|
private readonly decimal _driftThreshold;
|
||||||
|
private readonly int _minSnapshotCount;
|
||||||
|
private readonly int _maxStepGapSeconds;
|
||||||
|
|
||||||
|
/// <param name="windowSeconds">Trailing window (seconds) over which drift is measured.</param>
|
||||||
|
/// <param name="driftThreshold">Minimum one-directional implied-probability rise to flag; in (0, 1).</param>
|
||||||
|
/// <param name="minSnapshotCount">Minimum live snapshots before detection runs (>= 2).</param>
|
||||||
|
/// <param name="maxStepGapSeconds">
|
||||||
|
/// Maximum gap between consecutive snapshots for the window to count as continuous.
|
||||||
|
/// A larger gap means a suspension occurred — that is flip territory, not steam.
|
||||||
|
/// </param>
|
||||||
|
public SteamMoveDetector(int windowSeconds, decimal driftThreshold, int minSnapshotCount, int maxStepGapSeconds)
|
||||||
|
{
|
||||||
|
if (windowSeconds <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(windowSeconds), windowSeconds, "Must be positive.");
|
||||||
|
if (driftThreshold is <= 0m or >= 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(driftThreshold), driftThreshold, "Must be in (0, 1).");
|
||||||
|
if (minSnapshotCount < 2)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2.");
|
||||||
|
if (maxStepGapSeconds <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxStepGapSeconds), maxStepGapSeconds, "Must be positive.");
|
||||||
|
|
||||||
|
_windowSeconds = windowSeconds;
|
||||||
|
_driftThreshold = driftThreshold;
|
||||||
|
_minSnapshotCount = minSnapshotCount;
|
||||||
|
_maxStepGapSeconds = maxStepGapSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(eventId);
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshots);
|
||||||
|
|
||||||
|
var live = snapshots
|
||||||
|
.Where(s => s.Source == OddsSource.Live)
|
||||||
|
.OrderBy(s => s.CapturedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (live.Count < _minSnapshotCount)
|
||||||
|
return Array.Empty<Anomaly>();
|
||||||
|
|
||||||
|
var window = TimeSpan.FromSeconds(_windowSeconds);
|
||||||
|
var maxStepGap = TimeSpan.FromSeconds(_maxStepGapSeconds);
|
||||||
|
|
||||||
|
var anomalies = new List<Anomaly>();
|
||||||
|
int windowStart = 0;
|
||||||
|
int continuityStart = 0;
|
||||||
|
|
||||||
|
for (int end = 1; end < live.Count; end++)
|
||||||
|
{
|
||||||
|
// A suspension-sized step resets continuity: the drift after it is a flip,
|
||||||
|
// not a steam move, so steam windows never span a suspension.
|
||||||
|
if (live[end].CapturedAt - live[end - 1].CapturedAt > maxStepGap)
|
||||||
|
continuityStart = end;
|
||||||
|
|
||||||
|
// Shrink the trailing window so [windowStart, end] is within windowSeconds.
|
||||||
|
while (live[end].CapturedAt - live[windowStart].CapturedAt > window)
|
||||||
|
windowStart++;
|
||||||
|
|
||||||
|
int start = Math.Max(windowStart, continuityStart);
|
||||||
|
if (start >= end)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var pre = MatchWinEvidence.Extract(live[start]);
|
||||||
|
var post = MatchWinEvidence.Extract(live[end]);
|
||||||
|
if (pre is null || post is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// One-directional rise: a side's normalised probability INCREASED (odds
|
||||||
|
// shortened) by at least the threshold — money steamed onto that side.
|
||||||
|
decimal drift = Math.Max(post.P1 - pre.P1, post.P2 - pre.P2);
|
||||||
|
if (pre.PDraw.HasValue && post.PDraw.HasValue)
|
||||||
|
drift = Math.Max(drift, post.PDraw.Value - pre.PDraw.Value);
|
||||||
|
|
||||||
|
if (drift < _driftThreshold)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var gapSeconds = (int)(live[end].CapturedAt - live[start].CapturedAt).TotalSeconds;
|
||||||
|
var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, live[start], pre, live[end], post);
|
||||||
|
|
||||||
|
anomalies.Add(new Anomaly(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: eventId,
|
||||||
|
DetectedAt: MoscowTime.Now,
|
||||||
|
Kind: AnomalyKind.SteamMove,
|
||||||
|
Score: Math.Min(1m, drift),
|
||||||
|
EvidenceJson: evidenceJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
return anomalies.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Betting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure function that grades a <see cref="Bet"/> selection against a final
|
||||||
|
/// <see cref="EventResult"/>. Used by the bet-journal resolver to auto-settle
|
||||||
|
/// pending wagers the moment a result lands.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Grading rules:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>Win</c> (Side1/Side2): selection wins iff <c>WinnerSide</c> matches the side.</item>
|
||||||
|
/// <item><c>Draw</c>: wins iff <c>WinnerSide == Draw</c>.</item>
|
||||||
|
/// <item><c>WinFora</c> with handicap <c>h</c> on side S: adjusted S-score
|
||||||
|
/// = <c>S.Score + h</c>. Wins when adjusted > opponent, voids on tie, loses otherwise.</item>
|
||||||
|
/// <item><c>Total</c> with threshold <c>t</c>: combined = <c>Side1Score + Side2Score</c>.
|
||||||
|
/// <c>More</c> wins when combined > t, voids on equal, loses when less.
|
||||||
|
/// <c>Less</c> is the mirror image.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Returns <c>null</c> when the bet cannot be graded against this result —
|
||||||
|
/// today only period-scope selections, because <see cref="EventResult"/> stores
|
||||||
|
/// the full-time score only. Callers must leave such bets in
|
||||||
|
/// <see cref="BetOutcome.Pending"/> for manual settlement.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class BetOutcomeResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Grades <paramref name="selection"/> against <paramref name="result"/>.
|
||||||
|
/// Returns the resulting <see cref="BetOutcome"/> or <c>null</c> if the
|
||||||
|
/// bet shape cannot be auto-resolved from the available result data.
|
||||||
|
/// </summary>
|
||||||
|
public static BetOutcome? Resolve(Bet selection, EventResult result)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(selection);
|
||||||
|
ArgumentNullException.ThrowIfNull(result);
|
||||||
|
|
||||||
|
// Period-scope bets need per-period scores which EventResult does not
|
||||||
|
// carry today — leave for manual grading.
|
||||||
|
if (selection.Scope is not MatchScope)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return selection.Type switch
|
||||||
|
{
|
||||||
|
BetType.Win => ResolveWin(selection.Side, result),
|
||||||
|
BetType.Draw => ResolveDraw(result),
|
||||||
|
BetType.WinFora => ResolveFora(selection.Side, selection.Value!.Value, result),
|
||||||
|
BetType.Total => ResolveTotal(selection.Side, selection.Value!.Value, result),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BetOutcome ResolveWin(Side side, EventResult result) =>
|
||||||
|
result.WinnerSide == side ? BetOutcome.Won : BetOutcome.Lost;
|
||||||
|
|
||||||
|
private static BetOutcome ResolveDraw(EventResult result) =>
|
||||||
|
result.WinnerSide == Side.Draw ? BetOutcome.Won : BetOutcome.Lost;
|
||||||
|
|
||||||
|
private static BetOutcome ResolveFora(Side side, decimal handicap, EventResult result)
|
||||||
|
{
|
||||||
|
// Adjusted score for the side that took the handicap.
|
||||||
|
var (own, opponent) = side == Side.Side1
|
||||||
|
? (result.Side1Score, result.Side2Score)
|
||||||
|
: (result.Side2Score, result.Side1Score);
|
||||||
|
|
||||||
|
var adjusted = own + handicap;
|
||||||
|
|
||||||
|
if (adjusted > opponent) return BetOutcome.Won;
|
||||||
|
if (adjusted == opponent) return BetOutcome.Void;
|
||||||
|
return BetOutcome.Lost;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BetOutcome ResolveTotal(Side side, decimal threshold, EventResult result)
|
||||||
|
{
|
||||||
|
var total = (decimal)(result.Side1Score + result.Side2Score);
|
||||||
|
|
||||||
|
// More wins when total > threshold; Less wins when total < threshold.
|
||||||
|
// Equality is a push (Void) for both sides.
|
||||||
|
if (total == threshold) return BetOutcome.Void;
|
||||||
|
|
||||||
|
var totalIsOver = total > threshold;
|
||||||
|
return side switch
|
||||||
|
{
|
||||||
|
Side.More => totalIsOver ? BetOutcome.Won : BetOutcome.Lost,
|
||||||
|
Side.Less => totalIsOver ? BetOutcome.Lost : BetOutcome.Won,
|
||||||
|
_ => BetOutcome.Lost, // Defensive — Bet invariant rejects other sides for Total.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
namespace Marathon.Domain.Betting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure fractional-Kelly stake sizing for a single back bet at decimal odds.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The Kelly criterion maximises the long-run growth rate of a bankroll by staking
|
||||||
|
/// a fraction of it proportional to the edge. For decimal odds <c>o</c> and an
|
||||||
|
/// estimated win probability <c>p</c>, the full-Kelly fraction of bankroll is:
|
||||||
|
/// </para>
|
||||||
|
/// <code>f* = (p·o − 1) / (o − 1)</code>
|
||||||
|
/// <para>
|
||||||
|
/// When <c>f*</c> is zero or negative there is no positive expected value, and the
|
||||||
|
/// suggested stake is <c>0</c> — the calculator never recommends betting into a
|
||||||
|
/// negative-EV price. Most disciplined bettors stake a <i>fraction</i> of full
|
||||||
|
/// Kelly (e.g. quarter-Kelly, <c>fraction = 0.25</c>) to cut variance and blunt the
|
||||||
|
/// impact of probability-estimation error; full Kelly is famously over-aggressive
|
||||||
|
/// once <c>p</c> is even slightly wrong.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The win probability is an input the bettor supplies — it is intentionally NOT
|
||||||
|
/// derived from an anomaly score here, so the calculator stays a pure, reusable
|
||||||
|
/// money-management primitive independent of any signal source.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class KellyCalculator
|
||||||
|
{
|
||||||
|
/// <summary>Default Kelly fraction: quarter-Kelly — the conventional variance-safe choice.</summary>
|
||||||
|
public const decimal DefaultFraction = 0.25m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full-Kelly fraction of bankroll <c>(p·o − 1)/(o − 1)</c>. May be negative or
|
||||||
|
/// zero, signalling no positive edge. Exposed for callers that want the raw
|
||||||
|
/// figure (e.g. to display the edge) rather than a clamped stake.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="winProbability">Estimated win probability in the closed interval [0, 1].</param>
|
||||||
|
/// <param name="decimalOdds">Decimal odds, strictly greater than 1.0.</param>
|
||||||
|
public static decimal FullKellyFraction(decimal winProbability, decimal decimalOdds)
|
||||||
|
{
|
||||||
|
if (winProbability is < 0m or > 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(winProbability), winProbability, "Must be in [0, 1].");
|
||||||
|
if (decimalOdds <= 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(decimalOdds), decimalOdds, "Decimal odds must be greater than 1.0.");
|
||||||
|
|
||||||
|
return (winProbability * decimalOdds - 1m) / (decimalOdds - 1m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggested stake using fractional Kelly, rounded down to two decimals so the
|
||||||
|
/// suggestion is never larger than the theoretical figure. Returns <c>0</c> when
|
||||||
|
/// there is no positive edge.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="winProbability">Estimated win probability in the open interval (0, 1).</param>
|
||||||
|
/// <param name="decimalOdds">Decimal odds, strictly greater than 1.0.</param>
|
||||||
|
/// <param name="bankroll">Total bankroll; must be non-negative.</param>
|
||||||
|
/// <param name="fraction">Kelly fraction in (0, 1]; defaults to <see cref="DefaultFraction"/>.</param>
|
||||||
|
public static decimal SuggestStake(
|
||||||
|
decimal winProbability,
|
||||||
|
decimal decimalOdds,
|
||||||
|
decimal bankroll,
|
||||||
|
decimal fraction = DefaultFraction)
|
||||||
|
{
|
||||||
|
if (winProbability is <= 0m or >= 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(winProbability), winProbability, "Must be in the open interval (0, 1).");
|
||||||
|
if (bankroll < 0m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(bankroll), bankroll, "Must be non-negative.");
|
||||||
|
if (fraction is <= 0m or > 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(fraction), fraction, "Kelly fraction must be in (0, 1].");
|
||||||
|
|
||||||
|
// FullKellyFraction validates decimalOdds.
|
||||||
|
var full = FullKellyFraction(winProbability, decimalOdds);
|
||||||
|
if (full <= 0m)
|
||||||
|
return 0m;
|
||||||
|
|
||||||
|
var stake = fraction * full * bankroll;
|
||||||
|
|
||||||
|
// Truncate (floor toward zero) to two decimals so a stake suggestion never
|
||||||
|
// exceeds the computed figure — a conservative bias for real-money sizing.
|
||||||
|
return Math.Truncate(stake * 100m) / 100m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,4 +63,11 @@ public sealed record Event(
|
|||||||
/// numeric event ID.
|
/// numeric event ID.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string? EventPath { get; init; }
|
public string? EventPath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display title in the canonical "{Side1Name} vs {Side2Name}" form. Single
|
||||||
|
/// source for the home-vs-away join that was previously duplicated across the
|
||||||
|
/// report use cases and list/feed services.
|
||||||
|
/// </summary>
|
||||||
|
public string Title => $"{Side1Name} vs {Side2Name}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A wager the user manually recorded as having placed (with this or another
|
||||||
|
/// bookmaker). Reuses the <see cref="Bet"/> vocabulary so the journal can mirror
|
||||||
|
/// scraped markets directly — same Scope / Type / Side / Value / Rate invariants
|
||||||
|
/// apply to <see cref="Selection"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Id">Stable identifier — Guid so duplicates can be detected by the UI.</param>
|
||||||
|
/// <param name="EventId">Event the wager is on.</param>
|
||||||
|
/// <param name="Selection">
|
||||||
|
/// The market + rate the user took. <c>Selection.Rate</c> is the "taken rate"
|
||||||
|
/// used for ROI and CLV calculations.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Stake">
|
||||||
|
/// Money risked, in the user's currency. The domain does not encode currency —
|
||||||
|
/// stake values are compared as raw decimals.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="PlacedAt">When the bet was recorded. Stored as Moscow time.</param>
|
||||||
|
/// <param name="Outcome">Current settlement state — see <see cref="BetOutcome"/>.</param>
|
||||||
|
/// <param name="Notes">Optional free text — strategy tag, source, etc.</param>
|
||||||
|
public sealed record PlacedBet(
|
||||||
|
Guid Id,
|
||||||
|
EventId EventId,
|
||||||
|
Bet Selection,
|
||||||
|
decimal Stake,
|
||||||
|
DateTimeOffset PlacedAt,
|
||||||
|
BetOutcome Outcome,
|
||||||
|
string? Notes)
|
||||||
|
{
|
||||||
|
public Guid Id { get; } = Id == Guid.Empty
|
||||||
|
? throw new ArgumentException("PlacedBet Id must not be an empty GUID.", nameof(Id))
|
||||||
|
: Id;
|
||||||
|
|
||||||
|
public EventId EventId { get; } = EventId ?? throw new ArgumentNullException(nameof(EventId));
|
||||||
|
|
||||||
|
public Bet Selection { get; } = Selection ?? throw new ArgumentNullException(nameof(Selection));
|
||||||
|
|
||||||
|
public decimal Stake { get; } = Stake > 0m
|
||||||
|
? Stake
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(Stake), Stake,
|
||||||
|
"Stake must be positive.");
|
||||||
|
|
||||||
|
public DateTimeOffset PlacedAt { get; } = PlacedAt.Offset == MoscowTime.Offset
|
||||||
|
? PlacedAt
|
||||||
|
: throw new ArgumentException(
|
||||||
|
$"PlacedAt must be in Europe/Moscow time (UTC+03:00). " +
|
||||||
|
$"Received offset: {PlacedAt.Offset:hh\\:mm}.",
|
||||||
|
nameof(PlacedAt));
|
||||||
|
|
||||||
|
public BetOutcome Outcome { get; } = Outcome;
|
||||||
|
|
||||||
|
public string? Notes { get; } = string.IsNullOrWhiteSpace(Notes) ? null : Notes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gross return on this bet for the current outcome — the amount the
|
||||||
|
/// bookmaker pays back to the user (stake + winnings).
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="BetOutcome.Won"/>: <c>Stake × Rate</c></item>
|
||||||
|
/// <item><see cref="BetOutcome.Void"/>: <c>Stake</c> (push — stake returned)</item>
|
||||||
|
/// <item><see cref="BetOutcome.Lost"/>: <c>0</c></item>
|
||||||
|
/// <item><see cref="BetOutcome.Pending"/>: <c>null</c> (unknown)</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public decimal? GrossReturn => Outcome switch
|
||||||
|
{
|
||||||
|
BetOutcome.Won => Stake * Selection.Rate.Value,
|
||||||
|
BetOutcome.Void => Stake,
|
||||||
|
BetOutcome.Lost => 0m,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Net profit for the current outcome — <see cref="GrossReturn"/> minus
|
||||||
|
/// <see cref="Stake"/>. Negative for losses. Null while pending.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? NetProfit => GrossReturn is null ? null : GrossReturn.Value - Stake;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a copy with a new <see cref="Outcome"/> — used by the resolver
|
||||||
|
/// use case after grading the event. Constructs explicitly because the
|
||||||
|
/// manual validating <c>get</c>-only properties prevent <c>with</c>.
|
||||||
|
/// </summary>
|
||||||
|
public PlacedBet WithOutcome(BetOutcome outcome) =>
|
||||||
|
new(Id, EventId, Selection, Stake, PlacedAt, outcome, Notes);
|
||||||
|
}
|
||||||
@@ -10,4 +10,10 @@ public enum AnomalyKind
|
|||||||
/// Bookmaker suspended the market, then flipped the underdog/favourite coefficients.
|
/// Bookmaker suspended the market, then flipped the underdog/favourite coefficients.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
SuspensionFlip,
|
SuspensionFlip,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A rapid, one-directional drift in a side's implied probability over a short
|
||||||
|
/// continuous window (no suspension) — money moving the line ("steam").
|
||||||
|
/// </summary>
|
||||||
|
SteamMove,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settlement status of a user-tracked <see cref="Marathon.Domain.Entities.PlacedBet"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum BetOutcome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The event has not been graded yet, or the bet has not been auto-resolved
|
||||||
|
/// yet. Default state for a freshly recorded bet.
|
||||||
|
/// </summary>
|
||||||
|
Pending,
|
||||||
|
|
||||||
|
/// <summary>The selection won — stake returned plus winnings.</summary>
|
||||||
|
Won,
|
||||||
|
|
||||||
|
/// <summary>The selection lost — stake is forfeit.</summary>
|
||||||
|
Lost,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handicap/total push or event abandoned — stake returned, no profit/loss.
|
||||||
|
/// </summary>
|
||||||
|
Void,
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Marathon.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Marathon.Infrastructure.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(MarathonDbContext))]
|
||||||
|
[Migration("20260516000000_AddPlacedBets")]
|
||||||
|
public partial class AddPlacedBets : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PlacedBets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
EventCode = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Scope = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
PeriodNumber = table.Column<int>(type: "INTEGER", nullable: true),
|
||||||
|
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Side = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Value = table.Column<decimal>(type: "TEXT", nullable: true),
|
||||||
|
Rate = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||||
|
Stake = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||||
|
PlacedAt = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Outcome = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PlacedBets", x => x.Id);
|
||||||
|
// No foreign key to Events — the journal is user data and must
|
||||||
|
// survive snapshot retention pruning the source event row.
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PlacedBets_EventCode",
|
||||||
|
table: "PlacedBets",
|
||||||
|
column: "EventCode");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PlacedBets_Outcome",
|
||||||
|
table: "PlacedBets",
|
||||||
|
column: "Outcome");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(name: "PlacedBets");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Marathon.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Marathon.Infrastructure.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(MarathonDbContext))]
|
||||||
|
[Migration("20260528000000_AddSnapshotCapturedAtIndexes")]
|
||||||
|
public partial class AddSnapshotCapturedAtIndexes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Composite index for the dominant read shape: filter by EventCode + a
|
||||||
|
// CapturedAt range, frequently with ORDER BY CapturedAt. Lets SQLite serve
|
||||||
|
// both the predicate and the ordering from the index rather than scanning.
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Snapshots_EventCode_CapturedAt",
|
||||||
|
table: "Snapshots",
|
||||||
|
columns: new[] { "EventCode", "CapturedAt" });
|
||||||
|
|
||||||
|
// Covers GetLatestPreMatchAsync: EventCode + Source filter, ORDER BY CapturedAt DESC.
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Snapshots_EventCode_Source_CapturedAt",
|
||||||
|
table: "Snapshots",
|
||||||
|
columns: new[] { "EventCode", "Source", "CapturedAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Snapshots_EventCode_Source_CapturedAt",
|
||||||
|
table: "Snapshots");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Snapshots_EventCode_CapturedAt",
|
||||||
|
table: "Snapshots");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,8 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
|||||||
b.Property<int>("Source").HasColumnType("INTEGER");
|
b.Property<int>("Source").HasColumnType("INTEGER");
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
b.HasIndex("EventCode").HasDatabaseName("IX_Snapshots_EventCode");
|
b.HasIndex("EventCode").HasDatabaseName("IX_Snapshots_EventCode");
|
||||||
|
b.HasIndex("EventCode", "CapturedAt").HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
|
||||||
|
b.HasIndex("EventCode", "Source", "CapturedAt").HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
|
||||||
b.ToTable("Snapshots");
|
b.ToTable("Snapshots");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,6 +106,26 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
|||||||
b.ToTable("Sports");
|
b.ToTable("Sports");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id").HasColumnType("TEXT");
|
||||||
|
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
|
||||||
|
b.Property<int>("Scope").HasColumnType("INTEGER");
|
||||||
|
b.Property<int?>("PeriodNumber").HasColumnType("INTEGER");
|
||||||
|
b.Property<int>("Type").HasColumnType("INTEGER");
|
||||||
|
b.Property<int>("Side").HasColumnType("INTEGER");
|
||||||
|
b.Property<decimal?>("Value").HasColumnType("TEXT");
|
||||||
|
b.Property<decimal>("Rate").HasColumnType("TEXT");
|
||||||
|
b.Property<decimal>("Stake").HasColumnType("TEXT");
|
||||||
|
b.Property<string>("PlacedAt").IsRequired().HasColumnType("TEXT");
|
||||||
|
b.Property<int>("Outcome").HasColumnType("INTEGER");
|
||||||
|
b.Property<string>("Notes").HasColumnType("TEXT");
|
||||||
|
b.HasKey("Id");
|
||||||
|
b.HasIndex("EventCode").HasDatabaseName("IX_PlacedBets_EventCode");
|
||||||
|
b.HasIndex("Outcome").HasDatabaseName("IX_PlacedBets_Outcome");
|
||||||
|
b.ToTable("PlacedBets");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
|
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
|
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Marathon.Infrastructure.Persistence.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Marathon.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
internal sealed class PlacedBetConfiguration : IEntityTypeConfiguration<PlacedBetEntity>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PlacedBetEntity> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("PlacedBets");
|
||||||
|
|
||||||
|
builder.HasKey(b => b.Id);
|
||||||
|
builder.Property(b => b.Id).HasColumnType("TEXT").IsRequired();
|
||||||
|
builder.Property(b => b.EventCode).HasColumnType("TEXT").IsRequired();
|
||||||
|
|
||||||
|
builder.Property(b => b.Scope).HasColumnType("INTEGER").IsRequired();
|
||||||
|
builder.Property(b => b.PeriodNumber).HasColumnType("INTEGER");
|
||||||
|
builder.Property(b => b.Type).HasColumnType("INTEGER").IsRequired();
|
||||||
|
builder.Property(b => b.Side).HasColumnType("INTEGER").IsRequired();
|
||||||
|
builder.Property(b => b.Value).HasColumnType("TEXT");
|
||||||
|
builder.Property(b => b.Rate).HasColumnType("TEXT").IsRequired();
|
||||||
|
|
||||||
|
builder.Property(b => b.Stake).HasColumnType("TEXT").IsRequired();
|
||||||
|
builder.Property(b => b.PlacedAt).HasColumnType("TEXT").IsRequired();
|
||||||
|
builder.Property(b => b.Outcome).HasColumnType("INTEGER").IsRequired();
|
||||||
|
builder.Property(b => b.Notes).HasColumnType("TEXT");
|
||||||
|
|
||||||
|
// EventCode is intentionally NOT a foreign key — the journal is the
|
||||||
|
// user's data and must survive snapshot retention pruning the source
|
||||||
|
// event row. Existence is checked once at insert time by the use case.
|
||||||
|
builder.HasIndex(b => b.EventCode).HasDatabaseName("IX_PlacedBets_EventCode");
|
||||||
|
builder.HasIndex(b => b.Outcome).HasDatabaseName("IX_PlacedBets_Outcome");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,17 @@ internal sealed class SnapshotConfiguration : IEntityTypeConfiguration<SnapshotE
|
|||||||
|
|
||||||
builder.HasIndex(s => s.EventCode).HasDatabaseName("IX_Snapshots_EventCode");
|
builder.HasIndex(s => s.EventCode).HasDatabaseName("IX_Snapshots_EventCode");
|
||||||
|
|
||||||
|
// Snapshots is the largest table (live cadence 5–10s, 90-day retention) and
|
||||||
|
// every hot read filters EventCode + CapturedAt range, often with an ORDER BY
|
||||||
|
// CapturedAt. These composite indexes let SQLite satisfy the filter and the
|
||||||
|
// ordering from the index instead of scanning + sorting the table.
|
||||||
|
builder.HasIndex(s => new { s.EventCode, s.CapturedAt })
|
||||||
|
.HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
|
||||||
|
|
||||||
|
// Covers GetLatestPreMatchAsync: EventCode + Source filter, ORDER BY CapturedAt DESC.
|
||||||
|
builder.HasIndex(s => new { s.EventCode, s.Source, s.CapturedAt })
|
||||||
|
.HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
|
||||||
|
|
||||||
builder.HasMany(s => s.Bets)
|
builder.HasMany(s => s.Bets)
|
||||||
.WithOne(b => b.Snapshot)
|
.WithOne(b => b.Snapshot)
|
||||||
.HasForeignKey(b => b.SnapshotId)
|
.HasForeignKey(b => b.SnapshotId)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace Marathon.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core persistence entity for a user-tracked <see cref="Marathon.Domain.Entities.PlacedBet"/>.
|
||||||
|
/// Flattens the embedded <c>Bet</c> selection (Scope / Type / Side / Value / Rate)
|
||||||
|
/// into columns so SQLite can index by event and outcome cheaply.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlacedBetEntity
|
||||||
|
{
|
||||||
|
/// <summary>GUID primary key stored as TEXT.</summary>
|
||||||
|
public string Id { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <summary>Foreign key to <see cref="EventEntity.EventCode"/>.</summary>
|
||||||
|
public string EventCode { get; set; } = default!;
|
||||||
|
|
||||||
|
// ─── Embedded Bet selection ──────────────────────────────────────────────
|
||||||
|
/// <summary>Scope discriminator: 0 = Match, 1 = Period.</summary>
|
||||||
|
public int Scope { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Period number when <see cref="Scope"/> = 1; null otherwise.</summary>
|
||||||
|
public int? PeriodNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>BetType as int (Win / Draw / WinFora / Total).</summary>
|
||||||
|
public int Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Side as int (Side1 / Side2 / Draw / Less / More).</summary>
|
||||||
|
public int Side { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Handicap or total threshold; null for Win / Draw markets.</summary>
|
||||||
|
public decimal? Value { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Decimal odds the user took.</summary>
|
||||||
|
public decimal Rate { get; set; }
|
||||||
|
|
||||||
|
// ─── Wager fields ────────────────────────────────────────────────────────
|
||||||
|
/// <summary>Stake in the user's currency.</summary>
|
||||||
|
public decimal Stake { get; set; }
|
||||||
|
|
||||||
|
/// <summary>ISO 8601 timestamp when the bet was recorded (Moscow time).</summary>
|
||||||
|
public string PlacedAt { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <summary>BetOutcome as int (Pending / Won / Lost / Void).</summary>
|
||||||
|
public int Outcome { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional free-text note from the user.</summary>
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Marathon.Domain.Entities;
|
using Marathon.Domain.Entities;
|
||||||
using Marathon.Domain.Enums;
|
using Marathon.Domain.Enums;
|
||||||
using Marathon.Domain.ValueObjects;
|
using Marathon.Domain.ValueObjects;
|
||||||
@@ -10,14 +9,13 @@ namespace Marathon.Infrastructure.Persistence;
|
|||||||
/// Mapping helpers that translate between domain objects and EF Core persistence entities.
|
/// Mapping helpers that translate between domain objects and EF Core persistence entities.
|
||||||
/// Domain invariants are enforced on the domain side; mapping is purely structural.
|
/// Domain invariants are enforced on the domain side; mapping is purely structural.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ScheduledAt / CapturedAt / DetectedAt / CompletedAt / PlacedAt are encoded and
|
||||||
|
/// decoded exclusively through <see cref="SqliteDateText"/> so the write format and
|
||||||
|
/// the repositories' range-predicate format can never drift apart.
|
||||||
|
/// </remarks>
|
||||||
internal static class Mapping
|
internal static class Mapping
|
||||||
{
|
{
|
||||||
// ScheduledAt / CapturedAt / DetectedAt / CompletedAt are written via
|
|
||||||
// DateTimeOffset.ToString("O") — round-trip ISO 8601. Parse with the
|
|
||||||
// invariant culture and RoundtripKind so a non-en-US thread culture
|
|
||||||
// (or a future locale change) cannot corrupt the round-trip.
|
|
||||||
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
|
|
||||||
|
|
||||||
// ─── Bet scope discriminator constants ────────────────────────────────────
|
// ─── Bet scope discriminator constants ────────────────────────────────────
|
||||||
private const int ScopeMatch = 0;
|
private const int ScopeMatch = 0;
|
||||||
private const int ScopePeriod = 1;
|
private const int ScopePeriod = 1;
|
||||||
@@ -31,7 +29,7 @@ internal static class Mapping
|
|||||||
CountryCode = domain.CountryCode,
|
CountryCode = domain.CountryCode,
|
||||||
LeagueId = domain.LeagueId,
|
LeagueId = domain.LeagueId,
|
||||||
Category = domain.Category,
|
Category = domain.Category,
|
||||||
ScheduledAt = domain.ScheduledAt.ToString("O"),
|
ScheduledAt = SqliteDateText.Key(domain.ScheduledAt),
|
||||||
Side1Name = domain.Side1Name,
|
Side1Name = domain.Side1Name,
|
||||||
Side2Name = domain.Side2Name,
|
Side2Name = domain.Side2Name,
|
||||||
EventPath = domain.EventPath,
|
EventPath = domain.EventPath,
|
||||||
@@ -44,7 +42,7 @@ internal static class Mapping
|
|||||||
CountryCode: entity.CountryCode,
|
CountryCode: entity.CountryCode,
|
||||||
LeagueId: entity.LeagueId,
|
LeagueId: entity.LeagueId,
|
||||||
Category: entity.Category,
|
Category: entity.Category,
|
||||||
ScheduledAt: DateTimeOffset.Parse(entity.ScheduledAt, CultureInfo.InvariantCulture, RoundtripStyles),
|
ScheduledAt: SqliteDateText.Parse(entity.ScheduledAt),
|
||||||
Side1Name: entity.Side1Name,
|
Side1Name: entity.Side1Name,
|
||||||
Side2Name: entity.Side2Name)
|
Side2Name: entity.Side2Name)
|
||||||
{
|
{
|
||||||
@@ -57,7 +55,7 @@ internal static class Mapping
|
|||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
EventCode = domain.EventId.Value,
|
EventCode = domain.EventId.Value,
|
||||||
CapturedAt = domain.CapturedAt.ToString("O"),
|
CapturedAt = SqliteDateText.Key(domain.CapturedAt),
|
||||||
Source = (int)domain.Source,
|
Source = (int)domain.Source,
|
||||||
Bets = domain.Bets.Select(ToEntity).ToList(),
|
Bets = domain.Bets.Select(ToEntity).ToList(),
|
||||||
};
|
};
|
||||||
@@ -65,7 +63,7 @@ internal static class Mapping
|
|||||||
public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
|
public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
|
||||||
new(
|
new(
|
||||||
eventId: new EventId(entity.EventCode),
|
eventId: new EventId(entity.EventCode),
|
||||||
capturedAt: DateTimeOffset.Parse(entity.CapturedAt, CultureInfo.InvariantCulture, RoundtripStyles),
|
capturedAt: SqliteDateText.Parse(entity.CapturedAt),
|
||||||
source: (OddsSource)entity.Source,
|
source: (OddsSource)entity.Source,
|
||||||
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
|
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
|
||||||
|
|
||||||
@@ -109,7 +107,7 @@ internal static class Mapping
|
|||||||
Side1Score = domain.Side1Score,
|
Side1Score = domain.Side1Score,
|
||||||
Side2Score = domain.Side2Score,
|
Side2Score = domain.Side2Score,
|
||||||
WinnerSide = (int)domain.WinnerSide,
|
WinnerSide = (int)domain.WinnerSide,
|
||||||
CompletedAt = domain.CompletedAt.ToString("O"),
|
CompletedAt = SqliteDateText.Key(domain.CompletedAt),
|
||||||
};
|
};
|
||||||
|
|
||||||
public static EventResult ToDomain(EventResultEntity entity) =>
|
public static EventResult ToDomain(EventResultEntity entity) =>
|
||||||
@@ -118,7 +116,7 @@ internal static class Mapping
|
|||||||
Side1Score: entity.Side1Score,
|
Side1Score: entity.Side1Score,
|
||||||
Side2Score: entity.Side2Score,
|
Side2Score: entity.Side2Score,
|
||||||
WinnerSide: (Side)entity.WinnerSide,
|
WinnerSide: (Side)entity.WinnerSide,
|
||||||
CompletedAt: DateTimeOffset.Parse(entity.CompletedAt, CultureInfo.InvariantCulture, RoundtripStyles));
|
CompletedAt: SqliteDateText.Parse(entity.CompletedAt));
|
||||||
|
|
||||||
// ─── Anomaly ──────────────────────────────────────────────────────────────
|
// ─── Anomaly ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -127,7 +125,7 @@ internal static class Mapping
|
|||||||
{
|
{
|
||||||
Id = domain.Id.ToString(),
|
Id = domain.Id.ToString(),
|
||||||
EventCode = domain.EventId.Value,
|
EventCode = domain.EventId.Value,
|
||||||
DetectedAt = domain.DetectedAt.ToString("O"),
|
DetectedAt = SqliteDateText.Key(domain.DetectedAt),
|
||||||
Kind = (int)domain.Kind,
|
Kind = (int)domain.Kind,
|
||||||
Score = domain.Score,
|
Score = domain.Score,
|
||||||
EvidenceJson = domain.EvidenceJson,
|
EvidenceJson = domain.EvidenceJson,
|
||||||
@@ -137,7 +135,7 @@ internal static class Mapping
|
|||||||
new(
|
new(
|
||||||
Id: Guid.Parse(entity.Id),
|
Id: Guid.Parse(entity.Id),
|
||||||
EventId: new EventId(entity.EventCode),
|
EventId: new EventId(entity.EventCode),
|
||||||
DetectedAt: DateTimeOffset.Parse(entity.DetectedAt, CultureInfo.InvariantCulture, RoundtripStyles),
|
DetectedAt: SqliteDateText.Parse(entity.DetectedAt),
|
||||||
Kind: (AnomalyKind)entity.Kind,
|
Kind: (AnomalyKind)entity.Kind,
|
||||||
Score: entity.Score,
|
Score: entity.Score,
|
||||||
EvidenceJson: entity.EvidenceJson);
|
EvidenceJson: entity.EvidenceJson);
|
||||||
@@ -158,6 +156,51 @@ internal static class Mapping
|
|||||||
NameRu: entity.NameRu,
|
NameRu: entity.NameRu,
|
||||||
NameEn: entity.NameEn);
|
NameEn: entity.NameEn);
|
||||||
|
|
||||||
|
// ─── PlacedBet ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static PlacedBetEntity ToEntity(PlacedBet domain) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = domain.Id.ToString(),
|
||||||
|
EventCode = domain.EventId.Value,
|
||||||
|
Scope = domain.Selection.Scope is MatchScope ? ScopeMatch : ScopePeriod,
|
||||||
|
PeriodNumber = domain.Selection.Scope is PeriodScope ps ? ps.Number : null,
|
||||||
|
Type = (int)domain.Selection.Type,
|
||||||
|
Side = (int)domain.Selection.Side,
|
||||||
|
Value = domain.Selection.Value?.Value,
|
||||||
|
Rate = domain.Selection.Rate.Value,
|
||||||
|
Stake = domain.Stake,
|
||||||
|
PlacedAt = SqliteDateText.Key(domain.PlacedAt),
|
||||||
|
Outcome = (int)domain.Outcome,
|
||||||
|
Notes = domain.Notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static PlacedBet ToDomain(PlacedBetEntity entity)
|
||||||
|
{
|
||||||
|
var scope = entity.Scope switch
|
||||||
|
{
|
||||||
|
ScopeMatch => (BetScope)MatchScope.Instance,
|
||||||
|
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"Unknown BetScope discriminator: {entity.Scope}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var value = entity.Value.HasValue ? new OddsValue(entity.Value.Value) : null;
|
||||||
|
var rate = new OddsRate(entity.Rate);
|
||||||
|
var type = (BetType)entity.Type;
|
||||||
|
var side = (Side)entity.Side;
|
||||||
|
var selection = new Bet(scope, type, side, value, rate);
|
||||||
|
|
||||||
|
return new PlacedBet(
|
||||||
|
Id: Guid.Parse(entity.Id),
|
||||||
|
EventId: new EventId(entity.EventCode),
|
||||||
|
Selection: selection,
|
||||||
|
Stake: entity.Stake,
|
||||||
|
PlacedAt: SqliteDateText.Parse(entity.PlacedAt),
|
||||||
|
Outcome: (BetOutcome)entity.Outcome,
|
||||||
|
Notes: entity.Notes);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── League ───────────────────────────────────────────────────────────────
|
// ─── League ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public static LeagueEntity ToEntity(League domain) =>
|
public static LeagueEntity ToEntity(League domain) =>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public sealed class MarathonDbContext : DbContext
|
|||||||
public DbSet<AnomalyEntity> Anomalies => Set<AnomalyEntity>();
|
public DbSet<AnomalyEntity> Anomalies => Set<AnomalyEntity>();
|
||||||
public DbSet<SportEntity> Sports => Set<SportEntity>();
|
public DbSet<SportEntity> Sports => Set<SportEntity>();
|
||||||
public DbSet<LeagueEntity> Leagues => Set<LeagueEntity>();
|
public DbSet<LeagueEntity> Leagues => Set<LeagueEntity>();
|
||||||
|
public DbSet<PlacedBetEntity> PlacedBets => Set<PlacedBetEntity>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ public static class PersistenceModule
|
|||||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||||
services.AddScoped<IResultRepository, ResultRepository>();
|
services.AddScoped<IResultRepository, ResultRepository>();
|
||||||
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
|
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
|
||||||
|
services.AddScoped<IPlacedBetRepository, PlacedBetRepository>();
|
||||||
services.AddScoped<IExcelExporter, ExcelExporter>();
|
services.AddScoped<IExcelExporter, ExcelExporter>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -23,6 +23,44 @@ internal sealed class AnomalyRepository : IAnomalyRepository
|
|||||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Server-side COUNT(*) — the unread-badge hot path must not materialise the
|
||||||
|
// whole table (with EvidenceJson) just to count. DetectedAt is stored as the
|
||||||
|
// O-format TEXT key (see SqliteDateText); ">" matches the prior in-memory
|
||||||
|
// GetUnreadCountAsync semantics (strictly newer than the last-seen marker).
|
||||||
|
var sinceStr = SqliteDateText.Key(since);
|
||||||
|
return await _db.Anomalies.AsNoTracking()
|
||||||
|
.Where(a => a.DetectedAt.CompareTo(sinceStr) > 0)
|
||||||
|
.CountAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Anomaly>> ListByDateRangeAsync(
|
||||||
|
DateTimeOffset? from,
|
||||||
|
DateTimeOffset? to,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var q = _db.Anomalies.AsNoTracking();
|
||||||
|
|
||||||
|
if (from is { } f)
|
||||||
|
{
|
||||||
|
var fromStr = SqliteDateText.Key(f);
|
||||||
|
q = q.Where(a => a.DetectedAt.CompareTo(fromStr) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to is { } t)
|
||||||
|
{
|
||||||
|
var toStr = SqliteDateText.Key(t);
|
||||||
|
q = q.Where(a => a.DetectedAt.CompareTo(toStr) <= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entities = await q
|
||||||
|
.OrderByDescending(a => a.DetectedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task AddAsync(Anomaly entity, CancellationToken ct = default)
|
public async Task AddAsync(Anomaly entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var efEntity = Mapping.ToEntity(entity);
|
var efEntity = Mapping.ToEntity(entity);
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ internal sealed class EventRepository : IEventRepository
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// ScheduledAt is stored as ISO 8601 TEXT; SQLite TEXT comparison sorts correctly for ISO 8601.
|
// ScheduledAt is stored as ISO 8601 TEXT (see SqliteDateText); SQLite TEXT
|
||||||
var fromStr = range.From.ToString("O");
|
// comparison sorts chronologically for the fixed-offset O format.
|
||||||
var toStr = range.To.ToString("O");
|
var fromStr = SqliteDateText.Key(range.From);
|
||||||
|
var toStr = SqliteDateText.Key(range.To);
|
||||||
|
|
||||||
// EF Core SQLite cannot translate string.Compare(...) with StringComparison; it can
|
// EF Core SQLite cannot translate string.Compare(...) with StringComparison; it can
|
||||||
// translate the relational operators on string columns (which use BINARY/ordinal
|
// translate the relational operators on string columns (which use BINARY/ordinal
|
||||||
@@ -41,6 +42,57 @@ internal sealed class EventRepository : IEventRepository
|
|||||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
|
||||||
|
var fromStr = SqliteDateText.Key(query.Dates.From);
|
||||||
|
var toStr = SqliteDateText.Key(query.Dates.To);
|
||||||
|
|
||||||
|
// Date range + sport filter pushed to SQL so a multi-sport page no longer
|
||||||
|
// materialises every event in the window. The composite
|
||||||
|
// IX_Events_SportCode_ScheduledAt index covers this predicate. Case-sensitive
|
||||||
|
// search / country filtering and locale-aware sorting stay in the service
|
||||||
|
// layer where Cyrillic ordinal semantics are preserved.
|
||||||
|
var q = _db.Events.AsNoTracking()
|
||||||
|
.Where(e => e.ScheduledAt.CompareTo(fromStr) >= 0
|
||||||
|
&& e.ScheduledAt.CompareTo(toStr) <= 0);
|
||||||
|
|
||||||
|
if (query.SportCodes is { Count: > 0 } sports)
|
||||||
|
{
|
||||||
|
var sportArray = sports.Distinct().ToArray();
|
||||||
|
q = q.Where(e => sportArray.Contains(e.SportCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
var entities = await q.ToListAsync(ct);
|
||||||
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
|
||||||
|
IReadOnlyCollection<EventId> ids,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(ids);
|
||||||
|
|
||||||
|
var result = new Dictionary<EventId, Event>(ids.Count);
|
||||||
|
if (ids.Count == 0)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var codes = ids.Select(e => e.Value).Distinct().ToArray();
|
||||||
|
|
||||||
|
var entities = await _db.Events.AsNoTracking()
|
||||||
|
.Where(e => codes.Contains(e.EventCode))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
var domain = Mapping.ToDomain(entity);
|
||||||
|
result[domain.Id] = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var entities = await _db.Events.AsNoTracking()
|
var entities = await _db.Events.AsNoTracking()
|
||||||
@@ -50,6 +102,9 @@ internal sealed class EventRepository : IEventRepository
|
|||||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<int> CountAsync(CancellationToken ct = default) =>
|
||||||
|
_db.Events.AsNoTracking().CountAsync(ct);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var codes = await _db.Events.AsNoTracking()
|
var codes = await _db.Events.AsNoTracking()
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Marathon.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
|
internal sealed class PlacedBetRepository : IPlacedBetRepository
|
||||||
|
{
|
||||||
|
private readonly MarathonDbContext _db;
|
||||||
|
|
||||||
|
public PlacedBetRepository(MarathonDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<PlacedBet?> GetAsync(Guid key, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var idStr = key.ToString();
|
||||||
|
// AsNoTracking so callers can re-map and UpdateAsync without tripping
|
||||||
|
// EF's "another instance with the same key is already tracked" guard.
|
||||||
|
var entity = await _db.PlacedBets.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(b => b.Id == idStr, ct);
|
||||||
|
return entity is null ? null : Mapping.ToDomain(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<PlacedBet>> ListAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entities = await _db.PlacedBets.AsNoTracking().ToListAsync(ct);
|
||||||
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<PlacedBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var outcomeInt = (int)outcome;
|
||||||
|
var entities = await _db.PlacedBets.AsNoTracking()
|
||||||
|
.Where(b => b.Outcome == outcomeInt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<PlacedBet>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// PlacedAt is stored as ISO 8601 TEXT — same lexical-equals-chronological ordering
|
||||||
|
// trick used in EventRepository.ListByDateRangeAsync.
|
||||||
|
var fromStr = range.From.ToString("O");
|
||||||
|
var toStr = range.To.ToString("O");
|
||||||
|
|
||||||
|
var entities = await _db.PlacedBets.AsNoTracking()
|
||||||
|
.Where(b => b.PlacedAt.CompareTo(fromStr) >= 0
|
||||||
|
&& b.PlacedAt.CompareTo(toStr) <= 0)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<PlacedBet>> ListByEventAsync(EventId eventId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entities = await _db.PlacedBets.AsNoTracking()
|
||||||
|
.Where(b => b.EventCode == eventId.Value)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(PlacedBet entity, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var efEntity = Mapping.ToEntity(entity);
|
||||||
|
await _db.PlacedBets.AddAsync(efEntity, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateAsync(PlacedBet entity, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var efEntity = Mapping.ToEntity(entity);
|
||||||
|
_db.PlacedBets.Update(efEntity);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(Guid key, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var idStr = key.ToString();
|
||||||
|
var entity = await _db.PlacedBets.FirstOrDefaultAsync(b => b.Id == idStr, ct);
|
||||||
|
if (entity is not null)
|
||||||
|
_db.PlacedBets.Remove(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveChangesAsync(CancellationToken ct = default) =>
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
@@ -23,6 +23,31 @@ internal sealed class ResultRepository : IResultRepository
|
|||||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<EventId, EventResult>> GetManyAsync(
|
||||||
|
IReadOnlyCollection<EventId> ids,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(ids);
|
||||||
|
|
||||||
|
var result = new Dictionary<EventId, EventResult>(ids.Count);
|
||||||
|
if (ids.Count == 0)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var codes = ids.Select(e => e.Value).Distinct().ToArray();
|
||||||
|
|
||||||
|
var entities = await _db.EventResults.AsNoTracking()
|
||||||
|
.Where(r => codes.Contains(r.EventCode))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
var domain = Mapping.ToDomain(entity);
|
||||||
|
result[domain.EventId] = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task AddAsync(EventResult entity, CancellationToken ct = default)
|
public async Task AddAsync(EventResult entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var efEntity = Mapping.ToEntity(entity);
|
var efEntity = Mapping.ToEntity(entity);
|
||||||
|
|||||||
@@ -19,14 +19,22 @@ internal sealed class SnapshotRepository : ISnapshotRepository
|
|||||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sinceStr = SqliteDateText.Key(since);
|
||||||
|
return _db.Snapshots.AsNoTracking()
|
||||||
|
.Where(s => s.CapturedAt.CompareTo(sinceStr) >= 0)
|
||||||
|
.CountAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
||||||
EventId eventId,
|
EventId eventId,
|
||||||
DateTimeOffset from,
|
DateTimeOffset from,
|
||||||
DateTimeOffset to,
|
DateTimeOffset to,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var fromStr = from.ToString("O");
|
var fromStr = SqliteDateText.Key(from);
|
||||||
var toStr = to.ToString("O");
|
var toStr = SqliteDateText.Key(to);
|
||||||
|
|
||||||
var entities = await _db.Snapshots.AsNoTracking()
|
var entities = await _db.Snapshots.AsNoTracking()
|
||||||
.Include(s => s.Bets)
|
.Include(s => s.Bets)
|
||||||
@@ -51,8 +59,8 @@ internal sealed class SnapshotRepository : ISnapshotRepository
|
|||||||
return result;
|
return result;
|
||||||
|
|
||||||
var ids = eventIds.Select(e => e.Value).Distinct().ToArray();
|
var ids = eventIds.Select(e => e.Value).Distinct().ToArray();
|
||||||
var fromStr = from.ToString("O");
|
var fromStr = SqliteDateText.Key(from);
|
||||||
var toStr = to.ToString("O");
|
var toStr = SqliteDateText.Key(to);
|
||||||
|
|
||||||
var entities = await _db.Snapshots.AsNoTracking()
|
var entities = await _db.Snapshots.AsNoTracking()
|
||||||
.Include(s => s.Bets)
|
.Include(s => s.Bets)
|
||||||
@@ -83,4 +91,26 @@ internal sealed class SnapshotRepository : ISnapshotRepository
|
|||||||
|
|
||||||
public async Task SaveChangesAsync(CancellationToken ct = default) =>
|
public async Task SaveChangesAsync(CancellationToken ct = default) =>
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
public async Task<OddsSnapshot?> GetLatestPreMatchAsync(
|
||||||
|
EventId eventId,
|
||||||
|
DateTimeOffset atOrBefore,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// OddsSource enum: PreMatch == 0. Inlined as an int constant to keep the
|
||||||
|
// expression EF-translatable (the IL would otherwise carry a cast).
|
||||||
|
const int preMatchSource = (int)Marathon.Domain.Enums.OddsSource.PreMatch;
|
||||||
|
|
||||||
|
var toStr = SqliteDateText.Key(atOrBefore);
|
||||||
|
|
||||||
|
var entity = await _db.Snapshots.AsNoTracking()
|
||||||
|
.Include(s => s.Bets)
|
||||||
|
.Where(s => s.EventCode == eventId.Value
|
||||||
|
&& s.Source == preMatchSource
|
||||||
|
&& s.CapturedAt.CompareTo(toStr) <= 0)
|
||||||
|
.OrderByDescending(s => s.CapturedAt)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
return entity is null ? null : Mapping.ToDomain(entity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Marathon.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for how <see cref="DateTimeOffset"/> values are encoded
|
||||||
|
/// as the TEXT used by both the write path (<see cref="Mapping"/>) and the
|
||||||
|
/// date-range predicates / ORDER BY clauses in the repositories.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Dates are stored as round-trip ISO-8601 (<c>"O"</c> format) TEXT. SQLite TEXT
|
||||||
|
/// columns use BINARY (ordinal) collation by default, so the relational operators
|
||||||
|
/// (<c>>=</c>, <c><=</c>) and <c>ORDER BY</c> on these strings sort
|
||||||
|
/// <b>chronologically</b> — but ONLY because every persisted timestamp carries the
|
||||||
|
/// same Moscow <c>+03:00</c> offset (see the project invariant in CLAUDE.md). Two
|
||||||
|
/// instants written with different offsets would sort lexically, not
|
||||||
|
/// chronologically, and silently corrupt range filtering.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Centralising the format here means the write encoding and the query-bound
|
||||||
|
/// encoding can never drift apart, and the offset invariant is documented in one
|
||||||
|
/// authoritative place. If a future change normalises storage to UTC or a native
|
||||||
|
/// DATETIME column, this is the only call site that must change.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class SqliteDateText
|
||||||
|
{
|
||||||
|
// Parse with the invariant culture + RoundtripKind so a non-en-US thread
|
||||||
|
// culture (or a future locale change) cannot corrupt the round-trip.
|
||||||
|
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes a <see cref="DateTimeOffset"/> as the TEXT key used for storage and
|
||||||
|
/// for the bounds of range/ordering predicates.
|
||||||
|
/// </summary>
|
||||||
|
public static string Key(DateTimeOffset value) => value.ToString("O");
|
||||||
|
|
||||||
|
/// <summary>Decodes a stored TEXT key back into a <see cref="DateTimeOffset"/>.</summary>
|
||||||
|
public static DateTimeOffset Parse(string text) =>
|
||||||
|
DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, RoundtripStyles);
|
||||||
|
}
|
||||||
@@ -6,9 +6,8 @@ using Marathon.Domain.Entities;
|
|||||||
using Marathon.Domain.Enums;
|
using Marathon.Domain.Enums;
|
||||||
using Marathon.Domain.ValueObjects;
|
using Marathon.Domain.ValueObjects;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
|
||||||
using AngleSharpConfig = AngleSharp.Configuration;
|
using AngleSharpConfig = AngleSharp.Configuration;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
namespace Marathon.Infrastructure.Scraping.Parsers;
|
namespace Marathon.Infrastructure.Scraping.Parsers;
|
||||||
|
|
||||||
@@ -517,8 +516,11 @@ public sealed partial class EventOddsParser : IEventOddsParser
|
|||||||
value.HasValue ? new OddsValue(value.Value) : null,
|
value.HasValue ? new OddsValue(value.Value) : null,
|
||||||
new OddsRate(rate)));
|
new OddsRate(rate)));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (ArgumentException ex)
|
||||||
{
|
{
|
||||||
|
// OddsValue / OddsRate / Bet guard clauses throw ArgumentException and its
|
||||||
|
// derivatives (ArgumentNullException, ArgumentOutOfRangeException). Catch
|
||||||
|
// only those — anything else is a real bug that must not be swallowed here.
|
||||||
_logger.LogDebug(ex,
|
_logger.LogDebug(ex,
|
||||||
"Skipping bet ({Type}, {Side}, value={Value}, rate={Rate}) — invariant violation.",
|
"Skipping bet ({Type}, {Side}, value={Value}, rate={Rate}) — invariant violation.",
|
||||||
type, side, value, rate);
|
type, side, value, rate);
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ internal sealed class LiveOddsPoller : BackgroundService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cycleStart = DateTime.UtcNow;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var scope = _services.CreateAsyncScope();
|
await using var scope = _services.CreateAsyncScope();
|
||||||
@@ -69,9 +71,17 @@ internal sealed class LiveOddsPoller : BackgroundService
|
|||||||
var interval = TimeSpan.FromSeconds(
|
var interval = TimeSpan.FromSeconds(
|
||||||
Math.Max(1, _opts.CurrentValue.LivePollIntervalSeconds));
|
Math.Max(1, _opts.CurrentValue.LivePollIntervalSeconds));
|
||||||
|
|
||||||
|
// Budget the sleep against the time the cycle already consumed so the
|
||||||
|
// effective cadence tracks the configured interval instead of
|
||||||
|
// (interval + scrapeDuration). If a cycle overran the interval, loop
|
||||||
|
// immediately rather than sleeping a full extra interval.
|
||||||
|
var remaining = interval - (DateTime.UtcNow - cycleStart);
|
||||||
|
if (remaining <= TimeSpan.Zero)
|
||||||
|
continue;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.Delay(interval, stoppingToken);
|
await Task.Delay(remaining, stoppingToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -212,6 +212,7 @@
|
|||||||
private string KindLabel(AnomalyKind kind) => kind switch
|
private string KindLabel(AnomalyKind kind) => kind switch
|
||||||
{
|
{
|
||||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||||
|
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||||
_ => kind.ToString(),
|
_ => kind.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,24 @@
|
|||||||
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
|
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
|
||||||
<span>@L["Nav.Results"]</span>
|
<span>@L["Nav.Results"]</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink class="m-nav__link" href="anomalies/insights">
|
||||||
|
<MudIcon Icon="@Icons.Material.Outlined.Insights" Size="Size.Small" />
|
||||||
|
<span>@L["Nav.Insights"]</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink class="m-nav__link" href="my-bets">
|
||||||
|
<MudIcon Icon="@Icons.Material.Outlined.Receipt" Size="Size.Small" />
|
||||||
|
<span>@L["Nav.MyBets"]</span>
|
||||||
|
</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="export">
|
||||||
|
<MudIcon Icon="@Icons.Material.Outlined.FileDownload" Size="Size.Small" />
|
||||||
|
<span>@L["Nav.Export"]</span>
|
||||||
|
</NavLink>
|
||||||
<NavLink class="m-nav__link" href="settings">
|
<NavLink class="m-nav__link" href="settings">
|
||||||
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
|
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
|
||||||
<span>@L["Nav.Settings"]</span>
|
<span>@L["Nav.Settings"]</span>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@inject ThemeState ThemeState
|
@inject ThemeState ThemeState
|
||||||
@inject LocaleState LocaleState
|
@inject LocaleState LocaleState
|
||||||
@inject IStringLocalizer<SharedResource> L
|
@inject IStringLocalizer<SharedResource> L
|
||||||
|
@inject IOptionsMonitor<WorkerOptions> Workers
|
||||||
|
|
||||||
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
|
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
|
||||||
<MudPopoverProvider />
|
<MudPopoverProvider />
|
||||||
@@ -24,6 +25,12 @@
|
|||||||
<div class="m-appbar__spacer"></div>
|
<div class="m-appbar__spacer"></div>
|
||||||
|
|
||||||
<div class="m-appbar__tools m-rise m-rise-2">
|
<div class="m-appbar__tools m-rise m-rise-2">
|
||||||
|
<span class="m-capture-pill" data-test="capture-pill"
|
||||||
|
aria-label="@L["Scraping.Aria"]" title="@L["Scraping.Aria"]"
|
||||||
|
style="display:inline-flex; align-items:center; gap:7px; font-family:var(--m-font-mono); font-size:0.6875rem; text-transform:uppercase; letter-spacing:0.12em; color:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");">
|
||||||
|
<span style="width:8px; height:8px; border-radius:50%; background:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");"></span>
|
||||||
|
@(Capturing ? L["Scraping.On"] : L["Scraping.Off"])
|
||||||
|
</span>
|
||||||
<LocaleSwitcher />
|
<LocaleSwitcher />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
@@ -123,11 +130,20 @@
|
|||||||
@code {
|
@code {
|
||||||
private bool _drawerOpen = true;
|
private bool _drawerOpen = true;
|
||||||
private MudBlazor.MudTheme _theme = Theme.MarathonTheme.Build();
|
private MudBlazor.MudTheme _theme = Theme.MarathonTheme.Build();
|
||||||
|
private IDisposable? _workerOptionsListener;
|
||||||
|
|
||||||
|
// "Capturing" when any of the primary pollers is enabled in config.
|
||||||
|
private bool Capturing =>
|
||||||
|
Workers.CurrentValue.LivePollerEnabled
|
||||||
|
|| Workers.CurrentValue.UpcomingPollerEnabled
|
||||||
|
|| Workers.CurrentValue.AnomalyDetectionEnabled;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
ThemeState.OnChange += StateHasChanged;
|
ThemeState.OnChange += StateHasChanged;
|
||||||
LocaleState.OnChange += StateHasChanged;
|
LocaleState.OnChange += StateHasChanged;
|
||||||
|
// Reflect Settings toggles live without requiring a navigation.
|
||||||
|
_workerOptionsListener = Workers.OnChange(_ => InvokeAsync(StateHasChanged));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||||
@@ -136,5 +152,6 @@
|
|||||||
{
|
{
|
||||||
ThemeState.OnChange -= StateHasChanged;
|
ThemeState.OnChange -= StateHasChanged;
|
||||||
LocaleState.OnChange -= StateHasChanged;
|
LocaleState.OnChange -= StateHasChanged;
|
||||||
|
_workerOptionsListener?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,9 @@
|
|||||||
<button type="button" class="m-chip" @onclick="MarkAllRead" data-test="mark-read">
|
<button type="button" class="m-chip" @onclick="MarkAllRead" data-test="mark-read">
|
||||||
@L["Anomaly.Filter.MarkRead"]
|
@L["Anomaly.Filter.MarkRead"]
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="m-chip" @onclick="OpenInsights" data-test="open-insights">
|
||||||
|
@L["Nav.Insights"]
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -269,6 +272,11 @@
|
|||||||
State.MarkAllSeen(DateTimeOffset.UtcNow);
|
State.MarkAllSeen(DateTimeOffset.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OpenInsights()
|
||||||
|
{
|
||||||
|
Nav.NavigateTo("/anomalies/insights");
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleClick(AnomalyListItem item)
|
private void HandleClick(AnomalyListItem item)
|
||||||
{
|
{
|
||||||
Nav.NavigateTo($"/anomalies/{item.Id}");
|
Nav.NavigateTo($"/anomalies/{item.Id}");
|
||||||
|
|||||||
@@ -0,0 +1,897 @@
|
|||||||
|
@*
|
||||||
|
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.From"]</label>
|
||||||
|
<MudDatePicker @bind-Date="_form.From"
|
||||||
|
DateFormat="yyyy-MM-dd"
|
||||||
|
Clearable="true"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
data-test="backtest-from" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-backtest__form-field">
|
||||||
|
<label class="m-backtest__form-label">@L["Backtest.Field.To"]</label>
|
||||||
|
<MudDatePicker @bind-Date="_form.To"
|
||||||
|
DateFormat="yyyy-MM-dd"
|
||||||
|
Clearable="true"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
data-test="backtest-to" />
|
||||||
|
<span class="m-backtest__form-hint">@L["Backtest.Field.DateRange.Hint"]</span>
|
||||||
|
</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>
|
||||||
|
@if (_running)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="m-chip"
|
||||||
|
@onclick="CancelRun"
|
||||||
|
data-test="backtest-cancel">
|
||||||
|
<span>@L["Backtest.Action.Cancel"]</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 CancelRun() => _runCts?.Cancel();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,13 @@
|
|||||||
data-test="link-back-to-event">
|
data-test="link-back-to-event">
|
||||||
@L["Anomaly.Detail.LinkBackToEvent"]
|
@L["Anomaly.Detail.LinkBackToEvent"]
|
||||||
</MudButton>
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
StartIcon="@Icons.Material.Outlined.Receipt"
|
||||||
|
OnClick="@(() => Nav.NavigateTo($"/my-bets?eventId={Uri.EscapeDataString(_detail.Item.EventId.Value)}"))"
|
||||||
|
Class="m-detail-header__export"
|
||||||
|
data-test="log-bet">
|
||||||
|
@L["Action.LogBet"]
|
||||||
|
</MudButton>
|
||||||
</aside>
|
</aside>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -99,6 +106,7 @@
|
|||||||
private string KindLabel(AnomalyKind kind) => kind switch
|
private string KindLabel(AnomalyKind kind) => kind switch
|
||||||
{
|
{
|
||||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||||
|
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||||
_ => kind.ToString(),
|
_ => kind.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,887 @@
|
|||||||
|
@*
|
||||||
|
Insights — calibration page for the SuspensionFlip detector.
|
||||||
|
|
||||||
|
Loads a precomputed AnomalyInsightsVm and answers the single question that
|
||||||
|
matters: when the bookmaker flipped, did the post-flip favourite actually
|
||||||
|
win? Big numbers up top, three breakdowns in the middle, drill-down tables
|
||||||
|
at the bottom. Same editorial-quant tone as AnomalyFeed / Home.
|
||||||
|
*@
|
||||||
|
|
||||||
|
@page "/anomalies/insights"
|
||||||
|
@using Marathon.Application.Reporting
|
||||||
|
@using Marathon.Domain.AnomalyDetection
|
||||||
|
@using Marathon.Domain.Enums
|
||||||
|
@implements IDisposable
|
||||||
|
@inject IStringLocalizer<SharedResource> L
|
||||||
|
@inject IAnomalyInsightsService InsightsService
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@inject ILogger<Insights> Logger
|
||||||
|
|
||||||
|
<PageTitle>@L["App.Title"] · @L["Nav.Insights"]</PageTitle>
|
||||||
|
|
||||||
|
<section class="m-shell">
|
||||||
|
<header class="m-rise m-rise-1 m-insights__header" data-test="insights-header">
|
||||||
|
<div class="m-insights__header-text">
|
||||||
|
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
|
||||||
|
@L["Insights.Kicker"]
|
||||||
|
</span>
|
||||||
|
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Insights.Title"]</h1>
|
||||||
|
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Insights.Lede"]</p>
|
||||||
|
</div>
|
||||||
|
<div class="m-insights__header-actions">
|
||||||
|
<button type="button"
|
||||||
|
class="m-chip m-insights__refresh"
|
||||||
|
@onclick="LoadAsync"
|
||||||
|
disabled="@_loading"
|
||||||
|
data-test="insights-refresh">
|
||||||
|
<span class="m-insights__refresh-glyph @(_loading ? "is-spinning" : null)" aria-hidden="true">↻</span>
|
||||||
|
<span>@L["Insights.Action.Refresh"]</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (_loading && _vm is null)
|
||||||
|
{
|
||||||
|
<div class="m-list-empty m-rise m-rise-2" data-test="insights-loading">
|
||||||
|
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||||
|
<span class="m-mono">@L["Common.Loading"]</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_errored && _vm is null)
|
||||||
|
{
|
||||||
|
<div class="m-list-empty m-rise m-rise-2" data-test="insights-error">
|
||||||
|
<span class="m-kicker" style="border-color: var(--m-c-anomaly); color: var(--m-c-anomaly);">
|
||||||
|
@L["Common.Empty"]
|
||||||
|
</span>
|
||||||
|
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 50ch;">
|
||||||
|
@L["Insights.Empty.None"]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_vm is { } vm)
|
||||||
|
{
|
||||||
|
@* ---------- KPI strip ---------- *@
|
||||||
|
<div class="m-insights__kpis m-rise m-rise-2" data-test="insights-kpis">
|
||||||
|
<article class="m-insights__kpi m-insights__kpi--@HitRateTone(vm.HitRate)" data-test="insights-kpi-hitrate">
|
||||||
|
<span class="m-insights__kpi-label">@L["Insights.Stat.HitRate"]</span>
|
||||||
|
<span class="m-insights__kpi-value">@FormatPercent(vm.HitRate)</span>
|
||||||
|
<span class="m-insights__kpi-hint">@L["Insights.Stat.HitRate.Hint"]</span>
|
||||||
|
</article>
|
||||||
|
<article class="m-insights__kpi" data-test="insights-kpi-resolved">
|
||||||
|
<span class="m-insights__kpi-label">@L["Insights.Stat.Resolved"]</span>
|
||||||
|
<span class="m-insights__kpi-value">
|
||||||
|
@vm.ResolvedCount<span class="m-insights__kpi-denom"> / @vm.TotalAnomalies</span>
|
||||||
|
</span>
|
||||||
|
<span class="m-insights__kpi-hint">@L["Insights.Stat.Resolved.Hint"]</span>
|
||||||
|
</article>
|
||||||
|
<article class="m-insights__kpi" data-test="insights-kpi-unresolved">
|
||||||
|
<span class="m-insights__kpi-label">@L["Insights.Stat.Unresolved"]</span>
|
||||||
|
<span class="m-insights__kpi-value">@vm.UnresolvedCount</span>
|
||||||
|
<span class="m-insights__kpi-hint">@L["Insights.Stat.Unresolved.Hint"]</span>
|
||||||
|
</article>
|
||||||
|
<article class="m-insights__kpi m-insights__kpi--split" data-test="insights-kpi-hitsmisses">
|
||||||
|
<div class="m-insights__split">
|
||||||
|
<div class="m-insights__split-cell">
|
||||||
|
<span class="m-insights__kpi-label">@L["Insights.Stat.Hits"]</span>
|
||||||
|
<span class="m-insights__kpi-value m-insights__kpi-value--positive">@vm.HitCount</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-insights__split-divider" aria-hidden="true"></div>
|
||||||
|
<div class="m-insights__split-cell">
|
||||||
|
<span class="m-insights__kpi-label">@L["Insights.Stat.Misses"]</span>
|
||||||
|
<span class="m-insights__kpi-value m-insights__kpi-value--negative">@vm.MissCount</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="m-rule--double" />
|
||||||
|
|
||||||
|
@* ---------- By severity ---------- *@
|
||||||
|
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-severity">
|
||||||
|
<header class="m-insights__section-head">
|
||||||
|
<span class="m-kicker">@L["Insights.Section.BySeverity"]</span>
|
||||||
|
</header>
|
||||||
|
@RenderBucketTable(vm.BySeverity, BucketRenderKind.Severity)
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="m-rule--double" />
|
||||||
|
|
||||||
|
@* ---------- By sport ---------- *@
|
||||||
|
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-sport">
|
||||||
|
<header class="m-insights__section-head">
|
||||||
|
<span class="m-kicker">@L["Insights.Section.BySport"]</span>
|
||||||
|
</header>
|
||||||
|
@RenderBucketTable(vm.BySport, BucketRenderKind.Sport)
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="m-rule--double" />
|
||||||
|
|
||||||
|
@* ---------- By score bin (7 fixed rows) ---------- *@
|
||||||
|
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-score">
|
||||||
|
<header class="m-insights__section-head">
|
||||||
|
<span class="m-kicker">@L["Insights.Section.ByScore"]</span>
|
||||||
|
</header>
|
||||||
|
@RenderBucketTable(vm.ByScoreBin, BucketRenderKind.Score)
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="m-rule--double" />
|
||||||
|
|
||||||
|
@* ---------- Resolved table ---------- *@
|
||||||
|
<section class="m-insights__section m-rise m-rise-4" data-test="insights-resolved">
|
||||||
|
<header class="m-insights__section-head">
|
||||||
|
<span class="m-kicker">@L["Insights.Section.Resolved"]</span>
|
||||||
|
<span class="m-insights__section-count m-mono">@vm.Resolved.Count</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (vm.TotalAnomalies == 0)
|
||||||
|
{
|
||||||
|
<div class="m-list-empty" data-test="insights-empty-none">
|
||||||
|
<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["Insights.Empty.None"]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (vm.Resolved.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="m-list-empty" data-test="insights-empty-resolved">
|
||||||
|
<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["Insights.Empty.NoneResolved"]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="m-insights__table-wrap">
|
||||||
|
<table class="m-insights__table" data-test="insights-resolved-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">@L["Insights.Column.DetectedAt"]</th>
|
||||||
|
<th scope="col">@L["Insights.Column.Match"]</th>
|
||||||
|
<th scope="col">@L["Insights.Column.Sport"]</th>
|
||||||
|
<th scope="col" style="text-align: right;">@L["Insights.Column.Score"]</th>
|
||||||
|
<th scope="col">@L["Insights.Column.PreFavourite"]</th>
|
||||||
|
<th scope="col">@L["Insights.Column.PostFavourite"]</th>
|
||||||
|
<th scope="col">@L["Insights.Column.Winner"]</th>
|
||||||
|
<th scope="col">@L["Insights.Column.Outcome"]</th>
|
||||||
|
<th scope="col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var row in vm.Resolved)
|
||||||
|
{
|
||||||
|
var local = row;
|
||||||
|
<tr class="m-insights__row m-insights__row--@OutcomeCss(local.Outcome)"
|
||||||
|
data-test="insights-resolved-row"
|
||||||
|
data-anomaly-id="@local.AnomalyId">
|
||||||
|
<td class="m-mono">@local.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)</td>
|
||||||
|
<td style="font-weight: 500;">@local.EventTitle</td>
|
||||||
|
<td>
|
||||||
|
@if (local.Sport is { } sport)
|
||||||
|
{
|
||||||
|
<span class="m-insights__sport">
|
||||||
|
<SportIcon Code="@sport.Value" Label="@SportLabels.Resolve(L, sport.Value)" ClassName="m-insights__sport-icon" />
|
||||||
|
<span>@SportLabels.Resolve(L, sport.Value)</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span style="color: var(--m-c-ink-soft);">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="m-mono" style="text-align: right; font-weight: 600;">
|
||||||
|
@local.Score.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
</td>
|
||||||
|
<td>@SideLabel(local.PreFlipFavourite)</td>
|
||||||
|
<td style="font-weight: 600;">@SideLabel(local.PostFlipFavourite)</td>
|
||||||
|
<td>@SideLabel(local.ActualWinner)</td>
|
||||||
|
<td>
|
||||||
|
<span class="m-insights__verdict m-insights__verdict--@OutcomeCss(local.Outcome)">
|
||||||
|
@OutcomeLabel(local.Outcome)
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="@($"/anomalies/{local.AnomalyId}")"
|
||||||
|
class="m-insights__open"
|
||||||
|
data-test="insights-open-link"
|
||||||
|
@onclick="@(e => OpenAnomaly(e, local.AnomalyId))"
|
||||||
|
@onclick:preventDefault>
|
||||||
|
@L["Insights.Action.OpenAnomaly"]
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* ---------- Unresolved table (only when non-empty) ---------- *@
|
||||||
|
@if (vm.Unresolved.Count > 0)
|
||||||
|
{
|
||||||
|
<hr class="m-rule--double" />
|
||||||
|
|
||||||
|
<section class="m-insights__section m-rise m-rise-5" data-test="insights-unresolved">
|
||||||
|
<header class="m-insights__section-head">
|
||||||
|
<span class="m-kicker" style="color: var(--m-c-ink-soft); border-color: var(--m-c-ink-soft);">
|
||||||
|
@L["Insights.Section.Unresolved"]
|
||||||
|
</span>
|
||||||
|
<span class="m-insights__section-count m-mono">@vm.Unresolved.Count</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="m-insights__table-wrap m-insights__table-wrap--dim">
|
||||||
|
<table class="m-insights__table" data-test="insights-unresolved-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">@L["Insights.Column.DetectedAt"]</th>
|
||||||
|
<th scope="col">@L["Insights.Column.Match"]</th>
|
||||||
|
<th scope="col">@L["Insights.Column.Sport"]</th>
|
||||||
|
<th scope="col" style="text-align: right;">@L["Insights.Column.Score"]</th>
|
||||||
|
<th scope="col">@L["Insights.Column.PreFavourite"]</th>
|
||||||
|
<th scope="col">@L["Insights.Column.PostFavourite"]</th>
|
||||||
|
<th scope="col">@L["Insights.Column.Outcome"]</th>
|
||||||
|
<th scope="col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var row in vm.Unresolved)
|
||||||
|
{
|
||||||
|
var local = row;
|
||||||
|
<tr class="m-insights__row m-insights__row--pending"
|
||||||
|
data-test="insights-unresolved-row"
|
||||||
|
data-anomaly-id="@local.AnomalyId">
|
||||||
|
<td class="m-mono">@local.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)</td>
|
||||||
|
<td>@local.EventTitle</td>
|
||||||
|
<td>
|
||||||
|
@if (local.Sport is { } sport)
|
||||||
|
{
|
||||||
|
<span class="m-insights__sport">
|
||||||
|
<SportIcon Code="@sport.Value" Label="@SportLabels.Resolve(L, sport.Value)" ClassName="m-insights__sport-icon" />
|
||||||
|
<span>@SportLabels.Resolve(L, sport.Value)</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span style="color: var(--m-c-ink-soft);">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="m-mono" style="text-align: right; font-weight: 600;">
|
||||||
|
@local.Score.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
</td>
|
||||||
|
<td>@SideLabel(local.PreFlipFavourite)</td>
|
||||||
|
<td style="font-weight: 600;">@SideLabel(local.PostFlipFavourite)</td>
|
||||||
|
<td>
|
||||||
|
<span class="m-insights__verdict m-insights__verdict--pending">
|
||||||
|
@L["Insights.Outcome.Unresolved"]
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="@($"/anomalies/{local.AnomalyId}")"
|
||||||
|
class="m-insights__open"
|
||||||
|
@onclick="@(e => OpenAnomaly(e, local.AnomalyId))"
|
||||||
|
@onclick:preventDefault>
|
||||||
|
@L["Insights.Action.OpenAnomaly"]
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.m-insights__header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--m-space-5);
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
@@media (max-width: 720px) {
|
||||||
|
.m-insights__header { grid-template-columns: 1fr; }
|
||||||
|
.m-insights__header-actions { justify-self: start; }
|
||||||
|
}
|
||||||
|
.m-insights__header-text {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--m-space-3);
|
||||||
|
max-width: 880px;
|
||||||
|
}
|
||||||
|
.m-insights__header-actions { display: flex; gap: var(--m-space-3); }
|
||||||
|
|
||||||
|
.m-insights__refresh {
|
||||||
|
gap: var(--m-space-2);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-family: var(--m-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
.m-insights__refresh:disabled { opacity: 0.6; cursor: progress; }
|
||||||
|
.m-insights__refresh-glyph {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform 200ms ease;
|
||||||
|
}
|
||||||
|
.m-insights__refresh:hover .m-insights__refresh-glyph { transform: rotate(45deg); }
|
||||||
|
.m-insights__refresh-glyph.is-spinning { animation: m-insights-spin 1.1s linear infinite; }
|
||||||
|
@@keyframes m-insights-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@@media (prefers-reduced-motion: reduce) {
|
||||||
|
.m-insights__refresh-glyph.is-spinning { animation: none; }
|
||||||
|
.m-insights__refresh:hover .m-insights__refresh-glyph { transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- KPI strip ---- */
|
||||||
|
.m-insights__kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: var(--m-space-4);
|
||||||
|
}
|
||||||
|
.m-insights__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-insights__kpi--positive { border-left-color: var(--m-c-positive); }
|
||||||
|
.m-insights__kpi--neutral { border-left-color: var(--m-c-accent); }
|
||||||
|
.m-insights__kpi--negative { border-left-color: var(--m-c-anomaly); }
|
||||||
|
.m-insights__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-insights__kpi-value {
|
||||||
|
font-family: var(--m-font-mono);
|
||||||
|
font-feature-settings: var(--m-num-feature);
|
||||||
|
font-size: clamp(2rem, 3.5vw, 2.625rem);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--m-c-ink);
|
||||||
|
}
|
||||||
|
.m-insights__kpi--positive .m-insights__kpi-value { color: var(--m-c-positive); }
|
||||||
|
.m-insights__kpi--negative .m-insights__kpi-value { color: var(--m-c-anomaly); }
|
||||||
|
.m-insights__kpi-value--positive { color: var(--m-c-positive); }
|
||||||
|
.m-insights__kpi-value--negative { color: var(--m-c-anomaly); }
|
||||||
|
.m-insights__kpi-denom {
|
||||||
|
font-size: 0.55em;
|
||||||
|
color: var(--m-c-ink-soft);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.m-insights__kpi-hint {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--m-c-ink-soft);
|
||||||
|
}
|
||||||
|
.m-insights__kpi--split { padding: var(--m-space-4) var(--m-space-5); }
|
||||||
|
.m-insights__split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--m-space-3);
|
||||||
|
}
|
||||||
|
.m-insights__split-cell { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.m-insights__split-cell:last-child { text-align: right; }
|
||||||
|
.m-insights__split-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 56px;
|
||||||
|
background: var(--m-c-rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Section headers ---- */
|
||||||
|
.m-insights__section { display: grid; gap: var(--m-space-4); }
|
||||||
|
.m-insights__section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--m-space-3);
|
||||||
|
}
|
||||||
|
.m-insights__section-count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--m-c-ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Bucket / breakdown grid ---- */
|
||||||
|
.m-insights__buckets {
|
||||||
|
background: var(--m-c-paper);
|
||||||
|
border: 1px solid var(--m-c-rule);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.m-insights__bucket-head,
|
||||||
|
.m-insights__bucket-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1.4fr) minmax(140px, 1fr) minmax(220px, 2fr);
|
||||||
|
gap: var(--m-space-4);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--m-space-3) var(--m-space-4);
|
||||||
|
}
|
||||||
|
.m-insights__bucket-head {
|
||||||
|
font-family: var(--m-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--m-c-ink-soft);
|
||||||
|
background: var(--m-c-paper-2);
|
||||||
|
border-bottom: 1px solid var(--m-c-rule);
|
||||||
|
}
|
||||||
|
.m-insights__bucket-row {
|
||||||
|
border-bottom: 1px solid var(--m-c-rule);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
.m-insights__bucket-row:last-child { border-bottom: 0; }
|
||||||
|
.m-insights__bucket-row--dim { color: var(--m-c-ink-soft); }
|
||||||
|
.m-insights__bucket-row--dim .m-insights__bucket-label { color: var(--m-c-ink-soft); }
|
||||||
|
|
||||||
|
.m-insights__bucket-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--m-space-3);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.m-insights__bucket-label--mono {
|
||||||
|
font-family: var(--m-font-mono);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.m-insights__bucket-counts {
|
||||||
|
font-family: var(--m-font-mono);
|
||||||
|
font-feature-settings: var(--m-num-feature);
|
||||||
|
color: var(--m-c-ink-soft);
|
||||||
|
}
|
||||||
|
.m-insights__bucket-counts strong {
|
||||||
|
color: var(--m-c-ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-insights__bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 56px;
|
||||||
|
gap: var(--m-space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.m-insights__bar-track {
|
||||||
|
position: relative;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--m-c-paper-2);
|
||||||
|
border: 1px solid var(--m-c-rule);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.m-insights__bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
background: var(--m-c-accent);
|
||||||
|
transition: width 320ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
}
|
||||||
|
.m-insights__bar-fill--positive { background: var(--m-c-positive); }
|
||||||
|
.m-insights__bar-fill--negative { background: var(--m-c-anomaly); }
|
||||||
|
.m-insights__bar-fill--neutral { background: var(--m-c-accent); }
|
||||||
|
@@media (prefers-reduced-motion: reduce) {
|
||||||
|
.m-insights__bar-fill { transition: none; }
|
||||||
|
}
|
||||||
|
.m-insights__bar-pct {
|
||||||
|
font-family: var(--m-font-mono);
|
||||||
|
font-feature-settings: var(--m-num-feature);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--m-c-ink);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.m-insights__bar-na {
|
||||||
|
font-family: var(--m-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--m-c-ink-soft);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Resolved / unresolved tables ---- */
|
||||||
|
.m-insights__table-wrap {
|
||||||
|
background: var(--m-c-paper);
|
||||||
|
border: 1px solid var(--m-c-rule);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.m-insights__table-wrap--dim { background: var(--m-c-paper-2); opacity: 0.92; }
|
||||||
|
.m-insights__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: var(--m-font-body);
|
||||||
|
}
|
||||||
|
.m-insights__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-insights__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-insights__table tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
.m-insights__row { transition: background 120ms ease; }
|
||||||
|
.m-insights__row:hover { background: var(--m-c-paper-2); }
|
||||||
|
.m-insights__row--hit { box-shadow: inset 2px 0 0 0 var(--m-c-positive); }
|
||||||
|
.m-insights__row--miss { box-shadow: inset 2px 0 0 0 var(--m-c-anomaly); }
|
||||||
|
.m-insights__row--pending { box-shadow: inset 2px 0 0 0 var(--m-c-rule); }
|
||||||
|
|
||||||
|
.m-insights__sport {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--m-space-2);
|
||||||
|
}
|
||||||
|
.m-insights__sport-icon { --m-sport-size: 18px; }
|
||||||
|
|
||||||
|
.m-insights__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-insights__verdict--hit {
|
||||||
|
color: var(--m-c-positive);
|
||||||
|
background: rgba(21, 128, 61, 0.10);
|
||||||
|
}
|
||||||
|
.m-insights__verdict--miss {
|
||||||
|
color: var(--m-c-anomaly);
|
||||||
|
background: rgba(220, 38, 38, 0.10);
|
||||||
|
}
|
||||||
|
.m-insights__verdict--pending {
|
||||||
|
color: var(--m-c-ink-soft);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .m-insights__verdict--hit {
|
||||||
|
color: var(--m-c-positive);
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .m-insights__verdict--miss {
|
||||||
|
color: var(--m-c-anomaly);
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-insights__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-insights__open:hover {
|
||||||
|
color: var(--m-c-accent);
|
||||||
|
border-bottom-color: var(--m-c-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Empty-state block (shared with feed) ---- */
|
||||||
|
.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 {
|
||||||
|
// Render kind for the breakdown grid — disambiguates how `Key` is shown.
|
||||||
|
private enum BucketRenderKind
|
||||||
|
{
|
||||||
|
Severity,
|
||||||
|
Sport,
|
||||||
|
Score,
|
||||||
|
}
|
||||||
|
|
||||||
|
private AnomalyInsightsVm? _vm;
|
||||||
|
private bool _loading = true;
|
||||||
|
private bool _errored;
|
||||||
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_loadCts = new CancellationTokenSource();
|
||||||
|
var ct = _loadCts.Token;
|
||||||
|
|
||||||
|
_loading = true;
|
||||||
|
_errored = false;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var report = await InsightsService.GetReportAsync(ct);
|
||||||
|
if (ct.IsCancellationRequested) return;
|
||||||
|
_vm = report;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* superseded */ }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Insights: failed to build the anomaly outcome report.");
|
||||||
|
_errored = true;
|
||||||
|
_vm = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenAnomaly(MouseEventArgs e, Guid anomalyId)
|
||||||
|
{
|
||||||
|
Nav.NavigateTo("/anomalies/" + anomalyId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Bucket rendering ---------------------------------------------------
|
||||||
|
|
||||||
|
private RenderFragment RenderBucketTable(
|
||||||
|
IReadOnlyList<OutcomeBucket> buckets,
|
||||||
|
BucketRenderKind kind) => builder =>
|
||||||
|
{
|
||||||
|
builder.OpenElement(0, "div");
|
||||||
|
builder.AddAttribute(1, "class", "m-insights__buckets");
|
||||||
|
builder.AddAttribute(2, "data-test", "insights-bucket-grid");
|
||||||
|
|
||||||
|
// Head row
|
||||||
|
builder.OpenElement(10, "div");
|
||||||
|
builder.AddAttribute(11, "class", "m-insights__bucket-head");
|
||||||
|
|
||||||
|
builder.OpenElement(12, "span");
|
||||||
|
builder.AddContent(13, L["Insights.Column.Bucket"]);
|
||||||
|
builder.CloseElement();
|
||||||
|
|
||||||
|
builder.OpenElement(14, "span");
|
||||||
|
builder.AddContent(15, L["Insights.Column.HitsOfTotal"]);
|
||||||
|
builder.CloseElement();
|
||||||
|
|
||||||
|
builder.OpenElement(16, "span");
|
||||||
|
builder.AddContent(17, L["Insights.Column.HitRate"]);
|
||||||
|
builder.CloseElement();
|
||||||
|
|
||||||
|
builder.CloseElement();
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
var seq = 100;
|
||||||
|
foreach (var bucket in buckets)
|
||||||
|
{
|
||||||
|
var local = bucket;
|
||||||
|
var isEmpty = local.Total == 0;
|
||||||
|
var rowClass = isEmpty
|
||||||
|
? "m-insights__bucket-row m-insights__bucket-row--dim"
|
||||||
|
: "m-insights__bucket-row";
|
||||||
|
|
||||||
|
builder.OpenElement(seq++, "div");
|
||||||
|
builder.AddAttribute(seq++, "class", rowClass);
|
||||||
|
builder.AddAttribute(seq++, "data-test", "insights-bucket-row");
|
||||||
|
builder.AddAttribute(seq++, "data-bucket-key", local.Key);
|
||||||
|
|
||||||
|
// Label cell
|
||||||
|
builder.OpenElement(seq++, "span");
|
||||||
|
var labelClass = kind == BucketRenderKind.Score
|
||||||
|
? "m-insights__bucket-label m-insights__bucket-label--mono"
|
||||||
|
: "m-insights__bucket-label";
|
||||||
|
builder.AddAttribute(seq++, "class", labelClass);
|
||||||
|
builder.AddContent(seq++, RenderBucketLabel(local.Key, kind));
|
||||||
|
builder.CloseElement();
|
||||||
|
|
||||||
|
// Counts cell
|
||||||
|
builder.OpenElement(seq++, "span");
|
||||||
|
builder.AddAttribute(seq++, "class", "m-insights__bucket-counts");
|
||||||
|
builder.OpenElement(seq++, "strong");
|
||||||
|
builder.AddContent(seq++, local.Hits.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
builder.CloseElement();
|
||||||
|
builder.AddContent(seq++, " / " + local.Total.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
builder.CloseElement();
|
||||||
|
|
||||||
|
// Hit-rate cell — bar + percent, or N/A pill when empty
|
||||||
|
builder.OpenElement(seq++, "div");
|
||||||
|
builder.AddAttribute(seq++, "class", "m-insights__bar");
|
||||||
|
|
||||||
|
if (isEmpty || local.HitRate is null)
|
||||||
|
{
|
||||||
|
builder.OpenElement(seq++, "span");
|
||||||
|
builder.AddAttribute(seq++, "class", "m-insights__bar-na");
|
||||||
|
builder.AddContent(seq++, L["Insights.Bucket.NotApplicable"]);
|
||||||
|
builder.CloseElement();
|
||||||
|
|
||||||
|
builder.OpenElement(seq++, "span");
|
||||||
|
builder.AddAttribute(seq++, "class", "m-insights__bar-pct");
|
||||||
|
builder.AddAttribute(seq++, "style", "color: var(--m-c-ink-soft);");
|
||||||
|
builder.AddContent(seq++, "—");
|
||||||
|
builder.CloseElement();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var rate = local.HitRate.Value;
|
||||||
|
var pct = (double)(rate * 100m);
|
||||||
|
var pctClamped = Math.Max(0, Math.Min(100, pct));
|
||||||
|
var tone = rate >= 0.60m ? "positive" : (rate < 0.40m ? "negative" : "neutral");
|
||||||
|
|
||||||
|
builder.OpenElement(seq++, "div");
|
||||||
|
builder.AddAttribute(seq++, "class", "m-insights__bar-track");
|
||||||
|
builder.AddAttribute(seq++, "role", "progressbar");
|
||||||
|
builder.AddAttribute(seq++, "aria-valuemin", "0");
|
||||||
|
builder.AddAttribute(seq++, "aria-valuemax", "100");
|
||||||
|
builder.AddAttribute(seq++, "aria-valuenow", pctClamped.ToString("0", System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
builder.OpenElement(seq++, "div");
|
||||||
|
builder.AddAttribute(seq++, "class", "m-insights__bar-fill m-insights__bar-fill--" + tone);
|
||||||
|
builder.AddAttribute(seq++, "style", "width: " + pctClamped.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) + "%;");
|
||||||
|
builder.CloseElement();
|
||||||
|
|
||||||
|
builder.CloseElement();
|
||||||
|
|
||||||
|
builder.OpenElement(seq++, "span");
|
||||||
|
builder.AddAttribute(seq++, "class", "m-insights__bar-pct");
|
||||||
|
builder.AddContent(seq++, ((int)Math.Round(pct, MidpointRounding.AwayFromZero)).ToString(System.Globalization.CultureInfo.InvariantCulture) + "%");
|
||||||
|
builder.CloseElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.CloseElement(); // .m-insights__bar
|
||||||
|
builder.CloseElement(); // .m-insights__bucket-row
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.CloseElement(); // .m-insights__buckets
|
||||||
|
};
|
||||||
|
|
||||||
|
private RenderFragment RenderBucketLabel(string key, BucketRenderKind kind) => builder =>
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case BucketRenderKind.Severity:
|
||||||
|
{
|
||||||
|
var locKey = key switch
|
||||||
|
{
|
||||||
|
OutcomeBucketKeys.SeverityHigh => "Anomaly.Severity.High",
|
||||||
|
OutcomeBucketKeys.SeverityMedium => "Anomaly.Severity.Medium",
|
||||||
|
OutcomeBucketKeys.SeverityLow => "Anomaly.Severity.Low",
|
||||||
|
_ => "Anomaly.Severity.Low",
|
||||||
|
};
|
||||||
|
builder.AddContent(0, L[locKey]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case BucketRenderKind.Sport:
|
||||||
|
{
|
||||||
|
var trimmed = key.StartsWith(OutcomeBucketKeys.SportPrefix, StringComparison.Ordinal)
|
||||||
|
? key.Substring(OutcomeBucketKeys.SportPrefix.Length)
|
||||||
|
: key;
|
||||||
|
if (int.TryParse(trimmed, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var code))
|
||||||
|
{
|
||||||
|
var label = SportLabels.Resolve(L, code);
|
||||||
|
builder.OpenComponent<SportIcon>(0);
|
||||||
|
builder.AddAttribute(1, "Code", code);
|
||||||
|
builder.AddAttribute(2, "Label", label);
|
||||||
|
builder.AddAttribute(3, "ClassName", "m-insights__sport-icon");
|
||||||
|
builder.CloseComponent();
|
||||||
|
builder.AddContent(4, label);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.AddContent(0, key);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case BucketRenderKind.Score:
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
var trimmed = key.StartsWith(OutcomeBucketKeys.BinPrefix, StringComparison.Ordinal)
|
||||||
|
? key.Substring(OutcomeBucketKeys.BinPrefix.Length)
|
||||||
|
: key;
|
||||||
|
builder.AddContent(0, trimmed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Formatting / labels -----------------------------------------------
|
||||||
|
|
||||||
|
private static string HitRateTone(decimal? rate) => rate switch
|
||||||
|
{
|
||||||
|
null => "neutral",
|
||||||
|
>= 0.60m => "positive",
|
||||||
|
< 0.40m => "negative",
|
||||||
|
_ => "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string FormatPercent(decimal? rate)
|
||||||
|
{
|
||||||
|
if (rate is null) return "—";
|
||||||
|
var pct = (int)Math.Round(rate.Value * 100m, MidpointRounding.AwayFromZero);
|
||||||
|
return pct.ToString(System.Globalization.CultureInfo.InvariantCulture) + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string SideLabel(Side? side) => side switch
|
||||||
|
{
|
||||||
|
Side.Side1 => L["Insights.Side.Side1"],
|
||||||
|
Side.Side2 => L["Insights.Side.Side2"],
|
||||||
|
Side.Draw => L["Insights.Side.Draw"],
|
||||||
|
_ => L["Insights.Side.Unknown"],
|
||||||
|
};
|
||||||
|
|
||||||
|
private string OutcomeLabel(AnomalyOutcomeKind o) => o switch
|
||||||
|
{
|
||||||
|
AnomalyOutcomeKind.Hit => L["Insights.Outcome.Hit"],
|
||||||
|
AnomalyOutcomeKind.Miss => L["Insights.Outcome.Miss"],
|
||||||
|
AnomalyOutcomeKind.Unresolved => L["Insights.Outcome.Unresolved"],
|
||||||
|
_ => L["Insights.Outcome.Unresolved"],
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string OutcomeCss(AnomalyOutcomeKind o) => o switch
|
||||||
|
{
|
||||||
|
AnomalyOutcomeKind.Hit => "hit",
|
||||||
|
AnomalyOutcomeKind.Miss => "miss",
|
||||||
|
AnomalyOutcomeKind.Unresolved => "pending",
|
||||||
|
_ => "pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_loadCts?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@*
|
||||||
|
ExportHub — a top-level home for the Excel export so it is no longer gated
|
||||||
|
behind opening a single event's detail page. Hosts the shared ExportDialog
|
||||||
|
(date range + kind) which is not event-specific.
|
||||||
|
*@
|
||||||
|
|
||||||
|
@page "/export"
|
||||||
|
@inject IStringLocalizer<SharedResource> L
|
||||||
|
@inject IDialogService Dialog
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>@L["App.Title"] · @L["Export.Title"]</PageTitle>
|
||||||
|
|
||||||
|
<section class="m-shell">
|
||||||
|
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
|
||||||
|
<span class="m-kicker">@L["Export.Kicker"]</span>
|
||||||
|
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Export.Title"]</h1>
|
||||||
|
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Export.Hub.Lede"]</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<hr class="m-rule--double" />
|
||||||
|
|
||||||
|
<div class="m-rise m-rise-2" style="display: grid; gap: var(--m-space-3);">
|
||||||
|
<div>
|
||||||
|
<button type="button" class="m-chip" @onclick="OpenExportDialog" data-test="export-hub-open">
|
||||||
|
@L["Export.Hub.Action"] →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); letter-spacing: 0.08em;">
|
||||||
|
@L["Export.Hub.FilenameHint"]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private async Task OpenExportDialog()
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["InitialFrom"] = MoscowTime.Now.Date.AddDays(-7),
|
||||||
|
["InitialTo"] = MoscowTime.Now.Date,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
CloseOnEscapeKey = true,
|
||||||
|
FullWidth = true,
|
||||||
|
MaxWidth = MaxWidth.Small,
|
||||||
|
};
|
||||||
|
|
||||||
|
var reference = await Dialog.ShowAsync<ExportDialog>(L["Export.Title"], parameters, options);
|
||||||
|
var result = await reference.Result;
|
||||||
|
if (result is { Canceled: false, Data: string path })
|
||||||
|
{
|
||||||
|
Snackbar.Add(string.Format(CultureInfo.CurrentCulture, L["Export.Success"].Value, path), Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@inject IStringLocalizer<SharedResource> L
|
@inject IStringLocalizer<SharedResource> L
|
||||||
|
@inject IDashboardSummaryService Dashboard
|
||||||
|
@inject ILogger<Home> Logger
|
||||||
|
|
||||||
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</PageTitle>
|
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</PageTitle>
|
||||||
|
|
||||||
@@ -15,10 +17,13 @@
|
|||||||
<hr class="m-rule--double" />
|
<hr class="m-rule--double" />
|
||||||
|
|
||||||
<div class="m-grid--three m-rise m-rise-2">
|
<div class="m-grid--three m-rise m-rise-2">
|
||||||
<StatCard Label="@L["Home.Stat.Events"]" Value="@_eventsTracked.ToString("N0")" Delta="+12%" />
|
<StatCard Label="@L["Home.Stat.Events"]" Value="@_summary.EventsTracked.ToString("N0")" />
|
||||||
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_snapshotsToday.ToString("N0")" Delta="+318" />
|
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_summary.SnapshotsToday.ToString("N0")" />
|
||||||
<StatCard Label="@L["Home.Stat.Anomalies"]" Value="@_anomalies.ToString()" Delta="3 NEW" Anomaly="true" />
|
<StatCard Label="@L["Home.Stat.Anomalies"]"
|
||||||
<StatCard Label="@L["Home.Stat.Coverage"]" Value="4" Delta="BSK · FBL · TNS · HKY" />
|
Value="@_summary.AnomaliesTotal.ToString("N0")"
|
||||||
|
Delta="@AnomaliesDelta"
|
||||||
|
Anomaly="true" />
|
||||||
|
<StatCard Label="@L["Home.Stat.Coverage"]" Value="@_summary.SportsCovered.ToString()" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="m-grid--asym m-rise m-rise-3" style="margin-top: var(--m-space-6);">
|
<div class="m-grid--asym m-rise m-rise-3" style="margin-top: var(--m-space-6);">
|
||||||
@@ -30,49 +35,112 @@
|
|||||||
@L["Anomaly.Kind.SuspensionFlip"]
|
@L["Anomaly.Kind.SuspensionFlip"]
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div style="display: grid; gap: var(--m-space-4);">
|
@if (!_summary.HasAnyData)
|
||||||
@foreach (var item in _placeholderFeed)
|
|
||||||
{
|
{
|
||||||
<article style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
|
@* First-run: nothing captured yet. Make the next step unmissable. *@
|
||||||
|
<div data-test="home-empty" style="display: grid; gap: var(--m-space-4); padding: var(--m-space-4) 0;">
|
||||||
|
<span class="m-mono" style="font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.16em; color: var(--m-c-accent);">
|
||||||
|
@L["Home.Empty.Heading"]
|
||||||
|
</span>
|
||||||
|
<p style="color: var(--m-c-ink-soft); max-width: 48ch; margin: 0;">
|
||||||
|
@L["Home.Empty"]
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<a href="/settings" data-test="home-empty-cta"
|
||||||
|
style="display: inline-flex; align-items: center; gap: 8px; padding: 10px 18px; border: 1px solid 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.12em; text-decoration: none;">
|
||||||
|
@L["Home.Empty.Cta"] →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_summary.LatestSignals.Count == 0)
|
||||||
|
{
|
||||||
|
@* Capturing, but the detector hasn't flagged anything yet. *@
|
||||||
|
<div data-test="home-no-signals" style="display: grid; gap: var(--m-space-3); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
|
||||||
|
<p style="color: var(--m-c-ink-soft); margin: 0;">@L["Home.NoSignals"]</p>
|
||||||
|
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
|
||||||
|
@L["Home.ViewAll"] →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="display: grid; gap: var(--m-space-4);">
|
||||||
|
@foreach (var signal in _summary.LatestSignals)
|
||||||
|
{
|
||||||
|
<a href="@($"/anomalies/{signal.Id}")" data-test="home-signal"
|
||||||
|
style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule); text-decoration: none; color: inherit;">
|
||||||
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
|
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
|
||||||
@item.Time
|
@FormatSignalTime(signal.DetectedAt)
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight: 500;">@item.Match</div>
|
<div style="font-weight: 500;">@signal.EventTitle</div>
|
||||||
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">@item.Detail</div>
|
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">
|
||||||
|
@SportLabel(signal.Sport.Value) · @SeverityLabel(signal.Severity)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="m-anomaly">
|
<span class="m-anomaly">
|
||||||
<span class="m-anomaly__pulse"></span>
|
<span class="m-anomaly__pulse"></span>
|
||||||
@($"{item.Score:0.00}")
|
@signal.Score.ToString("0.00", CultureInfo.InvariantCulture)
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule); color: var(--m-c-ink-soft); font-size: 0.8125rem;">
|
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule);">
|
||||||
@L["Home.Empty"]
|
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
|
||||||
|
@L["Home.ViewAll"] →
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="m-card m-card--accented">
|
<aside class="m-card m-card--accented">
|
||||||
<span class="m-kicker">@L["Home.Section.Pipeline"]</span>
|
<span class="m-kicker">@L["Home.Section.Pipeline"]</span>
|
||||||
<ol style="list-style: none; padding: 0; margin: var(--m-space-4) 0 0; display: grid; gap: var(--m-space-3); counter-reset: m-step;">
|
<ol style="list-style: none; padding: 0; margin: var(--m-space-4) 0 0; display: grid; gap: var(--m-space-3); counter-reset: m-step;">
|
||||||
<PipelineStep Index="01" Label="@L["Home.Pipeline.Step1"]" Status="ok" />
|
<PipelineStep Index="01" Label="@L["Home.Pipeline.Step1"]" Status="@_summary.ScheduleStatus" />
|
||||||
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="ok" />
|
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="@_summary.SnapshotStatus" />
|
||||||
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="warn" />
|
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="@_summary.DetectorStatus" />
|
||||||
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="idle" />
|
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="@_summary.ExportStatus" />
|
||||||
</ol>
|
</ol>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
// Mock data — Phase 6+ will replace with live queries.
|
private DashboardSummary _summary = DashboardSummary.Empty;
|
||||||
private readonly int _eventsTracked = 0;
|
|
||||||
private readonly int _snapshotsToday = 0;
|
|
||||||
private readonly int _anomalies = 0;
|
|
||||||
|
|
||||||
private record FeedItem(string Time, string Match, string Detail, decimal Score);
|
private string? AnomaliesDelta => _summary.AnomaliesToday > 0
|
||||||
|
? string.Format(CultureInfo.CurrentCulture, L["Home.Stat.NewToday"], _summary.AnomaliesToday)
|
||||||
|
: null;
|
||||||
|
|
||||||
private readonly List<FeedItem> _placeholderFeed = new();
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_summary = await Dashboard.GetAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Dashboard is read-only chrome; on failure keep the empty summary and log.
|
||||||
|
Logger.LogError(ex, "Home: failed to load dashboard summary.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatSignalTime(DateTimeOffset at)
|
||||||
|
{
|
||||||
|
var moscow = at.ToOffset(MoscowTime.Offset);
|
||||||
|
return moscow.Date == MoscowTime.Now.Date
|
||||||
|
? moscow.ToString("HH:mm", CultureInfo.InvariantCulture)
|
||||||
|
: moscow.ToString("dd MMM", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string SportLabel(int code) => SportLabels.Resolve(L, code);
|
||||||
|
|
||||||
|
private string SeverityLabel(AnomalySeverity severity) => severity switch
|
||||||
|
{
|
||||||
|
AnomalySeverity.High => L["Anomaly.Severity.High"],
|
||||||
|
AnomalySeverity.Medium => L["Anomaly.Severity.Medium"],
|
||||||
|
_ => L["Anomaly.Severity.Low"],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,7 @@
|
|||||||
<Field Label="@L["Settings.Scraping.RetryBaseDelayMs"]">
|
<Field Label="@L["Settings.Scraping.RetryBaseDelayMs"]">
|
||||||
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.BaseDelayMs" Min="100" Max="60000" Variant="Variant.Outlined" />
|
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.BaseDelayMs" Min="100" Max="60000" Variant="Variant.Outlined" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field Label="@L["Settings.Scraping.BaseUrl"]">
|
<Field Label="@L["Settings.Scraping.BaseUrl"]" Hint="@L["Settings.Scraping.BaseUrl.Hint"]">
|
||||||
<MudTextField T="string" @bind-Value="_scraping.BaseUrl" Variant="Variant.Outlined" />
|
<MudTextField T="string" @bind-Value="_scraping.BaseUrl" Variant="Variant.Outlined" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field Label="@L["Settings.Scraping.RequestTimeoutSeconds"]">
|
<Field Label="@L["Settings.Scraping.RequestTimeoutSeconds"]">
|
||||||
@@ -242,6 +242,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload is ScrapingSettingsForm scraping
|
||||||
|
&& !(Uri.TryCreate(scraping.BaseUrl, UriKind.Absolute, out var baseUri)
|
||||||
|
&& (baseUri.Scheme == Uri.UriSchemeHttp || baseUri.Scheme == Uri.UriSchemeHttps)))
|
||||||
|
{
|
||||||
|
Snackbar.Add(L["Settings.Scraping.BaseUrl.Invalid"], Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload is WorkerOptions workers && !IsPlausibleCron(workers.UpcomingScheduleCron))
|
||||||
|
{
|
||||||
|
Snackbar.Add(L["Settings.Workers.Cron.Invalid"], Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var confirmed = await ConfirmAsync();
|
var confirmed = await ConfirmAsync();
|
||||||
if (!confirmed)
|
if (!confirmed)
|
||||||
{
|
{
|
||||||
@@ -260,6 +274,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lightweight 5- or 6-field cron sanity check — avoids a Cronos dependency in the
|
||||||
|
// UI layer; the worker still does the authoritative parse at startup.
|
||||||
|
private static bool IsPlausibleCron(string? expression)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(expression)) return false;
|
||||||
|
var fields = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return fields.Length is 5 or 6;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ResetSectionAsync(string section)
|
private async Task ResetSectionAsync(string section)
|
||||||
{
|
{
|
||||||
var confirmed = await ConfirmAsync();
|
var confirmed = await ConfirmAsync();
|
||||||
@@ -282,13 +305,6 @@
|
|||||||
|
|
||||||
private async Task<bool> ConfirmAsync()
|
private async Task<bool> ConfirmAsync()
|
||||||
{
|
{
|
||||||
var parameters = new DialogParameters
|
|
||||||
{
|
|
||||||
["ContentText"] = L["Settings.Confirm.Body"].Value,
|
|
||||||
["ButtonText"] = L["Settings.Action.Save"].Value,
|
|
||||||
["CancelText"] = L["Common.Cancel"].Value,
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = await Dialogs.ShowMessageBox(
|
var result = await Dialogs.ShowMessageBox(
|
||||||
title: L["Settings.Confirm.Title"],
|
title: L["Settings.Confirm.Title"],
|
||||||
message: L["Settings.Confirm.Body"],
|
message: L["Settings.Confirm.Body"],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
@using DomainEventId = Marathon.Domain.ValueObjects.EventId
|
@using DomainEventId = Marathon.Domain.ValueObjects.EventId
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject IStringLocalizer<SharedResource> L
|
@inject IStringLocalizer<SharedResource> L
|
||||||
|
@inject ILogger<EventListShell> Logger
|
||||||
|
|
||||||
<section class="m-shell">
|
<section class="m-shell">
|
||||||
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
|
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
|
||||||
@@ -364,7 +365,11 @@
|
|||||||
|
|
||||||
private void StartTimer()
|
private void StartTimer()
|
||||||
{
|
{
|
||||||
_refreshTimer?.Dispose();
|
if (_refreshTimer is not null)
|
||||||
|
{
|
||||||
|
_refreshTimer.Elapsed -= OnRefreshTimerElapsed;
|
||||||
|
_refreshTimer.Dispose();
|
||||||
|
}
|
||||||
var interval = Math.Max(5, AutoRefreshSeconds) * 1000.0;
|
var interval = Math.Max(5, AutoRefreshSeconds) * 1000.0;
|
||||||
_refreshTimer = new System.Timers.Timer(interval) { AutoReset = true };
|
_refreshTimer = new System.Timers.Timer(interval) { AutoReset = true };
|
||||||
_refreshTimer.Elapsed += OnRefreshTimerElapsed;
|
_refreshTimer.Elapsed += OnRefreshTimerElapsed;
|
||||||
@@ -383,10 +388,11 @@
|
|||||||
{
|
{
|
||||||
await InvokeAsync(LoadAsync);
|
await InvokeAsync(LoadAsync);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Swallowed — LoadAsync already handles its own errors; this catch
|
// Last line of defense for InvokeAsync itself — LoadAsync handles its
|
||||||
// is the last line of defense for InvokeAsync itself.
|
// own errors. Log rather than silently dropping the failure.
|
||||||
|
Logger.LogError(ex, "EventListShell ({Surface}): auto-refresh tick failed.", Surface);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,9 +420,10 @@
|
|||||||
{
|
{
|
||||||
// Swallow — superseded by a newer load.
|
// Swallow — superseded by a newer load.
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Hide errors from the UI; Phase 9 will add a snackbar.
|
// Degrade gracefully (clear the rows) but record the failure for diagnosis.
|
||||||
|
Logger.LogError(ex, "EventListShell ({Surface}): failed to load event rows.", Surface);
|
||||||
_rows = new List<EventListItem>();
|
_rows = new List<EventListItem>();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -523,7 +530,11 @@
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_refreshTimer?.Dispose();
|
if (_refreshTimer is not null)
|
||||||
|
{
|
||||||
|
_refreshTimer.Elapsed -= OnRefreshTimerElapsed;
|
||||||
|
_refreshTimer.Dispose();
|
||||||
|
}
|
||||||
_searchCts?.Cancel();
|
_searchCts?.Cancel();
|
||||||
_searchCts?.Dispose();
|
_searchCts?.Dispose();
|
||||||
_loadCts?.Cancel();
|
_loadCts?.Cancel();
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
<data name="Nav.Anomalies"><value>Anomalies</value></data>
|
<data name="Nav.Anomalies"><value>Anomalies</value></data>
|
||||||
<data name="Nav.Results"><value>Results</value></data>
|
<data name="Nav.Results"><value>Results</value></data>
|
||||||
<data name="Nav.Settings"><value>Settings</value></data>
|
<data name="Nav.Settings"><value>Settings</value></data>
|
||||||
|
<data name="Nav.Export"><value>Export</value></data>
|
||||||
|
|
||||||
<data name="Home.Kicker"><value>Briefing</value></data>
|
<data name="Home.Kicker"><value>Briefing</value></data>
|
||||||
<data name="Home.Title"><value>Hunting odds-flip anomalies</value></data>
|
<data name="Home.Title"><value>Hunting odds-flip anomalies</value></data>
|
||||||
@@ -79,6 +80,14 @@
|
|||||||
<data name="Home.Pipeline.Step3"><value>Flip detector</value></data>
|
<data name="Home.Pipeline.Step3"><value>Flip detector</value></data>
|
||||||
<data name="Home.Pipeline.Step4"><value>XLSX export</value></data>
|
<data name="Home.Pipeline.Step4"><value>XLSX export</value></data>
|
||||||
<data name="Home.Empty"><value>No data yet. Enable the background pollers in Settings to start the feed.</value></data>
|
<data name="Home.Empty"><value>No data yet. Enable the background pollers in Settings to start the feed.</value></data>
|
||||||
|
<data name="Home.Empty.Heading"><value>Nothing captured yet</value></data>
|
||||||
|
<data name="Home.Empty.Cta"><value>Open Settings</value></data>
|
||||||
|
<data name="Home.NoSignals"><value>Capturing lines — no flips flagged yet.</value></data>
|
||||||
|
<data name="Home.ViewAll"><value>View all signals</value></data>
|
||||||
|
<data name="Home.Stat.NewToday"><value>{0} new today</value></data>
|
||||||
|
<data name="Scraping.On"><value>Capturing</value></data>
|
||||||
|
<data name="Scraping.Off"><value>Paused</value></data>
|
||||||
|
<data name="Scraping.Aria"><value>Data capture status</value></data>
|
||||||
|
|
||||||
<data name="Settings.Kicker"><value>Configuration</value></data>
|
<data name="Settings.Kicker"><value>Configuration</value></data>
|
||||||
<data name="Settings.Title"><value>Settings</value></data>
|
<data name="Settings.Title"><value>Settings</value></data>
|
||||||
@@ -107,6 +116,9 @@
|
|||||||
<data name="Settings.Scraping.RateLimitRps"><value>Rate limit (RPS)</value></data>
|
<data name="Settings.Scraping.RateLimitRps"><value>Rate limit (RPS)</value></data>
|
||||||
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Requests per second. 1 is recommended.</value></data>
|
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Requests per second. 1 is recommended.</value></data>
|
||||||
<data name="Settings.Scraping.BaseUrl"><value>Base URL</value></data>
|
<data name="Settings.Scraping.BaseUrl"><value>Base URL</value></data>
|
||||||
|
<data name="Settings.Scraping.BaseUrl.Hint"><value>Must be an absolute http(s) URL, e.g. https://www.marathonbet.by</value></data>
|
||||||
|
<data name="Settings.Scraping.BaseUrl.Invalid"><value>Base URL must be an absolute http(s) address.</value></data>
|
||||||
|
<data name="Settings.Workers.Cron.Invalid"><value>Schedule must be a 5- or 6-field cron expression.</value></data>
|
||||||
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Request timeout (sec)</value></data>
|
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Request timeout (sec)</value></data>
|
||||||
<data name="Settings.Scraping.UsePlaywright"><value>Use Playwright</value></data>
|
<data name="Settings.Scraping.UsePlaywright"><value>Use Playwright</value></data>
|
||||||
|
|
||||||
@@ -154,6 +166,7 @@
|
|||||||
|
|
||||||
<data name="Anomaly.Live"><value>Anomaly</value></data>
|
<data name="Anomaly.Live"><value>Anomaly</value></data>
|
||||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
|
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
|
||||||
|
<data name="Anomaly.Kind.SteamMove"><value>Steam move</value></data>
|
||||||
<data name="Anomaly.Score"><value>Confidence</value></data>
|
<data name="Anomaly.Score"><value>Confidence</value></data>
|
||||||
|
|
||||||
<!-- Phase 7 — Anomaly feed UI -->
|
<!-- Phase 7 — Anomaly feed UI -->
|
||||||
@@ -181,6 +194,7 @@
|
|||||||
<data name="Anomaly.Evidence.FavouriteSwap"><value>Favourite swap</value></data>
|
<data name="Anomaly.Evidence.FavouriteSwap"><value>Favourite swap</value></data>
|
||||||
<data name="Anomaly.Detail.EvidenceTitle"><value>Evidence timeline</value></data>
|
<data name="Anomaly.Detail.EvidenceTitle"><value>Evidence timeline</value></data>
|
||||||
<data name="Anomaly.Detail.LinkBackToEvent"><value>Open event</value></data>
|
<data name="Anomaly.Detail.LinkBackToEvent"><value>Open event</value></data>
|
||||||
|
<data name="Action.LogBet"><value>Log bet</value></data>
|
||||||
<data name="Anomaly.Detail.BackToFeed"><value>Back to feed</value></data>
|
<data name="Anomaly.Detail.BackToFeed"><value>Back to feed</value></data>
|
||||||
<data name="Anomaly.Detail.NotFound"><value>Anomaly not found — it may have been pruned.</value></data>
|
<data name="Anomaly.Detail.NotFound"><value>Anomaly not found — it may have been pruned.</value></data>
|
||||||
<data name="Anomaly.Empty.NoneInRange"><value>No anomalies match the current filters. Loosen the severity threshold or widen the date range.</value></data>
|
<data name="Anomaly.Empty.NoneInRange"><value>No anomalies match the current filters. Loosen the severity threshold or widen the date range.</value></data>
|
||||||
@@ -254,6 +268,9 @@
|
|||||||
<data name="Export.Submit"><value>Export</value></data>
|
<data name="Export.Submit"><value>Export</value></data>
|
||||||
<data name="Export.Cancel"><value>Cancel</value></data>
|
<data name="Export.Cancel"><value>Cancel</value></data>
|
||||||
<data name="Export.Success"><value>Export saved to {0}</value></data>
|
<data name="Export.Success"><value>Export saved to {0}</value></data>
|
||||||
|
<data name="Export.Hub.Lede"><value>Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first.</value></data>
|
||||||
|
<data name="Export.Hub.Action"><value>Configure export</value></data>
|
||||||
|
<data name="Export.Hub.FilenameHint"><value>Saved as Marathon_<from>_to_<to>.xlsx in the configured export directory.</value></data>
|
||||||
<data name="Export.Error.MissingDates"><value>Pick a start and end date.</value></data>
|
<data name="Export.Error.MissingDates"><value>Pick a start and end date.</value></data>
|
||||||
<data name="Export.Error.InvalidRange"><value>End date must be on or after the start date.</value></data>
|
<data name="Export.Error.InvalidRange"><value>End date must be on or after the start date.</value></data>
|
||||||
<data name="Export.Error.Failed"><value>Export failed.</value></data>
|
<data name="Export.Error.Failed"><value>Export failed.</value></data>
|
||||||
@@ -305,4 +322,172 @@
|
|||||||
<data name="Results.Loader.Progress.Failed"><value>Failed</value></data>
|
<data name="Results.Loader.Progress.Failed"><value>Failed</value></data>
|
||||||
<data name="Results.Loader.Summary.Format"><value>Loaded {0}, skipped {1}, processed {2} total.</value></data>
|
<data name="Results.Loader.Summary.Format"><value>Loaded {0}, skipped {1}, processed {2} total.</value></data>
|
||||||
<data name="Results.Loader.Empty.NoCandidates"><value>No events to load in this range.</value></data>
|
<data name="Results.Loader.Empty.NoCandidates"><value>No events to load in this range.</value></data>
|
||||||
|
|
||||||
|
<data name="Nav.Insights"><value>Insights</value></data>
|
||||||
|
<data name="Insights.Kicker"><value>Calibration</value></data>
|
||||||
|
<data name="Insights.Title"><value>Did the flips predict the winner?</value></data>
|
||||||
|
<data name="Insights.Lede"><value>Every persisted suspension-flip anomaly joined against the final event result. The hit rate tells you whether the post-flip favourite is the side that actually won — the only metric that says the detector is doing its job.</value></data>
|
||||||
|
<data name="Insights.Stat.HitRate"><value>Hit rate</value></data>
|
||||||
|
<data name="Insights.Stat.HitRate.Hint"><value>Post-flip favourite won.</value></data>
|
||||||
|
<data name="Insights.Stat.Resolved"><value>Resolved</value></data>
|
||||||
|
<data name="Insights.Stat.Resolved.Hint"><value>Anomalies with a graded event.</value></data>
|
||||||
|
<data name="Insights.Stat.Unresolved"><value>Unresolved</value></data>
|
||||||
|
<data name="Insights.Stat.Unresolved.Hint"><value>Awaiting event result.</value></data>
|
||||||
|
<data name="Insights.Stat.Hits"><value>Hits</value></data>
|
||||||
|
<data name="Insights.Stat.Misses"><value>Misses</value></data>
|
||||||
|
<data name="Insights.Stat.Total"><value>Total anomalies</value></data>
|
||||||
|
<data name="Insights.Section.BySeverity"><value>By severity</value></data>
|
||||||
|
<data name="Insights.Section.BySport"><value>By sport</value></data>
|
||||||
|
<data name="Insights.Section.ByScore"><value>By confidence score</value></data>
|
||||||
|
<data name="Insights.Section.Resolved"><value>Resolved anomalies</value></data>
|
||||||
|
<data name="Insights.Section.Unresolved"><value>Awaiting results</value></data>
|
||||||
|
<data name="Insights.Column.DetectedAt"><value>Detected</value></data>
|
||||||
|
<data name="Insights.Column.Match"><value>Match</value></data>
|
||||||
|
<data name="Insights.Column.Sport"><value>Sport</value></data>
|
||||||
|
<data name="Insights.Column.Score"><value>Score</value></data>
|
||||||
|
<data name="Insights.Column.PreFavourite"><value>Pre-flip pick</value></data>
|
||||||
|
<data name="Insights.Column.PostFavourite"><value>Post-flip pick</value></data>
|
||||||
|
<data name="Insights.Column.Winner"><value>Actual winner</value></data>
|
||||||
|
<data name="Insights.Column.Outcome"><value>Verdict</value></data>
|
||||||
|
<data name="Insights.Column.Bucket"><value>Bucket</value></data>
|
||||||
|
<data name="Insights.Column.HitRate"><value>Hit rate</value></data>
|
||||||
|
<data name="Insights.Column.HitsOfTotal"><value>Hits / total</value></data>
|
||||||
|
<data name="Insights.Outcome.Hit"><value>Hit</value></data>
|
||||||
|
<data name="Insights.Outcome.Miss"><value>Miss</value></data>
|
||||||
|
<data name="Insights.Outcome.Unresolved"><value>Pending</value></data>
|
||||||
|
<data name="Insights.Side.Side1"><value>Side 1</value></data>
|
||||||
|
<data name="Insights.Side.Side2"><value>Side 2</value></data>
|
||||||
|
<data name="Insights.Side.Draw"><value>Draw</value></data>
|
||||||
|
<data name="Insights.Side.Unknown"><value>—</value></data>
|
||||||
|
<data name="Insights.Empty.None"><value>No anomalies have been recorded yet. Once the detector flags one and the matching event finishes, its verdict will appear here.</value></data>
|
||||||
|
<data name="Insights.Empty.NoneResolved"><value>Anomalies exist but no matching events have been graded yet. Run the results loader or wait for matches to complete.</value></data>
|
||||||
|
<data name="Insights.Action.Refresh"><value>Refresh</value></data>
|
||||||
|
<data name="Insights.Action.OpenAnomaly"><value>Open</value></data>
|
||||||
|
<data name="Insights.Bucket.NotApplicable"><value>—</value></data>
|
||||||
|
|
||||||
|
<data name="Nav.MyBets"><value>My bets</value></data>
|
||||||
|
<data name="Journal.Kicker"><value>Journal</value></data>
|
||||||
|
<data name="Journal.Title"><value>Your bets and CLV</value></data>
|
||||||
|
<data name="Journal.Lede"><value>Every wager you've recorded, graded against final results and scored against the closing line. Positive CLV is the leading indicator that says you're consistently beating the market.</value></data>
|
||||||
|
<data name="Journal.Stat.Roi"><value>ROI</value></data>
|
||||||
|
<data name="Journal.Stat.Roi.Hint"><value>Net profit ÷ total staked.</value></data>
|
||||||
|
<data name="Journal.Stat.StrikeRate"><value>Strike rate</value></data>
|
||||||
|
<data name="Journal.Stat.StrikeRate.Hint"><value>Wins ÷ (wins + losses).</value></data>
|
||||||
|
<data name="Journal.Stat.AvgClv"><value>Avg CLV</value></data>
|
||||||
|
<data name="Journal.Stat.AvgClv.Hint"><value>Mean closing-line implied-probability gain.</value></data>
|
||||||
|
<data name="Journal.Stat.NetProfit"><value>Net profit</value></data>
|
||||||
|
<data name="Journal.Stat.NetProfit.Hint"><value>Returns minus stakes (resolved bets).</value></data>
|
||||||
|
<data name="Journal.Stat.TotalBets"><value>Total bets</value></data>
|
||||||
|
<data name="Journal.Stat.Pending"><value>Pending</value></data>
|
||||||
|
<data name="Journal.Stat.Won"><value>Won</value></data>
|
||||||
|
<data name="Journal.Stat.Lost"><value>Lost</value></data>
|
||||||
|
<data name="Journal.Stat.Void"><value>Void</value></data>
|
||||||
|
<data name="Journal.Section.Add"><value>Record a bet</value></data>
|
||||||
|
<data name="Journal.Section.List"><value>Bet journal</value></data>
|
||||||
|
<data name="Journal.Action.Refresh"><value>Refresh</value></data>
|
||||||
|
<data name="Journal.Action.Resolve"><value>Resolve pending</value></data>
|
||||||
|
<data name="Journal.Action.Submit"><value>Record bet</value></data>
|
||||||
|
<data name="Journal.Action.Delete"><value>Delete</value></data>
|
||||||
|
<data name="Journal.Action.Confirm"><value>Confirm</value></data>
|
||||||
|
<data name="Journal.Action.Cancel"><value>Cancel</value></data>
|
||||||
|
<data name="Journal.Field.EventId"><value>Event ID</value></data>
|
||||||
|
<data name="Journal.Field.EventId.Hint"><value>Numeric ID from the event detail URL.</value></data>
|
||||||
|
<data name="Journal.Field.FindEvent"><value>Find event</value></data>
|
||||||
|
<data name="Journal.Field.FindEvent.Placeholder"><value>Search by team name…</value></data>
|
||||||
|
<data name="Journal.Field.FindEvent.Hint"><value>Pick an upcoming event to fill the ID, or type it below.</value></data>
|
||||||
|
<data name="Journal.Field.Type"><value>Bet type</value></data>
|
||||||
|
<data name="Journal.Field.Side"><value>Side</value></data>
|
||||||
|
<data name="Journal.Field.Value"><value>Threshold</value></data>
|
||||||
|
<data name="Journal.Field.Value.Hint"><value>Handicap or total line (e.g. -1.5, 2.5).</value></data>
|
||||||
|
<data name="Journal.Field.Rate"><value>Taken rate</value></data>
|
||||||
|
<data name="Journal.Field.Stake"><value>Stake</value></data>
|
||||||
|
<data name="Journal.Field.Notes"><value>Notes</value></data>
|
||||||
|
<data name="Journal.Field.Notes.Placeholder"><value>Strategy tag, bookmaker, or anything you want to remember…</value></data>
|
||||||
|
<data name="Journal.BetType.Win"><value>Win</value></data>
|
||||||
|
<data name="Journal.BetType.Draw"><value>Draw</value></data>
|
||||||
|
<data name="Journal.BetType.WinFora"><value>Handicap</value></data>
|
||||||
|
<data name="Journal.BetType.Total"><value>Total</value></data>
|
||||||
|
<data name="Journal.Side.Side1"><value>Side 1</value></data>
|
||||||
|
<data name="Journal.Side.Side2"><value>Side 2</value></data>
|
||||||
|
<data name="Journal.Side.Draw"><value>Draw</value></data>
|
||||||
|
<data name="Journal.Side.Less"><value>Under</value></data>
|
||||||
|
<data name="Journal.Side.More"><value>Over</value></data>
|
||||||
|
<data name="Journal.Outcome.Pending"><value>Pending</value></data>
|
||||||
|
<data name="Journal.Outcome.Won"><value>Won</value></data>
|
||||||
|
<data name="Journal.Outcome.Lost"><value>Lost</value></data>
|
||||||
|
<data name="Journal.Outcome.Void"><value>Void</value></data>
|
||||||
|
<data name="Journal.Column.PlacedAt"><value>Placed</value></data>
|
||||||
|
<data name="Journal.Column.Match"><value>Match</value></data>
|
||||||
|
<data name="Journal.Column.Selection"><value>Selection</value></data>
|
||||||
|
<data name="Journal.Column.Stake"><value>Stake</value></data>
|
||||||
|
<data name="Journal.Column.Rate"><value>Rate</value></data>
|
||||||
|
<data name="Journal.Column.Profit"><value>P&L</value></data>
|
||||||
|
<data name="Journal.Column.Clv"><value>CLV</value></data>
|
||||||
|
<data name="Journal.Column.Outcome"><value>Outcome</value></data>
|
||||||
|
<data name="Journal.Empty.None"><value>No bets recorded yet. Use the form above to log a wager — once the event finishes the journal will auto-grade it and compute closing-line value against the latest pre-match snapshot.</value></data>
|
||||||
|
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
|
||||||
|
<data name="Journal.Error.Generic"><value>Failed to save bet — check the event ID and try again.</value></data>
|
||||||
|
<data name="Journal.Submitted"><value>Bet recorded.</value></data>
|
||||||
|
<data name="Journal.Kelly.Title"><value>Stake helper (¼-Kelly)</value></data>
|
||||||
|
<data name="Journal.Kelly.Bankroll"><value>Bankroll</value></data>
|
||||||
|
<data name="Journal.Kelly.Probability"><value>Win probability (%)</value></data>
|
||||||
|
<data name="Journal.Kelly.Suggestion"><value>Suggested stake: {0:0.00}</value></data>
|
||||||
|
<data name="Journal.Kelly.Apply"><value>Apply</value></data>
|
||||||
|
<data name="Journal.Kelly.NoEdge"><value>No positive edge at this price.</value></data>
|
||||||
|
<data name="Journal.Kelly.Hint"><value>Enter bankroll + win probability for a ¼-Kelly stake.</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.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.From"><value>From date</value></data>
|
||||||
|
<data name="Backtest.Field.To"><value>To date</value></data>
|
||||||
|
<data name="Backtest.Field.DateRange.Hint"><value>Leave both empty to backtest every graded anomaly.</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.Action.Cancel"><value>Cancel</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>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
<data name="Nav.Anomalies"><value>Аномалии</value></data>
|
<data name="Nav.Anomalies"><value>Аномалии</value></data>
|
||||||
<data name="Nav.Results"><value>Результаты</value></data>
|
<data name="Nav.Results"><value>Результаты</value></data>
|
||||||
<data name="Nav.Settings"><value>Настройки</value></data>
|
<data name="Nav.Settings"><value>Настройки</value></data>
|
||||||
|
<data name="Nav.Export"><value>Экспорт</value></data>
|
||||||
|
|
||||||
<!-- Home / Dashboard -->
|
<!-- Home / Dashboard -->
|
||||||
<data name="Home.Kicker"><value>Сводка</value></data>
|
<data name="Home.Kicker"><value>Сводка</value></data>
|
||||||
@@ -82,6 +83,14 @@
|
|||||||
<data name="Home.Pipeline.Step3"><value>Детектор разворота</value></data>
|
<data name="Home.Pipeline.Step3"><value>Детектор разворота</value></data>
|
||||||
<data name="Home.Pipeline.Step4"><value>Экспорт XLSX</value></data>
|
<data name="Home.Pipeline.Step4"><value>Экспорт XLSX</value></data>
|
||||||
<data name="Home.Empty"><value>Пока пусто. Запустите фоновые сборщики на странице «Настройки», чтобы пошёл поток данных.</value></data>
|
<data name="Home.Empty"><value>Пока пусто. Запустите фоновые сборщики на странице «Настройки», чтобы пошёл поток данных.</value></data>
|
||||||
|
<data name="Home.Empty.Heading"><value>Пока ничего не собрано</value></data>
|
||||||
|
<data name="Home.Empty.Cta"><value>Открыть настройки</value></data>
|
||||||
|
<data name="Home.NoSignals"><value>Идёт сбор линий — разворотов пока нет.</value></data>
|
||||||
|
<data name="Home.ViewAll"><value>Все сигналы</value></data>
|
||||||
|
<data name="Home.Stat.NewToday"><value>{0} новых сегодня</value></data>
|
||||||
|
<data name="Scraping.On"><value>Идёт сбор</value></data>
|
||||||
|
<data name="Scraping.Off"><value>Пауза</value></data>
|
||||||
|
<data name="Scraping.Aria"><value>Статус сбора данных</value></data>
|
||||||
|
|
||||||
<!-- Settings — sections -->
|
<!-- Settings — sections -->
|
||||||
<data name="Settings.Kicker"><value>Конфигурация</value></data>
|
<data name="Settings.Kicker"><value>Конфигурация</value></data>
|
||||||
@@ -112,6 +121,9 @@
|
|||||||
<data name="Settings.Scraping.RateLimitRps"><value>Лимит RPS</value></data>
|
<data name="Settings.Scraping.RateLimitRps"><value>Лимит RPS</value></data>
|
||||||
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Запросов в секунду. Рекомендовано 1.</value></data>
|
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Запросов в секунду. Рекомендовано 1.</value></data>
|
||||||
<data name="Settings.Scraping.BaseUrl"><value>Базовый URL</value></data>
|
<data name="Settings.Scraping.BaseUrl"><value>Базовый URL</value></data>
|
||||||
|
<data name="Settings.Scraping.BaseUrl.Hint"><value>Должен быть абсолютный http(s)-адрес, например https://www.marathonbet.by</value></data>
|
||||||
|
<data name="Settings.Scraping.BaseUrl.Invalid"><value>Базовый URL должен быть абсолютным http(s)-адресом.</value></data>
|
||||||
|
<data name="Settings.Workers.Cron.Invalid"><value>Расписание должно быть cron-выражением из 5 или 6 полей.</value></data>
|
||||||
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Тайм-аут запроса (сек)</value></data>
|
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Тайм-аут запроса (сек)</value></data>
|
||||||
<data name="Settings.Scraping.UsePlaywright"><value>Использовать Playwright</value></data>
|
<data name="Settings.Scraping.UsePlaywright"><value>Использовать Playwright</value></data>
|
||||||
|
|
||||||
@@ -167,6 +179,7 @@
|
|||||||
<!-- Anomaly (Phase 7 placeholders) -->
|
<!-- Anomaly (Phase 7 placeholders) -->
|
||||||
<data name="Anomaly.Live"><value>Аномалия</value></data>
|
<data name="Anomaly.Live"><value>Аномалия</value></data>
|
||||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
|
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
|
||||||
|
<data name="Anomaly.Kind.SteamMove"><value>Движение линии</value></data>
|
||||||
<data name="Anomaly.Score"><value>Уверенность</value></data>
|
<data name="Anomaly.Score"><value>Уверенность</value></data>
|
||||||
|
|
||||||
<!-- Phase 7 — Лента аномалий -->
|
<!-- Phase 7 — Лента аномалий -->
|
||||||
@@ -194,6 +207,7 @@
|
|||||||
<data name="Anomaly.Evidence.FavouriteSwap"><value>Смена фаворита</value></data>
|
<data name="Anomaly.Evidence.FavouriteSwap"><value>Смена фаворита</value></data>
|
||||||
<data name="Anomaly.Detail.EvidenceTitle"><value>Хроника свидетельств</value></data>
|
<data name="Anomaly.Detail.EvidenceTitle"><value>Хроника свидетельств</value></data>
|
||||||
<data name="Anomaly.Detail.LinkBackToEvent"><value>Открыть событие</value></data>
|
<data name="Anomaly.Detail.LinkBackToEvent"><value>Открыть событие</value></data>
|
||||||
|
<data name="Action.LogBet"><value>Записать ставку</value></data>
|
||||||
<data name="Anomaly.Detail.BackToFeed"><value>К ленте</value></data>
|
<data name="Anomaly.Detail.BackToFeed"><value>К ленте</value></data>
|
||||||
<data name="Anomaly.Detail.NotFound"><value>Аномалия не найдена — возможно, она была удалена.</value></data>
|
<data name="Anomaly.Detail.NotFound"><value>Аномалия не найдена — возможно, она была удалена.</value></data>
|
||||||
<data name="Anomaly.Empty.NoneInRange"><value>Под текущие фильтры аномалии не попадают. Снизьте порог важности или расширьте диапазон дат.</value></data>
|
<data name="Anomaly.Empty.NoneInRange"><value>Под текущие фильтры аномалии не попадают. Снизьте порог важности или расширьте диапазон дат.</value></data>
|
||||||
@@ -267,6 +281,9 @@
|
|||||||
<data name="Export.Submit"><value>Экспорт</value></data>
|
<data name="Export.Submit"><value>Экспорт</value></data>
|
||||||
<data name="Export.Cancel"><value>Отмена</value></data>
|
<data name="Export.Cancel"><value>Отмена</value></data>
|
||||||
<data name="Export.Success"><value>Файл сохранён в {0}</value></data>
|
<data name="Export.Success"><value>Файл сохранён в {0}</value></data>
|
||||||
|
<data name="Export.Hub.Lede"><value>Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие.</value></data>
|
||||||
|
<data name="Export.Hub.Action"><value>Настроить экспорт</value></data>
|
||||||
|
<data name="Export.Hub.FilenameHint"><value>Сохраняется как Marathon_<от>_to_<до>.xlsx в указанной папке экспорта.</value></data>
|
||||||
<data name="Export.Error.MissingDates"><value>Выберите даты начала и конца.</value></data>
|
<data name="Export.Error.MissingDates"><value>Выберите даты начала и конца.</value></data>
|
||||||
<data name="Export.Error.InvalidRange"><value>Дата конца должна быть не раньше даты начала.</value></data>
|
<data name="Export.Error.InvalidRange"><value>Дата конца должна быть не раньше даты начала.</value></data>
|
||||||
<data name="Export.Error.Failed"><value>Экспорт не удался.</value></data>
|
<data name="Export.Error.Failed"><value>Экспорт не удался.</value></data>
|
||||||
@@ -318,4 +335,172 @@
|
|||||||
<data name="Results.Loader.Progress.Failed"><value>Ошибка</value></data>
|
<data name="Results.Loader.Progress.Failed"><value>Ошибка</value></data>
|
||||||
<data name="Results.Loader.Summary.Format"><value>Загружено {0}, пропущено {1}, всего обработано {2}.</value></data>
|
<data name="Results.Loader.Summary.Format"><value>Загружено {0}, пропущено {1}, всего обработано {2}.</value></data>
|
||||||
<data name="Results.Loader.Empty.NoCandidates"><value>Нет событий для загрузки в этом диапазоне.</value></data>
|
<data name="Results.Loader.Empty.NoCandidates"><value>Нет событий для загрузки в этом диапазоне.</value></data>
|
||||||
|
|
||||||
|
<data name="Nav.Insights"><value>Калибровка</value></data>
|
||||||
|
<data name="Insights.Kicker"><value>Калибровка</value></data>
|
||||||
|
<data name="Insights.Title"><value>Угадывают ли флипы победителя?</value></data>
|
||||||
|
<data name="Insights.Lede"><value>Каждая зафиксированная аномалия suspension-flip сопоставлена с итогом матча. Hit rate показывает, оказался ли пост-флип фаворит реальным победителем — это и есть единственная метрика, говорящая, что детектор работает.</value></data>
|
||||||
|
<data name="Insights.Stat.HitRate"><value>Hit rate</value></data>
|
||||||
|
<data name="Insights.Stat.HitRate.Hint"><value>Пост-флип фаворит выиграл.</value></data>
|
||||||
|
<data name="Insights.Stat.Resolved"><value>Подтверждены</value></data>
|
||||||
|
<data name="Insights.Stat.Resolved.Hint"><value>Аномалии с известным итогом.</value></data>
|
||||||
|
<data name="Insights.Stat.Unresolved"><value>Без результата</value></data>
|
||||||
|
<data name="Insights.Stat.Unresolved.Hint"><value>Ждём окончания матча.</value></data>
|
||||||
|
<data name="Insights.Stat.Hits"><value>Попадания</value></data>
|
||||||
|
<data name="Insights.Stat.Misses"><value>Промахи</value></data>
|
||||||
|
<data name="Insights.Stat.Total"><value>Всего аномалий</value></data>
|
||||||
|
<data name="Insights.Section.BySeverity"><value>По уровню</value></data>
|
||||||
|
<data name="Insights.Section.BySport"><value>По виду спорта</value></data>
|
||||||
|
<data name="Insights.Section.ByScore"><value>По уверенности</value></data>
|
||||||
|
<data name="Insights.Section.Resolved"><value>Подтверждённые аномалии</value></data>
|
||||||
|
<data name="Insights.Section.Unresolved"><value>Ожидают итога</value></data>
|
||||||
|
<data name="Insights.Column.DetectedAt"><value>Замечено</value></data>
|
||||||
|
<data name="Insights.Column.Match"><value>Матч</value></data>
|
||||||
|
<data name="Insights.Column.Sport"><value>Вид спорта</value></data>
|
||||||
|
<data name="Insights.Column.Score"><value>Score</value></data>
|
||||||
|
<data name="Insights.Column.PreFavourite"><value>До флипа</value></data>
|
||||||
|
<data name="Insights.Column.PostFavourite"><value>После флипа</value></data>
|
||||||
|
<data name="Insights.Column.Winner"><value>Победитель</value></data>
|
||||||
|
<data name="Insights.Column.Outcome"><value>Вердикт</value></data>
|
||||||
|
<data name="Insights.Column.Bucket"><value>Группа</value></data>
|
||||||
|
<data name="Insights.Column.HitRate"><value>Hit rate</value></data>
|
||||||
|
<data name="Insights.Column.HitsOfTotal"><value>Попаданий / всего</value></data>
|
||||||
|
<data name="Insights.Outcome.Hit"><value>Попадание</value></data>
|
||||||
|
<data name="Insights.Outcome.Miss"><value>Промах</value></data>
|
||||||
|
<data name="Insights.Outcome.Unresolved"><value>Ожидает</value></data>
|
||||||
|
<data name="Insights.Side.Side1"><value>Сторона 1</value></data>
|
||||||
|
<data name="Insights.Side.Side2"><value>Сторона 2</value></data>
|
||||||
|
<data name="Insights.Side.Draw"><value>Ничья</value></data>
|
||||||
|
<data name="Insights.Side.Unknown"><value>—</value></data>
|
||||||
|
<data name="Insights.Empty.None"><value>Аномалии ещё не зафиксированы. Когда детектор отметит первую и матч завершится, его вердикт появится здесь.</value></data>
|
||||||
|
<data name="Insights.Empty.NoneResolved"><value>Аномалии есть, но ни у одного из их событий нет результата. Запустите загрузчик результатов или подождите окончания матчей.</value></data>
|
||||||
|
<data name="Insights.Action.Refresh"><value>Обновить</value></data>
|
||||||
|
<data name="Insights.Action.OpenAnomaly"><value>Открыть</value></data>
|
||||||
|
<data name="Insights.Bucket.NotApplicable"><value>—</value></data>
|
||||||
|
|
||||||
|
<data name="Nav.MyBets"><value>Мои ставки</value></data>
|
||||||
|
<data name="Journal.Kicker"><value>Журнал</value></data>
|
||||||
|
<data name="Journal.Title"><value>Ваши ставки и CLV</value></data>
|
||||||
|
<data name="Journal.Lede"><value>Каждая зафиксированная ставка с автоматическим расчётом результата и оценкой против линии закрытия. Положительный CLV — главный долгосрочный индикатор того, что вы стабильно обыгрываете рынок.</value></data>
|
||||||
|
<data name="Journal.Stat.Roi"><value>ROI</value></data>
|
||||||
|
<data name="Journal.Stat.Roi.Hint"><value>Чистая прибыль ÷ сумма ставок.</value></data>
|
||||||
|
<data name="Journal.Stat.StrikeRate"><value>Strike rate</value></data>
|
||||||
|
<data name="Journal.Stat.StrikeRate.Hint"><value>Победы ÷ (победы + поражения).</value></data>
|
||||||
|
<data name="Journal.Stat.AvgClv"><value>Средний CLV</value></data>
|
||||||
|
<data name="Journal.Stat.AvgClv.Hint"><value>Средний прирост вероятности к линии закрытия.</value></data>
|
||||||
|
<data name="Journal.Stat.NetProfit"><value>Чистая прибыль</value></data>
|
||||||
|
<data name="Journal.Stat.NetProfit.Hint"><value>Возвраты минус ставки (учтены сыгравшие).</value></data>
|
||||||
|
<data name="Journal.Stat.TotalBets"><value>Всего ставок</value></data>
|
||||||
|
<data name="Journal.Stat.Pending"><value>В ожидании</value></data>
|
||||||
|
<data name="Journal.Stat.Won"><value>Победа</value></data>
|
||||||
|
<data name="Journal.Stat.Lost"><value>Проигрыш</value></data>
|
||||||
|
<data name="Journal.Stat.Void"><value>Возврат</value></data>
|
||||||
|
<data name="Journal.Section.Add"><value>Записать ставку</value></data>
|
||||||
|
<data name="Journal.Section.List"><value>Журнал ставок</value></data>
|
||||||
|
<data name="Journal.Action.Refresh"><value>Обновить</value></data>
|
||||||
|
<data name="Journal.Action.Resolve"><value>Рассчитать ожидающие</value></data>
|
||||||
|
<data name="Journal.Action.Submit"><value>Записать</value></data>
|
||||||
|
<data name="Journal.Action.Delete"><value>Удалить</value></data>
|
||||||
|
<data name="Journal.Action.Confirm"><value>Подтвердить</value></data>
|
||||||
|
<data name="Journal.Action.Cancel"><value>Отмена</value></data>
|
||||||
|
<data name="Journal.Field.EventId"><value>ID события</value></data>
|
||||||
|
<data name="Journal.Field.EventId.Hint"><value>Числовой ID из URL детальной страницы.</value></data>
|
||||||
|
<data name="Journal.Field.FindEvent"><value>Найти событие</value></data>
|
||||||
|
<data name="Journal.Field.FindEvent.Placeholder"><value>Поиск по названию команды…</value></data>
|
||||||
|
<data name="Journal.Field.FindEvent.Hint"><value>Выберите предстоящее событие, чтобы подставить ID, или введите его ниже.</value></data>
|
||||||
|
<data name="Journal.Field.Type"><value>Тип ставки</value></data>
|
||||||
|
<data name="Journal.Field.Side"><value>Сторона</value></data>
|
||||||
|
<data name="Journal.Field.Value"><value>Порог</value></data>
|
||||||
|
<data name="Journal.Field.Value.Hint"><value>Гандикап или тотал (например −1.5, 2.5).</value></data>
|
||||||
|
<data name="Journal.Field.Rate"><value>Кэф на момент ставки</value></data>
|
||||||
|
<data name="Journal.Field.Stake"><value>Сумма ставки</value></data>
|
||||||
|
<data name="Journal.Field.Notes"><value>Заметки</value></data>
|
||||||
|
<data name="Journal.Field.Notes.Placeholder"><value>Тег стратегии, букмекер или что угодно для памяти…</value></data>
|
||||||
|
<data name="Journal.BetType.Win"><value>Победа</value></data>
|
||||||
|
<data name="Journal.BetType.Draw"><value>Ничья</value></data>
|
||||||
|
<data name="Journal.BetType.WinFora"><value>Фора</value></data>
|
||||||
|
<data name="Journal.BetType.Total"><value>Тотал</value></data>
|
||||||
|
<data name="Journal.Side.Side1"><value>Сторона 1</value></data>
|
||||||
|
<data name="Journal.Side.Side2"><value>Сторона 2</value></data>
|
||||||
|
<data name="Journal.Side.Draw"><value>Ничья</value></data>
|
||||||
|
<data name="Journal.Side.Less"><value>Меньше</value></data>
|
||||||
|
<data name="Journal.Side.More"><value>Больше</value></data>
|
||||||
|
<data name="Journal.Outcome.Pending"><value>Ожидает</value></data>
|
||||||
|
<data name="Journal.Outcome.Won"><value>Победа</value></data>
|
||||||
|
<data name="Journal.Outcome.Lost"><value>Проигрыш</value></data>
|
||||||
|
<data name="Journal.Outcome.Void"><value>Возврат</value></data>
|
||||||
|
<data name="Journal.Column.PlacedAt"><value>Размещено</value></data>
|
||||||
|
<data name="Journal.Column.Match"><value>Матч</value></data>
|
||||||
|
<data name="Journal.Column.Selection"><value>Выбор</value></data>
|
||||||
|
<data name="Journal.Column.Stake"><value>Ставка</value></data>
|
||||||
|
<data name="Journal.Column.Rate"><value>Кэф</value></data>
|
||||||
|
<data name="Journal.Column.Profit"><value>P&L</value></data>
|
||||||
|
<data name="Journal.Column.Clv"><value>CLV</value></data>
|
||||||
|
<data name="Journal.Column.Outcome"><value>Итог</value></data>
|
||||||
|
<data name="Journal.Empty.None"><value>Ставок пока нет. Запишите свою ставку через форму выше — после окончания матча журнал авто-проставит результат и посчитает CLV против последнего пре-матч снимка.</value></data>
|
||||||
|
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
|
||||||
|
<data name="Journal.Error.Generic"><value>Не удалось сохранить ставку — проверьте ID события и повторите.</value></data>
|
||||||
|
<data name="Journal.Submitted"><value>Ставка записана.</value></data>
|
||||||
|
<data name="Journal.Kelly.Title"><value>Калькулятор ставки (¼-Келли)</value></data>
|
||||||
|
<data name="Journal.Kelly.Bankroll"><value>Банкролл</value></data>
|
||||||
|
<data name="Journal.Kelly.Probability"><value>Вероятность выигрыша (%)</value></data>
|
||||||
|
<data name="Journal.Kelly.Suggestion"><value>Рекомендуемая ставка: {0:0.00}</value></data>
|
||||||
|
<data name="Journal.Kelly.Apply"><value>Применить</value></data>
|
||||||
|
<data name="Journal.Kelly.NoEdge"><value>Нет преимущества при этом коэффициенте.</value></data>
|
||||||
|
<data name="Journal.Kelly.Hint"><value>Введите банкролл и вероятность для ставки по ¼-Келли.</value></data>
|
||||||
|
<data name="Journal.Resolve.None"><value>Ожидающих ставок к расчёту нет.</value></data>
|
||||||
|
<data name="Journal.Resolve.Done"><value>Рассчитано ожидающих: {0}.</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.From"><value>Дата с</value></data>
|
||||||
|
<data name="Backtest.Field.To"><value>Дата по</value></data>
|
||||||
|
<data name="Backtest.Field.DateRange.Hint"><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.Action.Cancel"><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>
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(filter);
|
ArgumentNullException.ThrowIfNull(filter);
|
||||||
|
|
||||||
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
|
// Date filter pushed to SQL; severity needs the parsed score and sport needs
|
||||||
|
// the event join, so those two stay in memory over the smaller returned set.
|
||||||
|
var all = await _anomalies.ListByDateRangeAsync(filter.From, filter.To, ct).ConfigureAwait(false);
|
||||||
if (all.Count == 0) return Array.Empty<AnomalyListItem>();
|
if (all.Count == 0) return Array.Empty<AnomalyListItem>();
|
||||||
|
|
||||||
// Resolve event metadata in one pass — distinct EventIds only.
|
// Resolve event metadata in one batched pass — distinct EventIds only.
|
||||||
var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false);
|
var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var items = new List<AnomalyListItem>(all.Count);
|
var items = new List<AnomalyListItem>(all.Count);
|
||||||
@@ -44,7 +46,7 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filters in-memory (small list, UI page).
|
// Remaining filters in-memory (page-sized set).
|
||||||
IEnumerable<AnomalyListItem> filtered = items;
|
IEnumerable<AnomalyListItem> filtered = items;
|
||||||
|
|
||||||
if (filter.MinSeverity is { } minSeverity)
|
if (filter.MinSeverity is { } minSeverity)
|
||||||
@@ -57,16 +59,6 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
|
|||||||
filtered = filtered.Where(i => sports.Contains(i.Sport.Value));
|
filtered = filtered.Where(i => sports.Contains(i.Sport.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.From is { } from)
|
|
||||||
{
|
|
||||||
filtered = filtered.Where(i => i.DetectedAt >= from);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.To is { } to)
|
|
||||||
{
|
|
||||||
filtered = filtered.Where(i => i.DetectedAt <= to);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
.OrderByDescending(static i => i.DetectedAt)
|
.OrderByDescending(static i => i.DetectedAt)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -88,16 +80,9 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
|
|||||||
return new AnomalyDetailVm(item, pre, post);
|
return new AnomalyDetailVm(item, pre, post);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
|
public Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
|
||||||
{
|
// Server-side COUNT(*) — no longer materialises the table to count.
|
||||||
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
|
=> _anomalies.CountSinceAsync(since, ct);
|
||||||
var count = 0;
|
|
||||||
foreach (var anomaly in all)
|
|
||||||
{
|
|
||||||
if (anomaly.DetectedAt > since) count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
|
public async Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -125,14 +110,8 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
|
|||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var dict = new Dictionary<DomainEventId, Event>(distinct.Count);
|
// Single batched query instead of one GetAsync per distinct event (N+1).
|
||||||
foreach (var eid in distinct)
|
return await _events.GetManyAsync(distinct, ct).ConfigureAwait(false);
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
var ev = await _events.GetAsync(eid, ct).ConfigureAwait(false);
|
|
||||||
if (ev is not null) dict[eid] = ev;
|
|
||||||
}
|
|
||||||
return dict;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryProject(
|
private static bool TryProject(
|
||||||
@@ -151,7 +130,7 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
|
|||||||
var country = ev?.CountryCode ?? string.Empty;
|
var country = ev?.CountryCode ?? string.Empty;
|
||||||
var league = ev?.LeagueId ?? string.Empty;
|
var league = ev?.LeagueId ?? string.Empty;
|
||||||
var title = ev is not null
|
var title = ev is not null
|
||||||
? $"{ev.Side1Name} vs {ev.Side2Name}"
|
? ev.Title
|
||||||
: anomaly.EventId.Value;
|
: anomaly.EventId.Value;
|
||||||
|
|
||||||
var preSnap = ToSnapshot(dto.PreSuspension);
|
var preSnap = ToSnapshot(dto.PreSuspension);
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Marathon.Application.UseCases;
|
||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.UI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Page-facing implementation of <see cref="IAnomalyInsightsService"/>. Runs
|
||||||
|
/// the application use case and reshapes its output for the page — event title
|
||||||
|
/// strings and severity buckets are computed once from the report's payload, so
|
||||||
|
/// the service performs no repository I/O of its own.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AnomalyInsightsService : IAnomalyInsightsService
|
||||||
|
{
|
||||||
|
private readonly EvaluateAnomalyOutcomesUseCase _useCase;
|
||||||
|
|
||||||
|
public AnomalyInsightsService(EvaluateAnomalyOutcomesUseCase useCase)
|
||||||
|
{
|
||||||
|
_useCase = useCase ?? throw new ArgumentNullException(nameof(useCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AnomalyInsightsVm> GetReportAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var report = await _useCase.ExecuteAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var resolvedRows = report.Resolved
|
||||||
|
.Select(r => ToRow(r, report.EventTitles))
|
||||||
|
.ToList();
|
||||||
|
var unresolvedRows = report.Unresolved
|
||||||
|
.Select(r => ToRow(r, report.EventTitles))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new AnomalyInsightsVm(
|
||||||
|
TotalAnomalies: report.TotalAnomalies,
|
||||||
|
ResolvedCount: report.ResolvedCount,
|
||||||
|
UnresolvedCount: report.UnresolvedCount,
|
||||||
|
HitCount: report.HitCount,
|
||||||
|
MissCount: report.MissCount,
|
||||||
|
HitRate: report.HitRate,
|
||||||
|
BySeverity: report.BySeverity,
|
||||||
|
BySport: report.BySport,
|
||||||
|
ByScoreBin: report.ByScoreBin,
|
||||||
|
Resolved: resolvedRows,
|
||||||
|
Unresolved: unresolvedRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResolvedAnomalyRow ToRow(
|
||||||
|
ResolvedAnomaly src,
|
||||||
|
IReadOnlyDictionary<DomainEventId, string> titles) =>
|
||||||
|
new(
|
||||||
|
AnomalyId: src.AnomalyId,
|
||||||
|
EventId: src.EventId,
|
||||||
|
EventTitle: titles.TryGetValue(src.EventId, out var t) ? t : src.EventId.Value,
|
||||||
|
DetectedAt: src.DetectedAt,
|
||||||
|
Score: src.Score,
|
||||||
|
Severity: AnomalySeverityRules.FromScore(src.Score),
|
||||||
|
Sport: src.Sport,
|
||||||
|
PreFlipFavourite: src.PreFlipFavourite,
|
||||||
|
PostFlipFavourite: src.PostFlipFavourite,
|
||||||
|
ActualWinner: src.ActualWinner,
|
||||||
|
Outcome: src.Outcome);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Marathon.Application.Reporting;
|
||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.UI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UI-facing projection of <see cref="AnomalyOutcomeReport"/>. Adds a resolved
|
||||||
|
/// event title and severity bucket per row so the page never has to round-trip
|
||||||
|
/// to a repository.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AnomalyInsightsVm(
|
||||||
|
int TotalAnomalies,
|
||||||
|
int ResolvedCount,
|
||||||
|
int UnresolvedCount,
|
||||||
|
int HitCount,
|
||||||
|
int MissCount,
|
||||||
|
decimal? HitRate,
|
||||||
|
IReadOnlyList<OutcomeBucket> BySeverity,
|
||||||
|
IReadOnlyList<OutcomeBucket> BySport,
|
||||||
|
IReadOnlyList<OutcomeBucket> ByScoreBin,
|
||||||
|
IReadOnlyList<ResolvedAnomalyRow> Resolved,
|
||||||
|
IReadOnlyList<ResolvedAnomalyRow> Unresolved);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row in the resolved / unresolved drill-down list — anomaly + outcome +
|
||||||
|
/// pre-shaped event title for the link-back affordance.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ResolvedAnomalyRow(
|
||||||
|
Guid AnomalyId,
|
||||||
|
EventId EventId,
|
||||||
|
string EventTitle,
|
||||||
|
DateTimeOffset DetectedAt,
|
||||||
|
decimal Score,
|
||||||
|
AnomalySeverity Severity,
|
||||||
|
SportCode? Sport,
|
||||||
|
Side? PreFlipFavourite,
|
||||||
|
Side? PostFlipFavourite,
|
||||||
|
Side? ActualWinner,
|
||||||
|
AnomalyOutcomeKind Outcome);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
using Marathon.Domain.Enums;
|
using Marathon.Domain.Enums;
|
||||||
using Marathon.Domain.ValueObjects;
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
@@ -83,12 +84,14 @@ public enum AnomalyFavourite
|
|||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Helpers for severity bucketing.</summary>
|
/// <summary>Helpers for severity bucketing. Thresholds come from
|
||||||
|
/// <see cref="AnomalySeverityThresholds"/> so the UI badges and the
|
||||||
|
/// Application-layer outcome report agree by construction.</summary>
|
||||||
public static class AnomalySeverityRules
|
public static class AnomalySeverityRules
|
||||||
{
|
{
|
||||||
public const decimal LowThreshold = 0.30m;
|
public const decimal LowThreshold = AnomalySeverityThresholds.Low;
|
||||||
public const decimal MediumThreshold = 0.45m;
|
public const decimal MediumThreshold = AnomalySeverityThresholds.Medium;
|
||||||
public const decimal HighThreshold = 0.60m;
|
public const decimal HighThreshold = AnomalySeverityThresholds.High;
|
||||||
|
|
||||||
public static AnomalySeverity FromScore(decimal score) => score switch
|
public static AnomalySeverity FromScore(decimal score) => score switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(), form.ToDateRange(), 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,113 @@
|
|||||||
|
using Marathon.Application.Storage;
|
||||||
|
using Marathon.Domain.Backtesting;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Optional inclusive date-range filter (Moscow dates). Both null = all anomalies.</summary>
|
||||||
|
public DateTime? From { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional inclusive date-range filter (Moscow dates). Both null = all anomalies.</summary>
|
||||||
|
public DateTime? To { get; set; }
|
||||||
|
|
||||||
|
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; }
|
||||||
|
if (From is { } f && To is { } t && f.Date > t.Date)
|
||||||
|
{ error = "From date must be on or before To date."; 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>
|
||||||
|
/// The inclusive Moscow-day date range, or null when either bound is unset
|
||||||
|
/// (meaning: run over every graded anomaly).
|
||||||
|
/// </summary>
|
||||||
|
public DateRange? ToDateRange()
|
||||||
|
{
|
||||||
|
if (From is not { } from || To is not { } to)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new DateRange(
|
||||||
|
new DateTimeOffset(from.Date, MoscowTime.Offset),
|
||||||
|
MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(to.Date)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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,117 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
|
using Marathon.Application.UseCases;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.UI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Page-facing implementation of <see cref="IBetJournalService"/>. Composes the
|
||||||
|
/// four bet-journal use cases and joins event titles for the table rows.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BetJournalService : IBetJournalService
|
||||||
|
{
|
||||||
|
private readonly BuildBetJournalReportUseCase _build;
|
||||||
|
private readonly RecordPlacedBetUseCase _record;
|
||||||
|
private readonly ResolvePendingBetsUseCase _resolve;
|
||||||
|
private readonly DeletePlacedBetUseCase _delete;
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
|
||||||
|
public BetJournalService(
|
||||||
|
BuildBetJournalReportUseCase build,
|
||||||
|
RecordPlacedBetUseCase record,
|
||||||
|
ResolvePendingBetsUseCase resolve,
|
||||||
|
DeletePlacedBetUseCase delete,
|
||||||
|
IEventRepository events)
|
||||||
|
{
|
||||||
|
_build = build ?? throw new ArgumentNullException(nameof(build));
|
||||||
|
_record = record ?? throw new ArgumentNullException(nameof(record));
|
||||||
|
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
||||||
|
_delete = delete ?? throw new ArgumentNullException(nameof(delete));
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BetJournalVm> GetReportAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var report = await _build.ExecuteAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (report.Bets.Count == 0)
|
||||||
|
return new BetJournalVm(report.Stats, Array.Empty<BetJournalRowVm>());
|
||||||
|
|
||||||
|
// Resolve event titles in one batched query — distinct ids only (was N+1).
|
||||||
|
// Missing events (pruned by snapshot retention) fall back to the raw id.
|
||||||
|
var distinctIds = report.Bets.Select(r => r.Bet.EventId).Distinct().ToList();
|
||||||
|
var events = await _events.GetManyAsync(distinctIds, ct).ConfigureAwait(false);
|
||||||
|
var titles = new Dictionary<DomainEventId, string>(distinctIds.Count);
|
||||||
|
foreach (var id in distinctIds)
|
||||||
|
{
|
||||||
|
titles[id] = events.TryGetValue(id, out var ev)
|
||||||
|
? ev.Title
|
||||||
|
: id.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = report.Bets
|
||||||
|
.Select(r => new BetJournalRowVm(
|
||||||
|
Id: r.Bet.Id,
|
||||||
|
EventTitle: titles.TryGetValue(r.Bet.EventId, out var t) ? t : r.Bet.EventId.Value,
|
||||||
|
Bet: r.Bet,
|
||||||
|
ClvProbabilityDelta: r.ClvProbabilityDelta))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new BetJournalVm(report.Stats, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guid> AddAsync(AddBetForm form, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(form);
|
||||||
|
|
||||||
|
if (!form.IsValid(out var error))
|
||||||
|
throw new ArgumentException(error ?? "Invalid form.", nameof(form));
|
||||||
|
|
||||||
|
var selection = new Bet(
|
||||||
|
scope: MatchScope.Instance,
|
||||||
|
type: form.Type,
|
||||||
|
side: form.Side,
|
||||||
|
value: form.Value is { } v ? new OddsValue(v) : null,
|
||||||
|
rate: new OddsRate(form.Rate));
|
||||||
|
|
||||||
|
var bet = new PlacedBet(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: new DomainEventId(form.EventId.Trim()),
|
||||||
|
Selection: selection,
|
||||||
|
Stake: form.Stake,
|
||||||
|
PlacedAt: MoscowTime.Now,
|
||||||
|
Outcome: BetOutcome.Pending,
|
||||||
|
Notes: form.Notes);
|
||||||
|
|
||||||
|
var stored = await _record.ExecuteAsync(bet, ct).ConfigureAwait(false);
|
||||||
|
return stored.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteAsync(Guid betId, CancellationToken ct) =>
|
||||||
|
_delete.ExecuteAsync(betId, ct);
|
||||||
|
|
||||||
|
public Task<int> ResolvePendingAsync(CancellationToken ct) =>
|
||||||
|
_resolve.ExecuteAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<EventOption>> GetUpcomingEventOptionsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Generous betting window: recently started through a month out. Loaded once
|
||||||
|
// by the page; the autocomplete filters this list in memory per keystroke.
|
||||||
|
var now = MoscowTime.Now;
|
||||||
|
var range = new DateRange(now.AddDays(-7), now.AddDays(30));
|
||||||
|
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return events
|
||||||
|
.OrderBy(e => e.ScheduledAt)
|
||||||
|
.Select(e => new EventOption(
|
||||||
|
e.Id.Value,
|
||||||
|
string.Concat(e.Title, " · ", e.ScheduledAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)),
|
||||||
|
e.ScheduledAt))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using Marathon.Application.Betting;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Marathon.UI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Page-facing projection of <see cref="BetJournalReport"/>. Adds the
|
||||||
|
/// pre-shaped event title per row so the Journal page never has to round-trip
|
||||||
|
/// back to <see cref="Marathon.Application.Abstractions.IEventRepository"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record BetJournalVm(
|
||||||
|
BetJournalStats Stats,
|
||||||
|
IReadOnlyList<BetJournalRowVm> Bets);
|
||||||
|
|
||||||
|
/// <summary>Row-level view model for the journal table.</summary>
|
||||||
|
public sealed record BetJournalRowVm(
|
||||||
|
Guid Id,
|
||||||
|
string EventTitle,
|
||||||
|
PlacedBet Bet,
|
||||||
|
decimal? ClvProbabilityDelta);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data the Add-Bet form posts. Loose-typed so the form can bind raw inputs;
|
||||||
|
/// <see cref="ToDomain"/> applies the same invariants as
|
||||||
|
/// <see cref="Bet"/> / <see cref="PlacedBet"/> and surfaces validation errors
|
||||||
|
/// as exceptions.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddBetForm
|
||||||
|
{
|
||||||
|
public string EventId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Bet type enum value — defaults to Win.</summary>
|
||||||
|
public BetType Type { get; set; } = BetType.Win;
|
||||||
|
|
||||||
|
/// <summary>Side enum value — Win/Side1 default.</summary>
|
||||||
|
public Side Side { get; set; } = Side.Side1;
|
||||||
|
|
||||||
|
/// <summary>Handicap / total threshold; required for WinFora and Total.</summary>
|
||||||
|
public decimal? Value { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The decimal odds the user took at placement.</summary>
|
||||||
|
public decimal Rate { get; set; } = 1.90m;
|
||||||
|
|
||||||
|
public decimal Stake { get; set; } = 100m;
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Upper sanity caps so a typo cannot torch the KPI strip.</summary>
|
||||||
|
public const decimal MaxRate = 1000m;
|
||||||
|
|
||||||
|
/// <summary>Upper sanity cap on a single wager.</summary>
|
||||||
|
public const decimal MaxStake = 10_000_000m;
|
||||||
|
|
||||||
|
public bool IsValid(out string? error)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(EventId)) { error = "EventId is required."; return false; }
|
||||||
|
if (Stake <= 0m) { error = "Stake must be positive."; return false; }
|
||||||
|
if (Stake > MaxStake) { error = $"Stake must be at most {MaxStake:N0}."; return false; }
|
||||||
|
if (Rate < 1.01m) { error = "Rate must be at least 1.01."; return false; }
|
||||||
|
if (Rate > MaxRate) { error = $"Rate must be at most {MaxRate:N0}."; return false; }
|
||||||
|
|
||||||
|
// Mirror Bet invariants — surface a friendly message instead of throwing
|
||||||
|
// ArgumentException deep in the use case.
|
||||||
|
switch (Type)
|
||||||
|
{
|
||||||
|
case BetType.Win:
|
||||||
|
if (Side is not (Side.Side1 or Side.Side2)) { error = "Win bet requires Side1 or Side2."; return false; }
|
||||||
|
break;
|
||||||
|
case BetType.Draw:
|
||||||
|
if (Side != Side.Draw) { error = "Draw bet requires Side = Draw."; return false; }
|
||||||
|
break;
|
||||||
|
case BetType.WinFora:
|
||||||
|
if (Side is not (Side.Side1 or Side.Side2)) { error = "Handicap bet requires Side1 or Side2."; return false; }
|
||||||
|
if (Value is null or 0m) { error = "Handicap bet needs a non-zero threshold."; return false; }
|
||||||
|
break;
|
||||||
|
case BetType.Total:
|
||||||
|
if (Side is not (Side.Less or Side.More)) { error = "Total bet requires Less or More."; return false; }
|
||||||
|
if (Value is null or 0m) { error = "Total bet needs a non-zero threshold."; return false; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Marathon.UI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository-backed implementation of <see cref="IDashboardSummaryService"/>.
|
||||||
|
/// Composes server-side counts (no full-table materialisation) with the top few
|
||||||
|
/// anomaly signals and the worker-toggle state. Scoped — captures the per-circuit
|
||||||
|
/// repository scope like the other browsing services.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DashboardSummaryService : IDashboardSummaryService
|
||||||
|
{
|
||||||
|
private const int LatestSignalCount = 5;
|
||||||
|
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly ISnapshotRepository _snapshots;
|
||||||
|
private readonly IAnomalyRepository _anomalies;
|
||||||
|
private readonly IAnomalyBrowsingService _anomalyBrowsing;
|
||||||
|
private readonly IOptionsMonitor<WorkerOptions> _workers;
|
||||||
|
|
||||||
|
public DashboardSummaryService(
|
||||||
|
IEventRepository events,
|
||||||
|
ISnapshotRepository snapshots,
|
||||||
|
IAnomalyRepository anomalies,
|
||||||
|
IAnomalyBrowsingService anomalyBrowsing,
|
||||||
|
IOptionsMonitor<WorkerOptions> workers)
|
||||||
|
{
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
|
||||||
|
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
|
||||||
|
_anomalyBrowsing = anomalyBrowsing ?? throw new ArgumentNullException(nameof(anomalyBrowsing));
|
||||||
|
_workers = workers ?? throw new ArgumentNullException(nameof(workers));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardSummary> GetAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var todayStart = new DateTimeOffset(MoscowTime.Now.Date, MoscowTime.Offset);
|
||||||
|
|
||||||
|
var eventsTracked = await _events.CountAsync(ct).ConfigureAwait(false);
|
||||||
|
var snapshotsToday = await _snapshots.CountSinceAsync(todayStart, ct).ConfigureAwait(false);
|
||||||
|
// DateTimeOffset.MinValue lower bound counts every row (year dominates the
|
||||||
|
// lexical comparison regardless of offset).
|
||||||
|
var anomaliesTotal = await _anomalies.CountSinceAsync(DateTimeOffset.MinValue, ct).ConfigureAwait(false);
|
||||||
|
var anomaliesToday = await _anomalies.CountSinceAsync(todayStart, ct).ConfigureAwait(false);
|
||||||
|
var sports = await _events.ListDistinctSportCodesAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var latest = anomaliesTotal == 0
|
||||||
|
? (IReadOnlyList<AnomalyListItem>)Array.Empty<AnomalyListItem>()
|
||||||
|
: (await _anomalyBrowsing.ListAsync(new AnomalyFilter(), ct).ConfigureAwait(false))
|
||||||
|
.Take(LatestSignalCount)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var w = _workers.CurrentValue;
|
||||||
|
|
||||||
|
return new DashboardSummary(
|
||||||
|
EventsTracked: eventsTracked,
|
||||||
|
SnapshotsToday: snapshotsToday,
|
||||||
|
AnomaliesTotal: anomaliesTotal,
|
||||||
|
AnomaliesToday: anomaliesToday,
|
||||||
|
SportsCovered: sports.Count,
|
||||||
|
LatestSignals: latest,
|
||||||
|
ScheduleStatus: StageStatus(w.UpcomingPollerEnabled, eventsTracked > 0),
|
||||||
|
SnapshotStatus: StageStatus(w.LivePollerEnabled, snapshotsToday > 0),
|
||||||
|
DetectorStatus: StageStatus(w.AnomalyDetectionEnabled, anomaliesTotal > 0),
|
||||||
|
ExportStatus: eventsTracked > 0 ? "ok" : "idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps a worker stage to the PipelineStep token: disabled → idle, enabled but
|
||||||
|
// not yet producing → warn (waiting), enabled and producing → ok.
|
||||||
|
private static string StageStatus(bool enabled, bool producing) =>
|
||||||
|
!enabled ? "idle" : producing ? "ok" : "warn";
|
||||||
|
}
|
||||||
@@ -83,15 +83,17 @@ public sealed class EventBrowsingService : IEventBrowsingService
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(filter);
|
ArgumentNullException.ThrowIfNull(filter);
|
||||||
|
|
||||||
var range = new DateRange(filter.Dates.From, filter.Dates.To);
|
// Date range + sport filter pushed to SQL (was: load the whole date range,
|
||||||
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
|
// then filter sports in memory). Country/search filtering and locale-aware
|
||||||
|
// sorting stay here to preserve the Cyrillic ordinal semantics that SQLite's
|
||||||
|
// BINARY collation would change.
|
||||||
|
var query = new EventQuery(
|
||||||
|
new DateRange(filter.Dates.From, filter.Dates.To),
|
||||||
|
filter.SportCodes);
|
||||||
|
var events = await _events.QueryAsync(query, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
// Apply non-temporal filters in-memory — list size is small (UI page).
|
|
||||||
IEnumerable<Event> filtered = events;
|
IEnumerable<Event> filtered = events;
|
||||||
|
|
||||||
if (filter.SportCodes is { Count: > 0 } sports)
|
|
||||||
filtered = filtered.Where(e => sports.Contains(e.Sport.Value));
|
|
||||||
|
|
||||||
if (filter.CountryCodes is { Count: > 0 } countries)
|
if (filter.CountryCodes is { Count: > 0 } countries)
|
||||||
filtered = filtered.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase));
|
filtered = filtered.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Marathon.UI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Browsing facade in front of <see cref="Marathon.Application.UseCases.EvaluateAnomalyOutcomesUseCase"/>.
|
||||||
|
/// The Insights page binds to this — never to the use case directly — so the
|
||||||
|
/// per-row event-title join, severity bucketing, and any future caching live
|
||||||
|
/// in one place.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAnomalyInsightsService
|
||||||
|
{
|
||||||
|
/// <summary>Builds the full report and projects it for the UI.</summary>
|
||||||
|
Task<AnomalyInsightsVm> GetReportAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace Marathon.UI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Browsing facade over the bet-journal use cases. The Journal page binds to
|
||||||
|
/// this — never the use cases directly — so view-model shaping, event-title
|
||||||
|
/// joining, and validation surface in one place.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBetJournalService
|
||||||
|
{
|
||||||
|
/// <summary>Builds the full report and projects it for the UI.</summary>
|
||||||
|
Task<BetJournalVm> GetReportAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates and persists a manually entered bet. Returns the newly stored
|
||||||
|
/// row's id. Throws <see cref="InvalidOperationException"/> when the form
|
||||||
|
/// validates but references an unknown event.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
|
||||||
|
Task<Guid> AddAsync(AddBetForm form, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Removes a bet by id. No-op when the id is unknown.</summary>
|
||||||
|
Task DeleteAsync(Guid betId, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sweeps pending bets and grades the ones whose events are now resolved.
|
||||||
|
/// Returns the count graded in this pass.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> ResolvePendingAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upcoming (and recently-started) events for the bet-entry autocomplete,
|
||||||
|
/// ordered by kickoff. Loaded once by the page; filtered client-side per keystroke.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<EventOption>> GetUpcomingEventOptionsAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A selectable event for the bet-journal "find event" autocomplete.</summary>
|
||||||
|
/// <param name="Id">The bookmaker event code (what the form's Event ID expects).</param>
|
||||||
|
/// <param name="Label">Display text: "Home vs Away · yyyy-MM-dd HH:mm".</param>
|
||||||
|
/// <param name="ScheduledAt">Kickoff, for ordering.</param>
|
||||||
|
public sealed record EventOption(string Id, string Label, DateTimeOffset ScheduledAt);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace Marathon.UI.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live figures for the dashboard (Home) page: real counts, the most recent
|
||||||
|
/// anomaly signals, and a per-stage capture-pipeline health read derived from the
|
||||||
|
/// worker toggles + whether each stage is actually producing data.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDashboardSummaryService
|
||||||
|
{
|
||||||
|
Task<DashboardSummary> GetAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of dashboard figures. <see cref="HasAnyData"/> drives the first-run
|
||||||
|
/// empty state. Pipeline statuses use the PipelineStep token vocabulary
|
||||||
|
/// (<c>ok</c> / <c>warn</c> / <c>idle</c> / <c>error</c>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DashboardSummary(
|
||||||
|
int EventsTracked,
|
||||||
|
int SnapshotsToday,
|
||||||
|
int AnomaliesTotal,
|
||||||
|
int AnomaliesToday,
|
||||||
|
int SportsCovered,
|
||||||
|
IReadOnlyList<AnomalyListItem> LatestSignals,
|
||||||
|
string ScheduleStatus,
|
||||||
|
string SnapshotStatus,
|
||||||
|
string DetectorStatus,
|
||||||
|
string ExportStatus)
|
||||||
|
{
|
||||||
|
/// <summary>True once anything has been captured — gates the welcome/empty state.</summary>
|
||||||
|
public bool HasAnyData => EventsTracked > 0 || AnomaliesTotal > 0;
|
||||||
|
|
||||||
|
/// <summary>An empty summary used as the initial render state before data loads.</summary>
|
||||||
|
public static DashboardSummary Empty { get; } = new(
|
||||||
|
EventsTracked: 0,
|
||||||
|
SnapshotsToday: 0,
|
||||||
|
AnomaliesTotal: 0,
|
||||||
|
AnomaliesToday: 0,
|
||||||
|
SportsCovered: 0,
|
||||||
|
LatestSignals: Array.Empty<AnomalyListItem>(),
|
||||||
|
ScheduleStatus: "idle",
|
||||||
|
SnapshotStatus: "idle",
|
||||||
|
DetectorStatus: "idle",
|
||||||
|
ExportStatus: "idle");
|
||||||
|
}
|
||||||
@@ -57,7 +57,11 @@ public static class UiServicesExtensions
|
|||||||
// Browsing facades — Scoped so they capture the per-circuit repository scope.
|
// Browsing facades — Scoped so they capture the per-circuit repository scope.
|
||||||
services.AddScoped<IEventBrowsingService, EventBrowsingService>();
|
services.AddScoped<IEventBrowsingService, EventBrowsingService>();
|
||||||
services.AddScoped<IAnomalyBrowsingService, AnomalyBrowsingService>();
|
services.AddScoped<IAnomalyBrowsingService, AnomalyBrowsingService>();
|
||||||
|
services.AddScoped<IAnomalyInsightsService, AnomalyInsightsService>();
|
||||||
services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>();
|
services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>();
|
||||||
|
services.AddScoped<IBetJournalService, BetJournalService>();
|
||||||
|
services.AddScoped<IBacktestService, BacktestService>();
|
||||||
|
services.AddScoped<IDashboardSummaryService, DashboardSummaryService>();
|
||||||
|
|
||||||
// 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,96 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Application.Betting;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Tests.Betting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="ClosingLineValueCalculator"/> covering the math
|
||||||
|
/// itself and the snapshot-matching path used by the report use case.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClosingLineValueCalculatorTests
|
||||||
|
{
|
||||||
|
private static readonly EventId EventId = new("11111111");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_Should_ReturnPositive_When_TakenRate_BeatsClose()
|
||||||
|
{
|
||||||
|
// Taken 2.20 (implied 0.4545); closed 2.00 (implied 0.5000) → CLV = +0.0455
|
||||||
|
var clv = ClosingLineValueCalculator.Compute(takenRate: 2.20m, closingRate: 2.00m);
|
||||||
|
|
||||||
|
clv.Should().BeGreaterThan(0m);
|
||||||
|
clv.Should().BeApproximately(0.04545m, 0.00001m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_Should_ReturnNegative_When_TakenRate_WorseThanClose()
|
||||||
|
{
|
||||||
|
// Taken 1.80 (0.5556); closed 2.00 (0.5000) → CLV = -0.0556
|
||||||
|
var clv = ClosingLineValueCalculator.Compute(takenRate: 1.80m, closingRate: 2.00m);
|
||||||
|
|
||||||
|
clv.Should().BeLessThan(0m);
|
||||||
|
clv.Should().BeApproximately(-0.05556m, 0.00001m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_Should_ReturnZero_When_RatesMatch()
|
||||||
|
{
|
||||||
|
ClosingLineValueCalculator.Compute(2.00m, 2.00m).Should().Be(0m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
public void Compute_Should_Throw_When_AnyRateIsZeroOrNegative(decimal rate)
|
||||||
|
{
|
||||||
|
((Action)(() => ClosingLineValueCalculator.Compute(rate, 2m)))
|
||||||
|
.Should().Throw<ArgumentOutOfRangeException>();
|
||||||
|
((Action)(() => ClosingLineValueCalculator.Compute(2m, rate)))
|
||||||
|
.Should().Throw<ArgumentOutOfRangeException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompute_Should_ReturnNull_When_NoMatchingBetInSnapshot()
|
||||||
|
{
|
||||||
|
var taken = new Bet(MatchScope.Instance, BetType.Win, Side.Side1,
|
||||||
|
value: null, new OddsRate(2.20m));
|
||||||
|
|
||||||
|
// Snapshot contains only Side2 — no match for Side1 Win.
|
||||||
|
var snapshot = new OddsSnapshot(EventId, DateTimeOffset.UtcNow, OddsSource.PreMatch,
|
||||||
|
new[] { new Bet(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(1.70m)) });
|
||||||
|
|
||||||
|
var clv = ClosingLineValueCalculator.TryCompute(2.20m, taken, snapshot);
|
||||||
|
|
||||||
|
clv.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompute_Should_ReturnNull_When_SnapshotIsNull()
|
||||||
|
{
|
||||||
|
var taken = new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m));
|
||||||
|
ClosingLineValueCalculator.TryCompute(2m, taken, closingSnapshot: null).Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompute_Should_MatchOnScopeTypeSideAndValue()
|
||||||
|
{
|
||||||
|
// Two handicap markets with different thresholds — pick the right one.
|
||||||
|
var taken = new Bet(MatchScope.Instance, BetType.WinFora, Side.Side1,
|
||||||
|
new OddsValue(-1.5m), new OddsRate(2.20m));
|
||||||
|
|
||||||
|
var snapshot = new OddsSnapshot(EventId, DateTimeOffset.UtcNow, OddsSource.PreMatch,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new Bet(MatchScope.Instance, BetType.WinFora, Side.Side1,
|
||||||
|
new OddsValue(-2.5m), new OddsRate(3.50m)), // wrong threshold
|
||||||
|
new Bet(MatchScope.Instance, BetType.WinFora, Side.Side1,
|
||||||
|
new OddsValue(-1.5m), new OddsRate(2.00m)), // match
|
||||||
|
});
|
||||||
|
|
||||||
|
var clv = ClosingLineValueCalculator.TryCompute(2.20m, taken, snapshot);
|
||||||
|
|
||||||
|
clv.Should().BeApproximately(0.04545m, 0.00001m);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.UseCases;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Tests.UseCases;
|
||||||
|
|
||||||
|
public sealed class BuildBetJournalReportUseCaseTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
private static readonly DateTimeOffset Placed = new(2026, 5, 16, 12, 0, 0, MoscowOffset);
|
||||||
|
private static readonly DateTimeOffset Kickoff = new(2026, 5, 16, 18, 0, 0, MoscowOffset);
|
||||||
|
|
||||||
|
private readonly IPlacedBetRepository _bets = Substitute.For<IPlacedBetRepository>();
|
||||||
|
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||||
|
private readonly ISnapshotRepository _snapshots = Substitute.For<ISnapshotRepository>();
|
||||||
|
|
||||||
|
public BuildBetJournalReportUseCaseTests()
|
||||||
|
{
|
||||||
|
// Use case batches event loads via GetManyAsync; route through per-id stubs.
|
||||||
|
TestFixtures.BridgeGetMany(_events);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BuildBetJournalReportUseCase CreateSut() =>
|
||||||
|
new(_bets, _events, _snapshots, NullLogger<BuildBetJournalReportUseCase>.Instance);
|
||||||
|
|
||||||
|
private static PlacedBet MakeBet(
|
||||||
|
EventId id,
|
||||||
|
BetOutcome outcome,
|
||||||
|
Side side = Side.Side1,
|
||||||
|
decimal stake = 100m,
|
||||||
|
decimal rate = 2.10m) =>
|
||||||
|
new(
|
||||||
|
Guid.NewGuid(), id,
|
||||||
|
new Bet(MatchScope.Instance, BetType.Win, side, null, new OddsRate(rate)),
|
||||||
|
stake, Placed, outcome, null);
|
||||||
|
|
||||||
|
private static Event MakeEvent(EventId id, DateTimeOffset scheduledAt) =>
|
||||||
|
new(id, new SportCode(11), "BY", "L1", "Cat", scheduledAt, "Team A", "Team B");
|
||||||
|
|
||||||
|
private static OddsSnapshot MakeSnapshot(EventId id, DateTimeOffset at, decimal rateSide1) =>
|
||||||
|
new(id, at, OddsSource.PreMatch,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(rateSide1)),
|
||||||
|
new Bet(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(2.00m)),
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_ReturnEmptyReport_When_NoBets()
|
||||||
|
{
|
||||||
|
_bets.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<PlacedBet>().ToList().AsReadOnly());
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.Bets.Should().BeEmpty();
|
||||||
|
report.Stats.TotalBets.Should().Be(0);
|
||||||
|
report.Stats.RoiPercent.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_AggregateStats_AcrossMixedOutcomes()
|
||||||
|
{
|
||||||
|
var id1 = new EventId("e-1");
|
||||||
|
var id2 = new EventId("e-2");
|
||||||
|
var id3 = new EventId("e-3");
|
||||||
|
|
||||||
|
_bets.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[]
|
||||||
|
{
|
||||||
|
MakeBet(id1, BetOutcome.Won, stake: 100m, rate: 2.00m), // gross 200, +100
|
||||||
|
MakeBet(id2, BetOutcome.Lost, stake: 100m, rate: 2.00m), // gross 0, -100
|
||||||
|
MakeBet(id3, BetOutcome.Pending),
|
||||||
|
}.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
// Wire events so the report can compute CLV (we don't need actual CLV here — leave snapshots empty).
|
||||||
|
foreach (var id in new[] { id1, id2, id3 })
|
||||||
|
{
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, Kickoff));
|
||||||
|
_snapshots.GetLatestPreMatchAsync(id, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns((OddsSnapshot?)null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.Stats.TotalBets.Should().Be(3);
|
||||||
|
report.Stats.PendingCount.Should().Be(1);
|
||||||
|
report.Stats.WonCount.Should().Be(1);
|
||||||
|
report.Stats.LostCount.Should().Be(1);
|
||||||
|
report.Stats.TotalStaked.Should().Be(200m, "pending bets are excluded from totals");
|
||||||
|
report.Stats.TotalReturned.Should().Be(200m);
|
||||||
|
report.Stats.NetProfit.Should().Be(0m);
|
||||||
|
report.Stats.RoiPercent.Should().Be(0m);
|
||||||
|
report.Stats.StrikeRatePercent.Should().Be(50m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_ComputeClv_AgainstClosingSnapshot()
|
||||||
|
{
|
||||||
|
var id = new EventId("clv-event");
|
||||||
|
var bet = MakeBet(id, BetOutcome.Won, rate: 2.20m, stake: 100m);
|
||||||
|
|
||||||
|
_bets.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { bet }.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, Kickoff));
|
||||||
|
|
||||||
|
// Closing snapshot returned by the dedicated repo method.
|
||||||
|
_snapshots.GetLatestPreMatchAsync(id, Kickoff, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(MakeSnapshot(id, Kickoff.AddMinutes(-5), rateSide1: 2.00m));
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.Bets.Should().HaveCount(1);
|
||||||
|
report.Bets[0].ClvProbabilityDelta.Should().NotBeNull();
|
||||||
|
// taken 2.20 vs closing 2.00 → +0.04545
|
||||||
|
report.Bets[0].ClvProbabilityDelta!.Value.Should().BeApproximately(0.04545m, 0.00001m);
|
||||||
|
report.Stats.AverageClvProbabilityDelta.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_LeaveClvNull_When_NoClosingSnapshotAvailable()
|
||||||
|
{
|
||||||
|
var id = new EventId("no-close");
|
||||||
|
_bets.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { MakeBet(id, BetOutcome.Won) }.ToList().AsReadOnly());
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, Kickoff));
|
||||||
|
_snapshots.GetLatestPreMatchAsync(id, Kickoff, Arg.Any<CancellationToken>())
|
||||||
|
.Returns((OddsSnapshot?)null);
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.Bets[0].ClvProbabilityDelta.Should().BeNull();
|
||||||
|
report.Stats.AverageClvProbabilityDelta.Should().BeNull(
|
||||||
|
"no rows had a computable CLV — average is undefined");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_ExcludeVoidStakes_FromRoiTurnover()
|
||||||
|
{
|
||||||
|
// 1 Won (+100), 1 Lost (-100), 1 Void (stake returned). Industry-standard
|
||||||
|
// ROI excludes pushes from turnover, so total staked = 200, returned 200,
|
||||||
|
// net 0, ROI 0%. If voids were included turnover would be 300 → ROI ≈ 0%
|
||||||
|
// numerator but inflated denominator semantics.
|
||||||
|
var ids = Enumerable.Range(1, 3)
|
||||||
|
.Select(i => new EventId($"void-{i}")).ToArray();
|
||||||
|
|
||||||
|
_bets.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[]
|
||||||
|
{
|
||||||
|
MakeBet(ids[0], BetOutcome.Won, stake: 100m, rate: 2.00m),
|
||||||
|
MakeBet(ids[1], BetOutcome.Lost, stake: 100m, rate: 2.00m),
|
||||||
|
MakeBet(ids[2], BetOutcome.Void, stake: 100m, rate: 2.00m),
|
||||||
|
}.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, Kickoff));
|
||||||
|
_snapshots.GetLatestPreMatchAsync(id, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns((OddsSnapshot?)null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.Stats.VoidCount.Should().Be(1);
|
||||||
|
report.Stats.TotalStaked.Should().Be(200m,
|
||||||
|
"void bets are pushes — the stake was returned and should not count as turnover");
|
||||||
|
report.Stats.TotalReturned.Should().Be(200m);
|
||||||
|
report.Stats.NetProfit.Should().Be(0m);
|
||||||
|
report.Stats.RoiPercent.Should().Be(0m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_OrderBets_NewestPlacedFirst()
|
||||||
|
{
|
||||||
|
var ids = Enumerable.Range(0, 3).Select(i => new EventId($"ord-{i}")).ToArray();
|
||||||
|
var older = new DateTimeOffset(2026, 5, 10, 12, 0, 0, MoscowOffset);
|
||||||
|
var newer = new DateTimeOffset(2026, 5, 16, 12, 0, 0, MoscowOffset);
|
||||||
|
|
||||||
|
// Bet 0 is the middle one, bet 1 oldest, bet 2 newest.
|
||||||
|
var b0 = new PlacedBet(Guid.NewGuid(), ids[0],
|
||||||
|
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
|
||||||
|
100m, older.AddDays(1), BetOutcome.Won, null);
|
||||||
|
var b1 = new PlacedBet(Guid.NewGuid(), ids[1],
|
||||||
|
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
|
||||||
|
100m, older, BetOutcome.Lost, null);
|
||||||
|
var b2 = new PlacedBet(Guid.NewGuid(), ids[2],
|
||||||
|
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
|
||||||
|
100m, newer, BetOutcome.Pending, null);
|
||||||
|
|
||||||
|
_bets.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { b0, b1, b2 }.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, Kickoff));
|
||||||
|
_snapshots.GetLatestPreMatchAsync(id, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns((OddsSnapshot?)null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.Bets.Select(r => r.Bet.Id).Should().ContainInOrder(b2.Id, b0.Id, b1.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Reporting;
|
||||||
|
using Marathon.Application.UseCases;
|
||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
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>
|
||||||
|
/// Unit tests for <see cref="EvaluateAnomalyOutcomesUseCase"/> covering empty
|
||||||
|
/// state, mixed hit/miss aggregation, unresolved partitioning, and missing
|
||||||
|
/// event metadata fallbacks.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EvaluateAnomalyOutcomesUseCaseTests
|
||||||
|
{
|
||||||
|
private readonly IAnomalyRepository _anomalies = Substitute.For<IAnomalyRepository>();
|
||||||
|
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||||
|
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
|
||||||
|
|
||||||
|
public EvaluateAnomalyOutcomesUseCaseTests()
|
||||||
|
{
|
||||||
|
// Use cases batch event/result loads via GetManyAsync; route those through
|
||||||
|
// the per-id GetAsync stubs each test already configures.
|
||||||
|
TestFixtures.BridgeGetMany(_events);
|
||||||
|
TestFixtures.BridgeGetMany(_results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
private static readonly DateTimeOffset BaseTime =
|
||||||
|
new(2026, 5, 10, 18, 0, 0, MoscowOffset);
|
||||||
|
|
||||||
|
// Flip evidence with Side1 → Side2 reversal.
|
||||||
|
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 EvaluateAnomalyOutcomesUseCase CreateSut() =>
|
||||||
|
new(_anomalies, _events, _results,
|
||||||
|
NullLogger<EvaluateAnomalyOutcomesUseCase>.Instance);
|
||||||
|
|
||||||
|
private static Anomaly MakeAnomaly(EventId eventId, decimal score) =>
|
||||||
|
new(Guid.NewGuid(), eventId, BaseTime, AnomalyKind.SuspensionFlip,
|
||||||
|
score, FlipEvidence);
|
||||||
|
|
||||||
|
private static Event MakeEvent(EventId id, int sportCode) =>
|
||||||
|
new(id, new SportCode(sportCode), "BY", "L1", "Cat",
|
||||||
|
BaseTime, "Team A", "Team B");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_ReturnEmptyReport_When_NoAnomaliesExist()
|
||||||
|
{
|
||||||
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.TotalAnomalies.Should().Be(0);
|
||||||
|
report.HitRate.Should().BeNull();
|
||||||
|
report.Resolved.Should().BeEmpty();
|
||||||
|
report.BySport.Should().BeEmpty();
|
||||||
|
report.BySeverity.Should().BeEmpty();
|
||||||
|
report.ByScoreBin.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_PartitionAnomalies_Into_ResolvedAndUnresolved()
|
||||||
|
{
|
||||||
|
var id1 = new EventId("11111111");
|
||||||
|
var id2 = new EventId("22222222");
|
||||||
|
|
||||||
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[]
|
||||||
|
{
|
||||||
|
MakeAnomaly(id1, score: 0.65m),
|
||||||
|
MakeAnomaly(id2, score: 0.40m),
|
||||||
|
}.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
_events.GetAsync(id1, Arg.Any<CancellationToken>()).Returns(MakeEvent(id1, 11));
|
||||||
|
_events.GetAsync(id2, Arg.Any<CancellationToken>()).Returns(MakeEvent(id2, 6));
|
||||||
|
|
||||||
|
// id1 has a result → resolved; id2 has no result → unresolved.
|
||||||
|
_results.GetAsync(id1, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(id1, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||||
|
_results.GetAsync(id2, Arg.Any<CancellationToken>())
|
||||||
|
.Returns((EventResult?)null);
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.TotalAnomalies.Should().Be(2);
|
||||||
|
report.ResolvedCount.Should().Be(1);
|
||||||
|
report.UnresolvedCount.Should().Be(1);
|
||||||
|
report.HitCount.Should().Be(1, "id1's post-flip favourite (Side2) matched the actual winner");
|
||||||
|
report.MissCount.Should().Be(0);
|
||||||
|
report.HitRate.Should().Be(1.0m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_ComputeHitRate_Across_MixedHitsAndMisses()
|
||||||
|
{
|
||||||
|
var ids = Enumerable.Range(1, 4)
|
||||||
|
.Select(i => new EventId($"event-{i:00000000}"))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ids.Select(id => MakeAnomaly(id, score: 0.55m)).ToList().AsReadOnly());
|
||||||
|
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three hits (Side2 wins), one miss (Side1 wins).
|
||||||
|
_results.GetAsync(ids[0], Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(ids[0], 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||||
|
_results.GetAsync(ids[1], Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(ids[1], 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||||
|
_results.GetAsync(ids[2], Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(ids[2], 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||||
|
_results.GetAsync(ids[3], Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(ids[3], 2, 0, Side.Side1, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.HitCount.Should().Be(3);
|
||||||
|
report.MissCount.Should().Be(1);
|
||||||
|
report.HitRate.Should().Be(0.75m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_BuildSeverityBuckets_Across_LowMediumHigh()
|
||||||
|
{
|
||||||
|
var idLow = new EventId("low000000");
|
||||||
|
var idMed = new EventId("med000000");
|
||||||
|
var idHigh = new EventId("high00000");
|
||||||
|
|
||||||
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[]
|
||||||
|
{
|
||||||
|
MakeAnomaly(idLow, score: 0.35m),
|
||||||
|
MakeAnomaly(idMed, score: 0.50m),
|
||||||
|
MakeAnomaly(idHigh, score: 0.75m),
|
||||||
|
}.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
foreach (var id in new[] { idLow, idMed, idHigh })
|
||||||
|
{
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||||
|
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.BySeverity.Should().HaveCount(3);
|
||||||
|
report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityLow).Total.Should().Be(1);
|
||||||
|
report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityMedium).Total.Should().Be(1);
|
||||||
|
report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityHigh).Total.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_GroupBySport_When_AnomaliesSpanMultipleSports()
|
||||||
|
{
|
||||||
|
var idFb = new EventId("fb000000");
|
||||||
|
var idBb = new EventId("bb000000");
|
||||||
|
|
||||||
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[]
|
||||||
|
{
|
||||||
|
MakeAnomaly(idFb, score: 0.55m),
|
||||||
|
MakeAnomaly(idBb, score: 0.55m),
|
||||||
|
}.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
_events.GetAsync(idFb, Arg.Any<CancellationToken>()).Returns(MakeEvent(idFb, 11));
|
||||||
|
_events.GetAsync(idBb, Arg.Any<CancellationToken>()).Returns(MakeEvent(idBb, 6));
|
||||||
|
|
||||||
|
_results.GetAsync(idFb, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(idFb, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||||
|
_results.GetAsync(idBb, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(idBb, 2, 0, Side.Side1, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.BySport.Select(b => b.Key)
|
||||||
|
.Should().BeEquivalentTo(new[] { "Sport.6", "Sport.11" });
|
||||||
|
report.BySport.Single(b => b.Key == "Sport.11").HitRate.Should().Be(1.0m);
|
||||||
|
report.BySport.Single(b => b.Key == "Sport.6").HitRate.Should().Be(0.0m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_BuildSevenScoreBins_With_CanonicalKeys()
|
||||||
|
{
|
||||||
|
var id = new EventId("score000");
|
||||||
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { MakeAnomaly(id, score: 0.95m) }.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||||
|
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.ByScoreBin.Should().HaveCount(7, "default buckets cover [0.30, 1.00] in 0.10-wide bins");
|
||||||
|
report.ByScoreBin.Select(b => b.Key).Should().BeEquivalentTo(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
"Bin.0.30-0.40", "Bin.0.40-0.50", "Bin.0.50-0.60", "Bin.0.60-0.70",
|
||||||
|
"Bin.0.70-0.80", "Bin.0.80-0.90", "Bin.0.90-1.00",
|
||||||
|
},
|
||||||
|
options => options.WithStrictOrdering(),
|
||||||
|
"the page reads these literals to render labels");
|
||||||
|
report.ByScoreBin.Last().Total.Should().Be(1, "score 0.95 should land in the [0.90, 1.00] bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.30, "Bin.0.30-0.40")]
|
||||||
|
[InlineData(0.40, "Bin.0.40-0.50")]
|
||||||
|
[InlineData(0.5999, "Bin.0.50-0.60")]
|
||||||
|
[InlineData(0.60, "Bin.0.60-0.70")]
|
||||||
|
[InlineData(1.00, "Bin.0.90-1.00")]
|
||||||
|
public async Task Should_PlaceScore_InCorrectBin_AtBoundary(double scoreDouble, string expectedKey)
|
||||||
|
{
|
||||||
|
var score = (decimal)scoreDouble;
|
||||||
|
var id = new EventId("boundary");
|
||||||
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { MakeAnomaly(id, score) }.ToList().AsReadOnly());
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||||
|
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var bin = report.ByScoreBin.Single(b => b.Total == 1);
|
||||||
|
bin.Key.Should().Be(expectedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_ExtendScoreBinsBelow_When_DetectorThresholdIsLowered()
|
||||||
|
{
|
||||||
|
// Operator lowered Anomaly.OddsFlipThreshold to 0.10 → anomalies with
|
||||||
|
// score 0.15 exist. The histogram must still account for them.
|
||||||
|
var idLow = new EventId("lowscore");
|
||||||
|
var idHigh = new EventId("hicscore");
|
||||||
|
|
||||||
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[]
|
||||||
|
{
|
||||||
|
MakeAnomaly(idLow, score: 0.15m),
|
||||||
|
MakeAnomaly(idHigh, score: 0.85m),
|
||||||
|
}.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
foreach (var id in new[] { idLow, idHigh })
|
||||||
|
{
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||||
|
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.ByScoreBin.Sum(b => b.Total).Should().Be(report.ResolvedCount,
|
||||||
|
"the histogram total must equal ResolvedCount regardless of detector tuning");
|
||||||
|
report.ByScoreBin.First().Key.Should().Be("Bin.0.10-0.20",
|
||||||
|
"buckets are extended downward to include the lowest observed score");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_PopulateEventTitles_ForJoinedEvents()
|
||||||
|
{
|
||||||
|
var id = new EventId("title000");
|
||||||
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { MakeAnomaly(id, 0.55m) }.ToList().AsReadOnly());
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
|
||||||
|
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.EventTitles.Should().ContainKey(id);
|
||||||
|
report.EventTitles[id].Should().Be("Team A vs Team B");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_BatchEventAndResultLoads_InsteadOfPerIdGetAsync()
|
||||||
|
{
|
||||||
|
// Regression guard for the N+1 fix: the use case must resolve events/results
|
||||||
|
// via the batched GetManyAsync, never the per-id GetAsync in a loop. We stub
|
||||||
|
// GetManyAsync directly (overriding the constructor bridge) so DidNotReceive()
|
||||||
|
// on GetAsync is meaningful.
|
||||||
|
var id1 = new EventId("11111111");
|
||||||
|
var id2 = new EventId("22222222");
|
||||||
|
|
||||||
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { MakeAnomaly(id1, 0.55m), MakeAnomaly(id2, 0.55m) }.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new Dictionary<EventId, Event> { [id1] = MakeEvent(id1, 11), [id2] = MakeEvent(id2, 6) });
|
||||||
|
_results.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new Dictionary<EventId, EventResult>());
|
||||||
|
|
||||||
|
await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await _events.Received(1)
|
||||||
|
.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>());
|
||||||
|
await _events.DidNotReceive()
|
||||||
|
.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>());
|
||||||
|
await _results.Received(1)
|
||||||
|
.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>());
|
||||||
|
await _results.DidNotReceive()
|
||||||
|
.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_HandleMissingEvent_By_OmittingFromSportBuckets()
|
||||||
|
{
|
||||||
|
var id = new EventId("orphan00");
|
||||||
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { MakeAnomaly(id, score: 0.55m) }.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns((Event?)null);
|
||||||
|
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
report.Resolved.Should().HaveCount(1,
|
||||||
|
"orphan anomalies are still evaluated for hit/miss");
|
||||||
|
report.BySport.Should().BeEmpty(
|
||||||
|
"missing event metadata excludes the row from sport breakdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@ public sealed class PullResultsUseCaseTests
|
|||||||
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
|
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
|
||||||
private readonly IResultRepository _resultRepo = Substitute.For<IResultRepository>();
|
private readonly IResultRepository _resultRepo = Substitute.For<IResultRepository>();
|
||||||
|
|
||||||
|
public PullResultsUseCaseTests()
|
||||||
|
{
|
||||||
|
// Selection-mode candidate resolution now batches via GetManyAsync; route
|
||||||
|
// it through the per-id GetAsync stubs each test configures.
|
||||||
|
TestFixtures.BridgeGetMany(_eventRepo);
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly DateRange AnyRange = new(
|
private static readonly DateRange AnyRange = new(
|
||||||
DateTimeOffset.UtcNow.AddDays(-1),
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
DateTimeOffset.UtcNow);
|
DateTimeOffset.UtcNow);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.UseCases;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Tests.UseCases;
|
||||||
|
|
||||||
|
public sealed class RecordPlacedBetUseCaseTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
|
||||||
|
private readonly IPlacedBetRepository _bets = Substitute.For<IPlacedBetRepository>();
|
||||||
|
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||||
|
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
|
||||||
|
|
||||||
|
private RecordPlacedBetUseCase CreateSut() =>
|
||||||
|
new(_bets, _events, _results, NullLogger<RecordPlacedBetUseCase>.Instance);
|
||||||
|
|
||||||
|
private static PlacedBet MakePending(EventId id, Side side = Side.Side1) =>
|
||||||
|
new(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: id,
|
||||||
|
Selection: new Bet(MatchScope.Instance, BetType.Win, side, null, new OddsRate(2.10m)),
|
||||||
|
Stake: 100m,
|
||||||
|
PlacedAt: new DateTimeOffset(2026, 5, 16, 12, 0, 0, MoscowOffset),
|
||||||
|
Outcome: BetOutcome.Pending,
|
||||||
|
Notes: null);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_Throw_When_EventDoesNotExist()
|
||||||
|
{
|
||||||
|
var id = new EventId("missing");
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns((Event?)null);
|
||||||
|
|
||||||
|
var act = async () => await CreateSut().ExecuteAsync(MakePending(id), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*unknown event*");
|
||||||
|
await _bets.DidNotReceive().AddAsync(Arg.Any<PlacedBet>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_PersistPending_When_NoResultYet()
|
||||||
|
{
|
||||||
|
var id = new EventId("event001");
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(TestFixtures.MakeEvent(id.Value));
|
||||||
|
_results.GetAsync(id, Arg.Any<CancellationToken>()).Returns((EventResult?)null);
|
||||||
|
|
||||||
|
var bet = MakePending(id);
|
||||||
|
|
||||||
|
var stored = await CreateSut().ExecuteAsync(bet, CancellationToken.None);
|
||||||
|
|
||||||
|
stored.Outcome.Should().Be(BetOutcome.Pending, "no result yet — should remain pending");
|
||||||
|
await _bets.Received(1).AddAsync(Arg.Any<PlacedBet>(), Arg.Any<CancellationToken>());
|
||||||
|
await _bets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_AutoGrade_When_ResultAlreadyAvailable()
|
||||||
|
{
|
||||||
|
var id = new EventId("event002");
|
||||||
|
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(TestFixtures.MakeEvent(id.Value));
|
||||||
|
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(id, 2, 1, Side.Side1, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
var bet = MakePending(id, side: Side.Side1);
|
||||||
|
|
||||||
|
var stored = await CreateSut().ExecuteAsync(bet, CancellationToken.None);
|
||||||
|
|
||||||
|
stored.Outcome.Should().Be(BetOutcome.Won, "Side1 was selected and Side1 won");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.UseCases;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Tests.UseCases;
|
||||||
|
|
||||||
|
public sealed class ResolvePendingBetsUseCaseTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
private static readonly DateTimeOffset Placed =
|
||||||
|
new(2026, 5, 16, 12, 0, 0, MoscowOffset);
|
||||||
|
|
||||||
|
private readonly IPlacedBetRepository _bets = Substitute.For<IPlacedBetRepository>();
|
||||||
|
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
|
||||||
|
|
||||||
|
private ResolvePendingBetsUseCase CreateSut() =>
|
||||||
|
new(_bets, _results, NullLogger<ResolvePendingBetsUseCase>.Instance);
|
||||||
|
|
||||||
|
private static PlacedBet MakePending(EventId id, Side side = Side.Side1) =>
|
||||||
|
new(
|
||||||
|
Guid.NewGuid(), id,
|
||||||
|
new Bet(MatchScope.Instance, BetType.Win, side, null, new OddsRate(2.10m)),
|
||||||
|
100m, Placed, BetOutcome.Pending, null);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_ReturnZero_When_NoPendingBets()
|
||||||
|
{
|
||||||
|
_bets.ListByOutcomeAsync(BetOutcome.Pending, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<PlacedBet>().ToList().AsReadOnly());
|
||||||
|
|
||||||
|
var count = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
count.Should().Be(0);
|
||||||
|
await _bets.DidNotReceive().UpdateAsync(Arg.Any<PlacedBet>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_GradeBetsWithResults_AndLeaveOthersAlone()
|
||||||
|
{
|
||||||
|
var idGraded = new EventId("event-1");
|
||||||
|
var idUngraded = new EventId("event-2");
|
||||||
|
|
||||||
|
_bets.ListByOutcomeAsync(BetOutcome.Pending, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[]
|
||||||
|
{
|
||||||
|
MakePending(idGraded, side: Side.Side1),
|
||||||
|
MakePending(idUngraded, side: Side.Side2),
|
||||||
|
}.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
_results.GetAsync(idGraded, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(idGraded, 2, 1, Side.Side1, DateTimeOffset.UtcNow));
|
||||||
|
_results.GetAsync(idUngraded, Arg.Any<CancellationToken>())
|
||||||
|
.Returns((EventResult?)null);
|
||||||
|
|
||||||
|
var count = await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
count.Should().Be(1, "only event-1 has a result");
|
||||||
|
await _bets.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<PlacedBet>(b => b.EventId == idGraded && b.Outcome == BetOutcome.Won),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
await _bets.DidNotReceive().UpdateAsync(
|
||||||
|
Arg.Is<PlacedBet>(b => b.EventId == idUngraded),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_CacheResultLookups_PerEvent()
|
||||||
|
{
|
||||||
|
// Two pending bets on the same event — only one result fetch should fire.
|
||||||
|
var id = new EventId("event-shared");
|
||||||
|
|
||||||
|
_bets.ListByOutcomeAsync(BetOutcome.Pending, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[]
|
||||||
|
{
|
||||||
|
MakePending(id, side: Side.Side1),
|
||||||
|
MakePending(id, side: Side.Side2),
|
||||||
|
}.ToList().AsReadOnly());
|
||||||
|
|
||||||
|
_results.GetAsync(id, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EventResult(id, 2, 1, Side.Side1, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await _results.Received(1).GetAsync(id, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
|
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>();
|
||||||
|
|
||||||
|
public RunBacktestUseCaseTests()
|
||||||
|
{
|
||||||
|
// Use case batches event/result loads via GetManyAsync; route through per-id stubs.
|
||||||
|
TestFixtures.BridgeGetMany(_events);
|
||||||
|
TestFixtures.BridgeGetMany(_results);
|
||||||
|
}
|
||||||
|
|
||||||
|
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_LoadByDateRange_When_RangeProvided()
|
||||||
|
{
|
||||||
|
var id = new EventId("77777777");
|
||||||
|
var range = new DateRange(BaseTime.AddDays(-1), BaseTime.AddDays(1));
|
||||||
|
|
||||||
|
_anomalies.ListByDateRangeAsync(
|
||||||
|
Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new[] { MakeAnomaly(id) }.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(), range, CancellationToken.None);
|
||||||
|
|
||||||
|
await _anomalies.Received(1).ListByDateRangeAsync(
|
||||||
|
Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>());
|
||||||
|
await _anomalies.DidNotReceive().ListAsync(Arg.Any<CancellationToken>());
|
||||||
|
result.BetsPlaced.Should().BeGreaterThan(0, "the in-range graded anomaly produces a bet");
|
||||||
|
}
|
||||||
|
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
using Marathon.Application.Configuration;
|
using Marathon.Application.Configuration;
|
||||||
using Marathon.Domain.Entities;
|
using Marathon.Domain.Entities;
|
||||||
using Marathon.Domain.Enums;
|
using Marathon.Domain.Enums;
|
||||||
using Marathon.Domain.ValueObjects;
|
using Marathon.Domain.ValueObjects;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
namespace Marathon.Application.Tests.UseCases;
|
namespace Marathon.Application.Tests.UseCases;
|
||||||
|
|
||||||
@@ -13,6 +15,45 @@ internal static class TestFixtures
|
|||||||
{
|
{
|
||||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bridges the legacy per-id <c>GetAsync</c> stubs to the batched
|
||||||
|
/// <c>GetManyAsync</c> the use cases now call: each requested id is resolved
|
||||||
|
/// through whatever <c>GetAsync</c> was configured to return for it. Lets the
|
||||||
|
/// existing per-id <c>.Returns(...)</c> setups keep working unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static void BridgeGetMany(IEventRepository events)
|
||||||
|
{
|
||||||
|
events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ci =>
|
||||||
|
{
|
||||||
|
var ids = ci.Arg<IReadOnlyCollection<EventId>>();
|
||||||
|
var dict = new Dictionary<EventId, Event>();
|
||||||
|
foreach (var id in ids.Distinct())
|
||||||
|
{
|
||||||
|
var ev = events.GetAsync(id, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
if (ev is not null) dict[id] = ev;
|
||||||
|
}
|
||||||
|
return (IReadOnlyDictionary<EventId, Event>)dict;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="BridgeGetMany(IEventRepository)"/>
|
||||||
|
public static void BridgeGetMany(IResultRepository results)
|
||||||
|
{
|
||||||
|
results.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ci =>
|
||||||
|
{
|
||||||
|
var ids = ci.Arg<IReadOnlyCollection<EventId>>();
|
||||||
|
var dict = new Dictionary<EventId, EventResult>();
|
||||||
|
foreach (var id in ids.Distinct())
|
||||||
|
{
|
||||||
|
var r = results.GetAsync(id, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
if (r is not null) dict[id] = r;
|
||||||
|
}
|
||||||
|
return (IReadOnlyDictionary<EventId, EventResult>)dict;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Creates a minimal valid <see cref="Event"/> with the given event ID string.</summary>
|
/// <summary>Creates a minimal valid <see cref="Event"/> with the given event ID string.</summary>
|
||||||
public static Event MakeEvent(string eventIdValue = "12345678")
|
public static Event MakeEvent(string eventIdValue = "12345678")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Tests.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="AnomalyEvidenceParser"/> covering happy path,
|
||||||
|
/// two-way (no draw), and malformed JSON tolerance.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AnomalyEvidenceParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Should_Parse_ThreeWayEvidence_With_DrawOutcome()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var parsed = AnomalyEvidenceParser.TryParse(json, out var data);
|
||||||
|
|
||||||
|
parsed.Should().BeTrue();
|
||||||
|
data.SuspensionGapSeconds.Should().Be(90);
|
||||||
|
data.PreSuspension.P1.Should().Be(0.55m);
|
||||||
|
data.PreSuspension.PDraw.Should().Be(0.20m);
|
||||||
|
data.PreSuspension.Favourite.Should().Be(Side.Side1, "Side1 had the highest pre-suspension probability");
|
||||||
|
data.PostSuspension.Favourite.Should().Be(Side.Side2, "Side2 became favourite after the flip");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_Parse_TwoWayEvidence_With_NullDraw()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"suspensionGapSeconds": 75,
|
||||||
|
"preSuspension": {
|
||||||
|
"capturedAt": "2026-05-10T18:00:00+03:00",
|
||||||
|
"p1": 0.70, "p2": 0.30,
|
||||||
|
"rate1": 1.4, "rate2": 3.3
|
||||||
|
},
|
||||||
|
"postSuspension": {
|
||||||
|
"capturedAt": "2026-05-10T18:01:30+03:00",
|
||||||
|
"p1": 0.30, "p2": 0.70,
|
||||||
|
"rate1": 3.3, "rate2": 1.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var parsed = AnomalyEvidenceParser.TryParse(json, out var data);
|
||||||
|
|
||||||
|
parsed.Should().BeTrue();
|
||||||
|
data.PreSuspension.PDraw.Should().BeNull("tennis has no draw outcome");
|
||||||
|
data.PreSuspension.RateDraw.Should().BeNull();
|
||||||
|
data.PreSuspension.Favourite.Should().Be(Side.Side1);
|
||||||
|
data.PostSuspension.Favourite.Should().Be(Side.Side2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReturnFalse_When_JsonIsNullOrEmpty()
|
||||||
|
{
|
||||||
|
AnomalyEvidenceParser.TryParse(null, out _).Should().BeFalse();
|
||||||
|
AnomalyEvidenceParser.TryParse(string.Empty, out _).Should().BeFalse();
|
||||||
|
AnomalyEvidenceParser.TryParse(" ", out _).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReturnFalse_When_JsonIsMalformed()
|
||||||
|
{
|
||||||
|
AnomalyEvidenceParser.TryParse("{not json", out _).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReturnFalse_When_PreOrPostSuspensionMissing()
|
||||||
|
{
|
||||||
|
const string onlyPre = """
|
||||||
|
{
|
||||||
|
"suspensionGapSeconds": 90,
|
||||||
|
"preSuspension": {
|
||||||
|
"capturedAt": "2026-05-10T18:00:00+03:00",
|
||||||
|
"p1": 0.55, "p2": 0.25, "rate1": 1.8, "rate2": 4.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
AnomalyEvidenceParser.TryParse(onlyPre, out _).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Favourite_Should_Be_Draw_When_DrawIsMostLikely()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"suspensionGapSeconds": 60,
|
||||||
|
"preSuspension": {
|
||||||
|
"capturedAt": "2026-05-10T18:00:00+03:00",
|
||||||
|
"p1": 0.30, "pDraw": 0.50, "p2": 0.20,
|
||||||
|
"rate1": 3.3, "rateDraw": 2.0, "rate2": 5.0
|
||||||
|
},
|
||||||
|
"postSuspension": {
|
||||||
|
"capturedAt": "2026-05-10T18:01:00+03:00",
|
||||||
|
"p1": 0.30, "pDraw": 0.50, "p2": 0.20,
|
||||||
|
"rate1": 3.3, "rateDraw": 2.0, "rate2": 5.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
AnomalyEvidenceParser.TryParse(json, out var data).Should().BeTrue();
|
||||||
|
data.PreSuspension.Favourite.Should().Be(Side.Draw);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Tests.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="AnomalyOutcomeEvaluator"/> covering the join with
|
||||||
|
/// <see cref="EventResult"/> for hit / miss / unresolved verdicts.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AnomalyOutcomeEvaluatorTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
private static readonly EventId DefaultEventId = new("12345678");
|
||||||
|
|
||||||
|
private const string ThreeWayFlipJson = """
|
||||||
|
{
|
||||||
|
"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(string evidenceJson, decimal score = 0.5m) =>
|
||||||
|
new(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: DefaultEventId,
|
||||||
|
DetectedAt: new DateTimeOffset(2026, 5, 10, 18, 5, 0, MoscowOffset),
|
||||||
|
Kind: AnomalyKind.SuspensionFlip,
|
||||||
|
Score: score,
|
||||||
|
EvidenceJson: evidenceJson);
|
||||||
|
|
||||||
|
private static EventResult MakeResult(Side winner, int s1 = 1, int s2 = 1) =>
|
||||||
|
new(DefaultEventId, s1, s2, winner, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReportHit_When_PostFlipFavourite_Wins()
|
||||||
|
{
|
||||||
|
// Post-flip favourite = Side2; result = Side2 wins → Hit.
|
||||||
|
var anomaly = MakeAnomaly(ThreeWayFlipJson, score: 0.65m);
|
||||||
|
var result = MakeResult(Side.Side2, s1: 0, s2: 2);
|
||||||
|
|
||||||
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(6), result);
|
||||||
|
|
||||||
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit);
|
||||||
|
verdict.PreFlipFavourite.Should().Be(Side.Side1);
|
||||||
|
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
||||||
|
verdict.ActualWinner.Should().Be(Side.Side2);
|
||||||
|
verdict.Sport!.Value.Should().Be(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReportMiss_When_PostFlipFavourite_Loses()
|
||||||
|
{
|
||||||
|
// Post-flip favourite = Side2; result = Side1 wins → Miss (detector wrong).
|
||||||
|
var anomaly = MakeAnomaly(ThreeWayFlipJson);
|
||||||
|
var result = MakeResult(Side.Side1, s1: 2, s2: 0);
|
||||||
|
|
||||||
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(11), result);
|
||||||
|
|
||||||
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss);
|
||||||
|
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
||||||
|
verdict.ActualWinner.Should().Be(Side.Side1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReportMiss_When_DrawOccurred_AndPostFlipFavouriteIsNotDraw()
|
||||||
|
{
|
||||||
|
var anomaly = MakeAnomaly(ThreeWayFlipJson);
|
||||||
|
var result = MakeResult(Side.Draw, s1: 1, s2: 1);
|
||||||
|
|
||||||
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result);
|
||||||
|
|
||||||
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss);
|
||||||
|
verdict.ActualWinner.Should().Be(Side.Draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReportUnresolved_When_ResultIsNull()
|
||||||
|
{
|
||||||
|
var anomaly = MakeAnomaly(ThreeWayFlipJson);
|
||||||
|
|
||||||
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result: null);
|
||||||
|
|
||||||
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved);
|
||||||
|
verdict.ActualWinner.Should().BeNull();
|
||||||
|
// Pre/post favourites still computed for display.
|
||||||
|
verdict.PreFlipFavourite.Should().Be(Side.Side1);
|
||||||
|
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReportUnresolved_When_EvidenceJsonIsMalformed()
|
||||||
|
{
|
||||||
|
var anomaly = MakeAnomaly("{malformed");
|
||||||
|
var result = MakeResult(Side.Side1);
|
||||||
|
|
||||||
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result);
|
||||||
|
|
||||||
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved,
|
||||||
|
"evidence cannot be parsed so we cannot judge the prediction");
|
||||||
|
verdict.PreFlipFavourite.Should().BeNull(
|
||||||
|
"fabricated favourites would mislead any consumer that reads the unresolved branch");
|
||||||
|
verdict.PostFlipFavourite.Should().BeNull();
|
||||||
|
verdict.ActualWinner.Should().Be(Side.Side1, "the result side is still known and surfaced");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReportHit_For_TwoWayTennis_When_PostFlipFavouriteWins()
|
||||||
|
{
|
||||||
|
const string twoWayJson = """
|
||||||
|
{
|
||||||
|
"suspensionGapSeconds": 75,
|
||||||
|
"preSuspension": {
|
||||||
|
"capturedAt": "2026-05-10T18:00:00+03:00",
|
||||||
|
"p1": 0.70, "p2": 0.30, "rate1": 1.4, "rate2": 3.3
|
||||||
|
},
|
||||||
|
"postSuspension": {
|
||||||
|
"capturedAt": "2026-05-10T18:01:30+03:00",
|
||||||
|
"p1": 0.30, "p2": 0.70, "rate1": 3.3, "rate2": 1.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var anomaly = MakeAnomaly(twoWayJson, score: 0.55m);
|
||||||
|
var result = MakeResult(Side.Side2, s1: 0, s2: 2);
|
||||||
|
|
||||||
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result);
|
||||||
|
|
||||||
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit);
|
||||||
|
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReportUnresolved_When_TwoWayMarket_Has_DrawWinner()
|
||||||
|
{
|
||||||
|
// Tennis cannot draw — if the result is Draw the data is inconsistent
|
||||||
|
// with the evidence and we refuse to grade rather than silently miss-classify.
|
||||||
|
const string twoWayJson = """
|
||||||
|
{
|
||||||
|
"suspensionGapSeconds": 75,
|
||||||
|
"preSuspension": {
|
||||||
|
"capturedAt": "2026-05-10T18:00:00+03:00",
|
||||||
|
"p1": 0.70, "p2": 0.30, "rate1": 1.4, "rate2": 3.3
|
||||||
|
},
|
||||||
|
"postSuspension": {
|
||||||
|
"capturedAt": "2026-05-10T18:01:30+03:00",
|
||||||
|
"p1": 0.30, "p2": 0.70, "rate1": 3.3, "rate2": 1.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var anomaly = MakeAnomaly(twoWayJson, score: 0.55m);
|
||||||
|
var result = MakeResult(Side.Draw);
|
||||||
|
|
||||||
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result);
|
||||||
|
|
||||||
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved);
|
||||||
|
verdict.ActualWinner.Should().Be(Side.Draw);
|
||||||
|
verdict.PostFlipFavourite.Should().Be(Side.Side2,
|
||||||
|
"favourite is still computed for display, just not graded");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_Throw_When_AnomalyIsNull()
|
||||||
|
{
|
||||||
|
var act = () => AnomalyOutcomeEvaluator.Evaluate(null!, null, null);
|
||||||
|
act.Should().Throw<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Tests.AnomalyDetection;
|
||||||
|
|
||||||
|
public sealed class SteamMoveDetectorTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
private static readonly DateTimeOffset BaseTime = new(2026, 5, 10, 18, 0, 0, MoscowOffset);
|
||||||
|
private static readonly EventId Event = new("26000001");
|
||||||
|
|
||||||
|
// window 120s, drift threshold 0.20, min 3 snapshots, continuity break at 60s.
|
||||||
|
private static SteamMoveDetector CreateSut() => new(120, 0.20m, 3, 60);
|
||||||
|
|
||||||
|
private static OddsSnapshot Live(int seconds, decimal r1, decimal r2) =>
|
||||||
|
new(Event, BaseTime.AddSeconds(seconds), OddsSource.Live,
|
||||||
|
new List<Bet>
|
||||||
|
{
|
||||||
|
new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(r1)),
|
||||||
|
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(r2)),
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_FlagSteamMove_When_OneSideShortensContinuously()
|
||||||
|
{
|
||||||
|
// Side2 shortens (3.0 → 1.6) over 90s in continuous 30s steps: its normalised
|
||||||
|
// implied probability rises ~0.33 → ~0.61, a ~0.28 drift > 0.20 threshold.
|
||||||
|
var snapshots = new[]
|
||||||
|
{
|
||||||
|
Live(0, 1.5m, 3.0m),
|
||||||
|
Live(30, 1.7m, 2.3m),
|
||||||
|
Live(60, 2.1m, 1.9m),
|
||||||
|
Live(90, 2.5m, 1.6m),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = CreateSut().Detect(Event, snapshots);
|
||||||
|
|
||||||
|
result.Should().NotBeEmpty();
|
||||||
|
result.Should().OnlyContain(a => a.Kind == AnomalyKind.SteamMove);
|
||||||
|
result.Max(a => a.Score).Should().BeGreaterThanOrEqualTo(0.20m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_NotFlag_When_DriftBelowThreshold()
|
||||||
|
{
|
||||||
|
// Gentle drift: Side2 0.333 → ~0.38, well under the 0.20 threshold.
|
||||||
|
var snapshots = new[]
|
||||||
|
{
|
||||||
|
Live(0, 1.5m, 3.0m),
|
||||||
|
Live(30, 1.55m, 2.85m),
|
||||||
|
Live(60, 1.6m, 2.7m),
|
||||||
|
};
|
||||||
|
|
||||||
|
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_NotFlag_When_DriftSpansSuspensionGap()
|
||||||
|
{
|
||||||
|
// The big move happens across a 90s gap (> 60s continuity break) — that is the
|
||||||
|
// SuspensionFlip detector's territory, so steam must not double-flag it.
|
||||||
|
var snapshots = new[]
|
||||||
|
{
|
||||||
|
Live(0, 1.3m, 4.0m),
|
||||||
|
Live(30, 1.3m, 4.0m),
|
||||||
|
Live(120, 4.0m, 1.3m), // 90s gap
|
||||||
|
Live(150, 4.0m, 1.3m),
|
||||||
|
};
|
||||||
|
|
||||||
|
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReturnEmpty_When_FewerThanMinSnapshots()
|
||||||
|
{
|
||||||
|
var snapshots = new[] { Live(0, 1.5m, 3.0m), Live(30, 2.5m, 1.6m) };
|
||||||
|
|
||||||
|
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_IgnorePreMatchSnapshots()
|
||||||
|
{
|
||||||
|
var snapshots = new[]
|
||||||
|
{
|
||||||
|
new OddsSnapshot(Event, BaseTime, OddsSource.PreMatch,
|
||||||
|
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.5m)),
|
||||||
|
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(3.0m)) }),
|
||||||
|
new OddsSnapshot(Event, BaseTime.AddSeconds(30), OddsSource.PreMatch,
|
||||||
|
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.5m)),
|
||||||
|
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(1.6m)) }),
|
||||||
|
new OddsSnapshot(Event, BaseTime.AddSeconds(60), OddsSource.PreMatch,
|
||||||
|
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.6m)),
|
||||||
|
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(1.55m)) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_EmitParseableEvidence_For_DetectedSteamMove()
|
||||||
|
{
|
||||||
|
var snapshots = new[]
|
||||||
|
{
|
||||||
|
Live(0, 1.5m, 3.0m),
|
||||||
|
Live(30, 1.9m, 2.0m),
|
||||||
|
Live(60, 2.5m, 1.6m),
|
||||||
|
};
|
||||||
|
|
||||||
|
var anomaly = CreateSut().Detect(Event, snapshots).First();
|
||||||
|
|
||||||
|
AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var data).Should().BeTrue();
|
||||||
|
data.PreSuspension.Should().NotBeNull();
|
||||||
|
data.PostSuspension.Should().NotBeNull();
|
||||||
|
// Post favourite is the steamed (shortened) side — drives the outcome evaluator.
|
||||||
|
data.PostSuspension.Favourite.Should().Be(Side.Side2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-30)]
|
||||||
|
public void Should_Throw_When_ConstructedWithInvalidWindow(int windowSeconds)
|
||||||
|
{
|
||||||
|
var act = () => new SteamMoveDetector(windowSeconds, 0.20m, 3, 60);
|
||||||
|
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Domain.Betting;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Tests.Betting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="BetOutcomeResolver"/> across every bet type +
|
||||||
|
/// every important boundary (handicap push, total push, period-scope null).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BetOutcomeResolverTests
|
||||||
|
{
|
||||||
|
private static readonly EventId EventId = new("12345678");
|
||||||
|
|
||||||
|
// ── Win bets ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(Side.Side1, Side.Side1, BetOutcome.Won)]
|
||||||
|
[InlineData(Side.Side1, Side.Side2, BetOutcome.Lost)]
|
||||||
|
[InlineData(Side.Side1, Side.Draw, BetOutcome.Lost)]
|
||||||
|
[InlineData(Side.Side2, Side.Side2, BetOutcome.Won)]
|
||||||
|
[InlineData(Side.Side2, Side.Side1, BetOutcome.Lost)]
|
||||||
|
public void Should_GradeWinBet(Side selectionSide, Side winner, BetOutcome expected)
|
||||||
|
{
|
||||||
|
var bet = MakeBet(BetType.Win, selectionSide);
|
||||||
|
var result = MakeResult(winner);
|
||||||
|
|
||||||
|
BetOutcomeResolver.Resolve(bet, result).Should().Be(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw bets ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(Side.Draw, BetOutcome.Won)]
|
||||||
|
[InlineData(Side.Side1, BetOutcome.Lost)]
|
||||||
|
[InlineData(Side.Side2, BetOutcome.Lost)]
|
||||||
|
public void Should_GradeDrawBet(Side winner, BetOutcome expected)
|
||||||
|
{
|
||||||
|
var bet = MakeBet(BetType.Draw, Side.Draw);
|
||||||
|
var result = MakeResult(winner, 1, 1);
|
||||||
|
|
||||||
|
BetOutcomeResolver.Resolve(bet, result).Should().Be(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handicap (WinFora) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Side1 with +1.5 handicap; final 0-2 → adjusted 1.5 vs 2 → loss
|
||||||
|
[InlineData(Side.Side1, 1.5, 0, 2, BetOutcome.Lost)]
|
||||||
|
// Side1 with +1.5; final 1-2 → adjusted 2.5 vs 2 → win
|
||||||
|
[InlineData(Side.Side1, 1.5, 1, 2, BetOutcome.Won)]
|
||||||
|
// Side1 with -1.5; final 3-1 → adjusted 1.5 vs 1 → win
|
||||||
|
[InlineData(Side.Side1, -1.5, 3, 1, BetOutcome.Won)]
|
||||||
|
// Whole-number handicap that ties: Side1 +1, final 1-2 → 2 vs 2 → push (Void)
|
||||||
|
[InlineData(Side.Side1, 1, 1, 2, BetOutcome.Void)]
|
||||||
|
// Side2 with -1; final 0-2 → adjusted Side2 1 vs Side1 0 → win
|
||||||
|
[InlineData(Side.Side2, -1, 0, 2, BetOutcome.Won)]
|
||||||
|
public void Should_GradeHandicapBet(
|
||||||
|
Side side, double handicap, int s1, int s2, BetOutcome expected)
|
||||||
|
{
|
||||||
|
var bet = new Bet(MatchScope.Instance, BetType.WinFora, side,
|
||||||
|
new OddsValue((decimal)handicap), new OddsRate(1.85m));
|
||||||
|
var result = new EventResult(EventId, s1, s2,
|
||||||
|
DeriveWinner(s1, s2), DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
BetOutcomeResolver.Resolve(bet, result).Should().Be(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Totals ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Over 2.5; total 3 → win
|
||||||
|
[InlineData(Side.More, 2.5, 1, 2, BetOutcome.Won)]
|
||||||
|
// Over 2.5; total 2 → loss
|
||||||
|
[InlineData(Side.More, 2.5, 0, 2, BetOutcome.Lost)]
|
||||||
|
// Over 3.0; total 3 → push (Void)
|
||||||
|
[InlineData(Side.More, 3.0, 1, 2, BetOutcome.Void)]
|
||||||
|
// Under 2.5; total 2 → win
|
||||||
|
[InlineData(Side.Less, 2.5, 1, 1, BetOutcome.Won)]
|
||||||
|
// Under 2.5; total 3 → loss
|
||||||
|
[InlineData(Side.Less, 2.5, 1, 2, BetOutcome.Lost)]
|
||||||
|
public void Should_GradeTotalBet(
|
||||||
|
Side side, double threshold, int s1, int s2, BetOutcome expected)
|
||||||
|
{
|
||||||
|
var bet = new Bet(MatchScope.Instance, BetType.Total, side,
|
||||||
|
new OddsValue((decimal)threshold), new OddsRate(1.85m));
|
||||||
|
var result = new EventResult(EventId, s1, s2,
|
||||||
|
DeriveWinner(s1, s2), DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
BetOutcomeResolver.Resolve(bet, result).Should().Be(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Period-scope guard ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReturnNull_When_BetScopeIsPeriod()
|
||||||
|
{
|
||||||
|
var bet = new Bet(new PeriodScope(1), BetType.Win, Side.Side1,
|
||||||
|
value: null, new OddsRate(2.10m));
|
||||||
|
var result = MakeResult(Side.Side1);
|
||||||
|
|
||||||
|
BetOutcomeResolver.Resolve(bet, result).Should().BeNull(
|
||||||
|
"period scope cannot be graded from full-time score alone");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_Throw_When_BetOrResultIsNull()
|
||||||
|
{
|
||||||
|
((Action)(() => BetOutcomeResolver.Resolve(null!, MakeResult(Side.Side1))))
|
||||||
|
.Should().Throw<ArgumentNullException>();
|
||||||
|
((Action)(() => BetOutcomeResolver.Resolve(MakeBet(BetType.Win, Side.Side1), null!)))
|
||||||
|
.Should().Throw<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static Bet MakeBet(BetType type, Side side) =>
|
||||||
|
new(MatchScope.Instance, type, side, value: null, new OddsRate(2.00m));
|
||||||
|
|
||||||
|
private static EventResult MakeResult(Side winner, int s1 = 1, int s2 = 0) =>
|
||||||
|
new(EventId, s1, s2, winner, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
private static Side DeriveWinner(int s1, int s2) =>
|
||||||
|
s1 == s2 ? Side.Draw : (s1 > s2 ? Side.Side1 : Side.Side2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Domain.Betting;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Tests.Betting;
|
||||||
|
|
||||||
|
public sealed class KellyCalculatorTests
|
||||||
|
{
|
||||||
|
// ── SuggestStake: edge handling ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReturnZeroStake_When_NoPositiveEdge()
|
||||||
|
{
|
||||||
|
// p·o = 0.50 × 1.90 = 0.95 < 1 → negative edge.
|
||||||
|
var stake = KellyCalculator.SuggestStake(
|
||||||
|
winProbability: 0.50m, decimalOdds: 1.90m, bankroll: 1000m);
|
||||||
|
|
||||||
|
stake.Should().Be(0m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReturnZeroStake_When_ExactlyBreakeven()
|
||||||
|
{
|
||||||
|
// p·o = 0.50 × 2.00 = 1 → zero edge.
|
||||||
|
var stake = KellyCalculator.SuggestStake(
|
||||||
|
winProbability: 0.50m, decimalOdds: 2.00m, bankroll: 1000m);
|
||||||
|
|
||||||
|
stake.Should().Be(0m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_SizeQuarterKellyStake_When_PositiveEdge()
|
||||||
|
{
|
||||||
|
// full = (0.55×2.10 − 1)/(2.10 − 1) = 0.155/1.10 = 0.140909…
|
||||||
|
// quarter = 0.0352272… × 1000 = 35.2272… → truncated to 35.22.
|
||||||
|
var stake = KellyCalculator.SuggestStake(
|
||||||
|
winProbability: 0.55m, decimalOdds: 2.10m, bankroll: 1000m, fraction: 0.25m);
|
||||||
|
|
||||||
|
stake.Should().Be(35.22m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_UseFullKelly_When_FractionIsOne()
|
||||||
|
{
|
||||||
|
// full = (0.60×2.00 − 1)/(2.00 − 1) = 0.20 → 0.20 × 1000 = 200.
|
||||||
|
var stake = KellyCalculator.SuggestStake(
|
||||||
|
winProbability: 0.60m, decimalOdds: 2.00m, bankroll: 1000m, fraction: 1.0m);
|
||||||
|
|
||||||
|
stake.Should().Be(200m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ScaleStake_Proportionally_WithBankroll()
|
||||||
|
{
|
||||||
|
var small = KellyCalculator.SuggestStake(0.60m, 2.00m, bankroll: 500m, fraction: 1.0m);
|
||||||
|
var large = KellyCalculator.SuggestStake(0.60m, 2.00m, bankroll: 1000m, fraction: 1.0m);
|
||||||
|
|
||||||
|
small.Should().Be(100m);
|
||||||
|
large.Should().Be(200m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_NeverExceedComputedFigure_When_Truncating()
|
||||||
|
{
|
||||||
|
// Raw quarter-Kelly stake is 35.2272…; the suggestion must floor, not round up.
|
||||||
|
var stake = KellyCalculator.SuggestStake(0.55m, 2.10m, bankroll: 1000m, fraction: 0.25m);
|
||||||
|
|
||||||
|
stake.Should().BeLessThanOrEqualTo(0.25m * KellyCalculator.FullKellyFraction(0.55m, 2.10m) * 1000m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FullKellyFraction ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullKellyFraction_Should_ComputeEdgeFraction()
|
||||||
|
{
|
||||||
|
KellyCalculator.FullKellyFraction(0.55m, 2.10m)
|
||||||
|
.Should().BeApproximately(0.140909m, 0.000001m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullKellyFraction_Should_BeNegative_When_NoEdge()
|
||||||
|
{
|
||||||
|
KellyCalculator.FullKellyFraction(0.40m, 2.00m).Should().Be(-0.20m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullKellyFraction_Should_BeZero_When_Breakeven()
|
||||||
|
{
|
||||||
|
KellyCalculator.FullKellyFraction(0.50m, 2.00m).Should().Be(0m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Guard clauses ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.0)]
|
||||||
|
[InlineData(1.0)]
|
||||||
|
[InlineData(-0.1)]
|
||||||
|
[InlineData(1.1)]
|
||||||
|
public void SuggestStake_Should_Throw_When_ProbabilityOutOfOpenInterval(double probability)
|
||||||
|
{
|
||||||
|
var act = () => KellyCalculator.SuggestStake((decimal)probability, 2.0m, 1000m);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1.0)]
|
||||||
|
[InlineData(0.5)]
|
||||||
|
public void SuggestStake_Should_Throw_When_OddsNotGreaterThanOne(double odds)
|
||||||
|
{
|
||||||
|
var act = () => KellyCalculator.SuggestStake(0.55m, (decimal)odds, 1000m);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SuggestStake_Should_Throw_When_BankrollNegative()
|
||||||
|
{
|
||||||
|
var act = () => KellyCalculator.SuggestStake(0.55m, 2.10m, bankroll: -1m);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.0)]
|
||||||
|
[InlineData(-0.25)]
|
||||||
|
[InlineData(1.5)]
|
||||||
|
public void SuggestStake_Should_Throw_When_FractionOutOfRange(double fraction)
|
||||||
|
{
|
||||||
|
var act = () => KellyCalculator.SuggestStake(0.55m, 2.10m, 1000m, (decimal)fraction);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Tests.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invariants and derived-property tests for <see cref="PlacedBet"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlacedBetTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||||
|
private static readonly DateTimeOffset MoscowMoment =
|
||||||
|
new(2026, 5, 16, 12, 0, 0, MoscowOffset);
|
||||||
|
|
||||||
|
private static PlacedBet Make(BetOutcome outcome, decimal rate = 2.10m, decimal stake = 100m) =>
|
||||||
|
new(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: new EventId("12345678"),
|
||||||
|
Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1,
|
||||||
|
value: null, new OddsRate(rate)),
|
||||||
|
Stake: stake,
|
||||||
|
PlacedAt: MoscowMoment,
|
||||||
|
Outcome: outcome,
|
||||||
|
Notes: null);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ComputeWonReturn_As_StakeTimesRate()
|
||||||
|
{
|
||||||
|
var bet = Make(BetOutcome.Won, rate: 2.10m, stake: 100m);
|
||||||
|
|
||||||
|
bet.GrossReturn.Should().Be(210m);
|
||||||
|
bet.NetProfit.Should().Be(110m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ComputeLossReturn_As_Zero()
|
||||||
|
{
|
||||||
|
var bet = Make(BetOutcome.Lost, stake: 50m);
|
||||||
|
|
||||||
|
bet.GrossReturn.Should().Be(0m);
|
||||||
|
bet.NetProfit.Should().Be(-50m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReturnStake_When_Outcome_IsVoid()
|
||||||
|
{
|
||||||
|
var bet = Make(BetOutcome.Void, stake: 75m);
|
||||||
|
|
||||||
|
bet.GrossReturn.Should().Be(75m);
|
||||||
|
bet.NetProfit.Should().Be(0m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_ReturnNullProfit_When_OutcomeIsPending()
|
||||||
|
{
|
||||||
|
var bet = Make(BetOutcome.Pending);
|
||||||
|
|
||||||
|
bet.GrossReturn.Should().BeNull();
|
||||||
|
bet.NetProfit.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithOutcome_Should_ReturnNewInstance_With_GradedOutcome()
|
||||||
|
{
|
||||||
|
var pending = Make(BetOutcome.Pending);
|
||||||
|
var graded = pending.WithOutcome(BetOutcome.Won);
|
||||||
|
|
||||||
|
graded.Should().NotBeSameAs(pending);
|
||||||
|
graded.Outcome.Should().Be(BetOutcome.Won);
|
||||||
|
graded.Id.Should().Be(pending.Id);
|
||||||
|
graded.Stake.Should().Be(pending.Stake);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_Throw_When_StakeIsZeroOrNegative()
|
||||||
|
{
|
||||||
|
var act = () => new PlacedBet(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: new EventId("11111111"),
|
||||||
|
Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
|
||||||
|
Stake: 0m,
|
||||||
|
PlacedAt: MoscowMoment,
|
||||||
|
Outcome: BetOutcome.Pending,
|
||||||
|
Notes: null);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentOutOfRangeException>().WithMessage("*Stake*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_Throw_When_PlacedAt_IsNotMoscowOffset()
|
||||||
|
{
|
||||||
|
var act = () => new PlacedBet(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: new EventId("11111111"),
|
||||||
|
Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
|
||||||
|
Stake: 100m,
|
||||||
|
PlacedAt: DateTimeOffset.UtcNow, // UTC offset, not Moscow
|
||||||
|
Outcome: BetOutcome.Pending,
|
||||||
|
Notes: null);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>().WithMessage("*Moscow*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Should_NormaliseWhitespace_Notes_To_Null()
|
||||||
|
{
|
||||||
|
var bet = new PlacedBet(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: new EventId("11111111"),
|
||||||
|
Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
|
||||||
|
Stake: 100m,
|
||||||
|
PlacedAt: MoscowMoment,
|
||||||
|
Outcome: BetOutcome.Pending,
|
||||||
|
Notes: " ");
|
||||||
|
|
||||||
|
bet.Notes.Should().BeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user