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; /// /// Shared helper for the match-win implied-probability extraction and the canonical /// pre/post evidence-JSON shape used by every . /// /// /// Centralising the evidence format here guarantees that all detector kinds write the /// identical on-disk shape, so the UI parser (AnomalyEvidenceParser) and the /// outcome evaluator () work for every kind /// without branching. The suspensionGapSeconds field carries the elapsed /// seconds between the two snapshots — a suspension gap for flips, a drift window for /// steam moves. /// internal static class MatchWinEvidence { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; /// /// Normalised match-win implied probabilities + raw rates for a snapshot. /// is the raw implied-probability sum (the bookmaker's /// margin/vig, >= 1.0) before normalisation. /// public sealed record Probabilities( decimal P1, decimal? PDraw, decimal P2, decimal Rate1, decimal? RateDraw, decimal Rate2, decimal Overround); /// /// Extracts normalised match-win implied probabilities, or null when the snapshot /// lacks both Side1 and Side2 Match-Win bets. /// 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); } /// Label of the side carrying the highest normalised implied probability. 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"; } /// Serialises the canonical pre/post evidence payload. 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); }