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);
}