Compare commits
2 Commits
4dae9e8d0d
...
d9d92ea8fd
| Author | SHA1 | Date | |
|---|---|---|---|
| d9d92ea8fd | |||
| 2b1025cae3 |
@@ -32,4 +32,16 @@ public sealed class AnomalyOptions
|
||||
/// in seconds. Default: 60 s.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -59,10 +59,18 @@ public sealed class DetectAnomaliesUseCase
|
||||
{
|
||||
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
|
||||
|
||||
var detector = new AnomalyDetector(
|
||||
var detectors = new IAnomalyDetector[]
|
||||
{
|
||||
new AnomalyDetector(
|
||||
_options.SuspensionGapSeconds,
|
||||
_options.OddsFlipThreshold,
|
||||
_options.MinSnapshotCount);
|
||||
_options.MinSnapshotCount),
|
||||
new SteamMoveDetector(
|
||||
_options.SteamMoveWindowSeconds,
|
||||
_options.SteamMoveDriftThreshold,
|
||||
_options.MinSnapshotCount,
|
||||
_options.SuspensionGapSeconds),
|
||||
};
|
||||
|
||||
var events = await _eventRepo.ListAsync(ct);
|
||||
int newAnomalyCount = 0;
|
||||
@@ -96,7 +104,7 @@ public sealed class DetectAnomaliesUseCase
|
||||
var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
|
||||
? slice
|
||||
: new List<Anomaly>();
|
||||
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingForEvent, ct);
|
||||
newAnomalyCount += await ProcessEventAsync(detectors, ev, snapshots, existingForEvent, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -120,13 +128,17 @@ public sealed class DetectAnomaliesUseCase
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private async Task<int> ProcessEventAsync(
|
||||
AnomalyDetector detector,
|
||||
IReadOnlyList<IAnomalyDetector> detectors,
|
||||
Event ev,
|
||||
IReadOnlyList<OddsSnapshot> snapshots,
|
||||
List<Anomaly> existingForEvent,
|
||||
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)
|
||||
return 0;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
@@ -23,20 +21,15 @@ namespace Marathon.Domain.AnomalyDetection;
|
||||
/// </list>
|
||||
///
|
||||
/// 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>
|
||||
public sealed class AnomalyDetector
|
||||
public sealed class AnomalyDetector : IAnomalyDetector
|
||||
{
|
||||
private readonly int _suspensionGapSeconds;
|
||||
private readonly decimal _oddsFlipThreshold;
|
||||
private readonly int _minSnapshotCount;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <param name="suspensionGapSeconds">
|
||||
/// Minimum gap between adjacent live snapshots (in seconds) to classify as a suspension.
|
||||
/// Default per spec: 60.
|
||||
@@ -68,16 +61,7 @@ public sealed class AnomalyDetector
|
||||
_minSnapshotCount = minSnapshotCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(eventId);
|
||||
@@ -119,9 +103,9 @@ public sealed class AnomalyDetector
|
||||
|
||||
private Anomaly? TryDetectFlip(EventId eventId, SuspensionInterval interval)
|
||||
{
|
||||
// Extract Match-Win bets from each snapshot.
|
||||
var preProbs = ExtractMatchWinProbabilities(interval.PreSuspension);
|
||||
var postProbs = ExtractMatchWinProbabilities(interval.PostSuspension);
|
||||
// Extract Match-Win implied probabilities from each snapshot.
|
||||
var preProbs = MatchWinEvidence.Extract(interval.PreSuspension);
|
||||
var postProbs = MatchWinEvidence.Extract(interval.PostSuspension);
|
||||
|
||||
// Cannot compute flip if either snapshot lacks Win bets.
|
||||
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.
|
||||
decimal flipScore = 0m;
|
||||
flipScore = Math.Max(flipScore,
|
||||
Math.Abs(postProbs.P1 - preProbs.P1));
|
||||
flipScore = Math.Max(flipScore,
|
||||
Math.Abs(postProbs.P2 - preProbs.P2));
|
||||
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P1 - preProbs.P1));
|
||||
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P2 - preProbs.P2));
|
||||
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
|
||||
{
|
||||
flipScore = Math.Max(flipScore,
|
||||
@@ -140,7 +122,8 @@ public sealed class AnomalyDetector
|
||||
}
|
||||
|
||||
// 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)
|
||||
return null;
|
||||
@@ -148,8 +131,11 @@ public sealed class AnomalyDetector
|
||||
// Clamp score to [0, 1] before constructing the Anomaly (domain invariant).
|
||||
var clampedScore = Math.Min(1m, flipScore);
|
||||
|
||||
// Step 6 — build evidence JSON.
|
||||
var evidenceJson = BuildEvidenceJson(interval, preProbs, postProbs);
|
||||
// Step 6 — build evidence JSON via the shared formatter.
|
||||
var evidenceJson = MatchWinEvidence.BuildJson(
|
||||
(int)interval.Gap.TotalSeconds,
|
||||
interval.PreSuspension, preProbs,
|
||||
interval.PostSuspension, postProbs);
|
||||
|
||||
return new Anomaly(
|
||||
Id: Guid.NewGuid(),
|
||||
@@ -159,100 +145,4 @@ public sealed class AnomalyDetector
|
||||
Score: clampedScore,
|
||||
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,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();
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,10 @@ public enum AnomalyKind
|
||||
/// Bookmaker suspended the market, then flipped the underdog/favourite coefficients.
|
||||
/// </summary>
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -212,6 +212,7 @@
|
||||
private string KindLabel(AnomalyKind kind) => kind switch
|
||||
{
|
||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||
_ => kind.ToString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -54,6 +54,13 @@
|
||||
data-test="link-back-to-event">
|
||||
@L["Anomaly.Detail.LinkBackToEvent"]
|
||||
</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>
|
||||
</header>
|
||||
|
||||
@@ -99,6 +106,7 @@
|
||||
private string KindLabel(AnomalyKind kind) => kind switch
|
||||
{
|
||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||
_ => kind.ToString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -114,6 +114,20 @@
|
||||
|
||||
<article class="m-card m-card--accented m-journal__form-card">
|
||||
<div class="m-journal__form-grid">
|
||||
<div class="m-journal__form-field m-journal__form-field--wide">
|
||||
<label class="m-journal__form-label">@L["Journal.Field.FindEvent"]</label>
|
||||
<MudAutocomplete T="EventOption"
|
||||
SearchFunc="SearchEventsAsync"
|
||||
ToStringFunc="@(o => o is null ? string.Empty : o.Label)"
|
||||
ValueChanged="OnEventSelected"
|
||||
Variant="Variant.Outlined"
|
||||
Clearable="true"
|
||||
ResetValueOnEmptyText="true"
|
||||
Placeholder="@L["Journal.Field.FindEvent.Placeholder"]"
|
||||
data-test="journal-find-event" />
|
||||
<span class="m-journal__form-hint">@L["Journal.Field.FindEvent.Hint"]</span>
|
||||
</div>
|
||||
|
||||
<div class="m-journal__form-field m-journal__form-field--wide">
|
||||
<label class="m-journal__form-label" for="journal-event-id">@L["Journal.Field.EventId"]</label>
|
||||
<MudTextField id="journal-event-id"
|
||||
@@ -718,9 +732,48 @@
|
||||
}
|
||||
private CancellationTokenSource? _loadCts;
|
||||
|
||||
private IReadOnlyList<EventOption> _eventOptions = Array.Empty<EventOption>();
|
||||
|
||||
/// <summary>Optional event code supplied by a "Log bet" deep link (e.g. from an anomaly).</summary>
|
||||
[Parameter, SupplyParameterFromQuery(Name = "eventId")]
|
||||
public string? PrefillEventId { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(PrefillEventId))
|
||||
_form.EventId = PrefillEventId.Trim();
|
||||
|
||||
await LoadAsync();
|
||||
await LoadEventOptionsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadEventOptionsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = await Service.GetUpcomingEventOptionsAsync(_loadCts?.Token ?? CancellationToken.None);
|
||||
_eventOptions = options ?? Array.Empty<EventOption>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The autocomplete is a convenience; failing to populate it must not break the page.
|
||||
Logger.LogWarning(ex, "Journal: failed to load event options for the autocomplete.");
|
||||
_eventOptions = Array.Empty<EventOption>();
|
||||
}
|
||||
}
|
||||
|
||||
private Task<IEnumerable<EventOption>> SearchEventsAsync(string? value, CancellationToken token)
|
||||
{
|
||||
IEnumerable<EventOption> matches = string.IsNullOrWhiteSpace(value)
|
||||
? _eventOptions
|
||||
: _eventOptions.Where(o => o.Label.Contains(value, StringComparison.OrdinalIgnoreCase));
|
||||
return Task.FromResult(matches.Take(20));
|
||||
}
|
||||
|
||||
private void OnEventSelected(EventOption? option)
|
||||
{
|
||||
if (option is not null)
|
||||
_form.EventId = option.Id;
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
|
||||
@@ -163,6 +163,7 @@
|
||||
|
||||
<data name="Anomaly.Live"><value>Anomaly</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>
|
||||
|
||||
<!-- Phase 7 — Anomaly feed UI -->
|
||||
@@ -190,6 +191,7 @@
|
||||
<data name="Anomaly.Evidence.FavouriteSwap"><value>Favourite swap</value></data>
|
||||
<data name="Anomaly.Detail.EvidenceTitle"><value>Evidence timeline</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.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>
|
||||
@@ -387,6 +389,9 @@
|
||||
<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>
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
<!-- Anomaly (Phase 7 placeholders) -->
|
||||
<data name="Anomaly.Live"><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>
|
||||
|
||||
<!-- Phase 7 — Лента аномалий -->
|
||||
@@ -203,6 +204,7 @@
|
||||
<data name="Anomaly.Evidence.FavouriteSwap"><value>Смена фаворита</value></data>
|
||||
<data name="Anomaly.Detail.EvidenceTitle"><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.NotFound"><value>Аномалия не найдена — возможно, она была удалена.</value></data>
|
||||
<data name="Anomaly.Empty.NoneInRange"><value>Под текущие фильтры аномалии не попадают. Снизьте порог важности или расширьте диапазон дат.</value></data>
|
||||
@@ -400,6 +402,9 @@
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Globalization;
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.Storage;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
@@ -95,4 +97,21 @@ public sealed class BetJournalService : IBetJournalService
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,16 @@ public interface IBetJournalService
|
||||
/// 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,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>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user