115872aad0
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.
125 lines
4.7 KiB
C#
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, >= 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);
|
|
}
|