Files
maraphon-app/src/Marathon.Domain/AnomalyDetection/MatchWinEvidence.cs
T
alexei.dolgolyov 115872aad0 feat(anomaly): overround-compression detector
Adds the 4th IAnomalyDetector: flags a sharp drop in the bookmaker's
overround (raw implied-probability sum / margin) over a continuous live
window. Informational/non-directional — excluded from outcome grading
and backtest staking via AnomalyKind.IsDirectional().

- OverroundCompressionDetector: sliding-window scan mirroring SteamMove,
  score = min(1, compression / 0.10 reference), suspension-gap aware.
- MatchWinEvidence.Probabilities gains Overround (pre-normalisation
  margin); evidence JSON shape unchanged.
- Wired into DetectAnomaliesUseCase fan-out; AnomalyOptions + appsettings
  (window 120s, threshold 0.02); en/ru resx + KindLabel arms.
- 11 detector tests incl. score saturation/scaling, inclusive threshold
  boundary, and a three-way (draw-leg) overround case.
2026-05-29 01:46:56 +03:00

125 lines
4.7 KiB
C#

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.
/// <see cref="Overround"/> is the raw implied-probability sum (the bookmaker's
/// margin/vig, &gt;= 1.0) before normalisation.
/// </summary>
public sealed record Probabilities(
decimal P1,
decimal? PDraw,
decimal P2,
decimal Rate1,
decimal? RateDraw,
decimal Rate2,
decimal Overround);
/// <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,
Overround: total);
}
/// <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);
}