feat(phase-7-backend): implement anomaly detection — SuspensionFlip detector, use case, poller, and tests
- AnomalyDetector (pure domain): detects odds-flip pattern from live snapshot timelines using implied-probability vectors (p=1/rate, normalised), flip score = max(|p_post−p_pre|), gated by both threshold AND favourite-changed test - SuspensionInterval record: typed pair of (pre, post) OddsSnapshot bracketing a gap - AnomalyOptions POCO (Application layer): bound to Anomaly:* config section with four fields (SuspensionGapSeconds=60, OddsFlipThreshold=0.30, MinSnapshotCount=3, DetectionIntervalSeconds=60) - DetectAnomaliesUseCase: iterates all events, loads last-24h live snapshots, runs detector, persists new anomalies with 1-minute dedup window - AnomalyDetectionPoller: BackgroundService polling every DetectionIntervalSeconds, gated by WorkerOptions.AnomalyDetectionEnabled (default true) - DI wiring: DetectAnomaliesUseCase registered Scoped in ApplicationModule; AnomalyOptions bound + AnomalyDetectionPoller hosted in InfrastructureModule - WorkerOptions.AnomalyDetectionEnabled added; appsettings.json updated - 13 domain tests + 4 application tests; total 245/245 passing (no regression)
This commit is contained in:
@@ -29,6 +29,7 @@ public static class ApplicationModule
|
||||
services.AddScoped<PullLiveOddsUseCase>();
|
||||
services.AddScoped<PullResultsUseCase>();
|
||||
services.AddScoped<ExportToExcelUseCase>();
|
||||
services.AddScoped<DetectAnomaliesUseCase>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Marathon.Application.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly typed options for the anomaly-detection subsystem.
|
||||
/// Bound from the <c>Anomaly</c> section of <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
public sealed class AnomalyOptions
|
||||
{
|
||||
/// <summary>Configuration section key.</summary>
|
||||
public const string SectionName = "Anomaly";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum gap between adjacent live snapshots, in seconds, to classify as
|
||||
/// a bookmaker suspension. Default: 60 s.
|
||||
/// </summary>
|
||||
public int SuspensionGapSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum normalised implied-probability delta required for the post-suspension
|
||||
/// odds change to qualify as a flip. Must be in (0, 1). Default: 0.30.
|
||||
/// </summary>
|
||||
public decimal OddsFlipThreshold { get; init; } = 0.30m;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of live snapshots an event must have before detection runs.
|
||||
/// Default: 3. Must be at least 2 (one pair).
|
||||
/// </summary>
|
||||
public int MinSnapshotCount { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// How long the <c>AnomalyDetectionPoller</c> sleeps between detection cycles,
|
||||
/// in seconds. Default: 60 s.
|
||||
/// </summary>
|
||||
public int DetectionIntervalSeconds { get; init; } = 60;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.Configuration;
|
||||
using Marathon.Domain.AnomalyDetection;
|
||||
using Marathon.Domain.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Marathon.Application.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates one anomaly-detection cycle:
|
||||
/// <list type="number">
|
||||
/// <item>Loads all tracked events.</item>
|
||||
/// <item>For each event, fetches its last-24-hour live snapshots.</item>
|
||||
/// <item>Runs <see cref="AnomalyDetector"/> over the snapshot timeline.</item>
|
||||
/// <item>Persists any new anomalies that have not already been stored (dedup by EventId + DetectedAt minute-window).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 🟡 Optimisation opportunity (Phase 8/9): currently iterates ALL events and loads 24 h of
|
||||
/// snapshots per event. A future improvement is to track a "last detection run" timestamp per
|
||||
/// event so we only load new snapshots. This is intentionally deferred to keep Phase 7 scope
|
||||
/// focused on the detection algorithm.
|
||||
/// </remarks>
|
||||
public sealed class DetectAnomaliesUseCase
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
private static readonly TimeSpan SnapshotLookback = TimeSpan.FromHours(24);
|
||||
|
||||
// Dedup window: two anomalies for the same event within this window are considered duplicates.
|
||||
private static readonly TimeSpan DedupWindow = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly IEventRepository _eventRepo;
|
||||
private readonly ISnapshotRepository _snapshotRepo;
|
||||
private readonly IAnomalyRepository _anomalyRepo;
|
||||
private readonly AnomalyOptions _options;
|
||||
private readonly ILogger<DetectAnomaliesUseCase> _logger;
|
||||
|
||||
public DetectAnomaliesUseCase(
|
||||
IEventRepository eventRepo,
|
||||
ISnapshotRepository snapshotRepo,
|
||||
IAnomalyRepository anomalyRepo,
|
||||
IOptions<AnomalyOptions> options,
|
||||
ILogger<DetectAnomaliesUseCase> logger)
|
||||
{
|
||||
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
|
||||
_anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes one detection cycle.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Number of new anomalies persisted during this cycle.</returns>
|
||||
public async Task<int> ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
|
||||
|
||||
var detector = new AnomalyDetector(
|
||||
_options.SuspensionGapSeconds,
|
||||
_options.OddsFlipThreshold,
|
||||
_options.MinSnapshotCount);
|
||||
|
||||
var events = await _eventRepo.ListAsync(ct);
|
||||
int newAnomalyCount = 0;
|
||||
|
||||
var now = DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
|
||||
var from = now - SnapshotLookback;
|
||||
|
||||
foreach (var ev in events)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
newAnomalyCount += await ProcessEventAsync(detector, ev, from, now, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"DetectAnomaliesUseCase: failed to process event {EventId} — skipping",
|
||||
ev.Id.Value);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"DetectAnomaliesUseCase: cycle done — {NewAnomalies} new anomalies across {TotalEvents} events",
|
||||
newAnomalyCount, events.Count);
|
||||
|
||||
return newAnomalyCount;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private async Task<int> ProcessEventAsync(
|
||||
AnomalyDetector detector,
|
||||
Event ev,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var snapshots = await _snapshotRepo.ListByEventAsync(ev.Id, from, to, ct);
|
||||
var detected = detector.Detect(ev.Id, snapshots);
|
||||
|
||||
if (detected.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Load existing anomalies for this event so we can deduplicate.
|
||||
var existing = await _anomalyRepo.ListAsync(ct);
|
||||
var existingForEvent = existing
|
||||
.Where(a => a.EventId == ev.Id)
|
||||
.ToList();
|
||||
|
||||
int persisted = 0;
|
||||
foreach (var anomaly in detected)
|
||||
{
|
||||
if (IsDuplicate(anomaly, existingForEvent))
|
||||
continue;
|
||||
|
||||
await _anomalyRepo.AddAsync(anomaly, ct);
|
||||
await _anomalyRepo.SaveChangesAsync(ct);
|
||||
existingForEvent.Add(anomaly); // Keep local list in sync so the same cycle doesn't re-add.
|
||||
persisted++;
|
||||
}
|
||||
|
||||
return persisted;
|
||||
}
|
||||
|
||||
private static bool IsDuplicate(Anomaly candidate, IReadOnlyList<Anomaly> existing)
|
||||
{
|
||||
// Two anomalies are considered duplicates if they share the same EventId, Kind,
|
||||
// and their DetectedAt timestamps fall within the dedup window.
|
||||
return existing.Any(a =>
|
||||
a.EventId == candidate.EventId &&
|
||||
a.Kind == candidate.Kind &&
|
||||
Math.Abs((a.DetectedAt - candidate.DetectedAt).TotalMinutes) <=
|
||||
DedupWindow.TotalMinutes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
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>
|
||||
/// Pure domain service that analyses a chronological sequence of live <see cref="OddsSnapshot"/>
|
||||
/// records for a single event and returns any detected <see cref="Anomaly"/> instances.
|
||||
///
|
||||
/// Algorithm (SuspensionFlip):
|
||||
/// <list type="number">
|
||||
/// <item>Filter to <see cref="OddsSource.Live"/> snapshots and sort by <c>CapturedAt</c>.</item>
|
||||
/// <item>Return empty if fewer than <c>minSnapshotCount</c> live snapshots are available.</item>
|
||||
/// <item>Walk adjacent pairs; identify gaps larger than <c>suspensionGapSeconds</c>.</item>
|
||||
/// <item>For each suspension, extract Match-Win bets from pre/post snapshots, compute
|
||||
/// implied probability vectors and normalise them to sum to 1.</item>
|
||||
/// <item>Compute flip score = max(|p_post[i] − p_pre[i]|) across sides.</item>
|
||||
/// <item>If flip score ≥ <c>oddsFlipThreshold</c> AND the favourite changed
|
||||
/// (argmax of implied probabilities differs), emit one <see cref="Anomaly"/>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// This class is stateless and deterministic — identical inputs always produce identical output.
|
||||
/// It has no I/O or DI dependencies.
|
||||
/// </summary>
|
||||
public sealed class AnomalyDetector
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
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.
|
||||
/// </param>
|
||||
/// <param name="oddsFlipThreshold">
|
||||
/// Minimum implied-probability delta to classify a post-suspension odds change as a flip.
|
||||
/// Default per spec: 0.30 (30 percentage points).
|
||||
/// </param>
|
||||
/// <param name="minSnapshotCount">
|
||||
/// Minimum number of live snapshots required before detection runs.
|
||||
/// Default per spec: 3.
|
||||
/// </param>
|
||||
public AnomalyDetector(int suspensionGapSeconds, decimal oddsFlipThreshold, int minSnapshotCount)
|
||||
{
|
||||
if (suspensionGapSeconds <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(suspensionGapSeconds),
|
||||
suspensionGapSeconds, "Must be positive.");
|
||||
|
||||
if (oddsFlipThreshold is <= 0m or >= 1m)
|
||||
throw new ArgumentOutOfRangeException(nameof(oddsFlipThreshold),
|
||||
oddsFlipThreshold, "Must be in (0, 1).");
|
||||
|
||||
if (minSnapshotCount < 2)
|
||||
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount),
|
||||
minSnapshotCount, "Must be at least 2 to form at least one pair.");
|
||||
|
||||
_suspensionGapSeconds = suspensionGapSeconds;
|
||||
_oddsFlipThreshold = oddsFlipThreshold;
|
||||
_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>
|
||||
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(eventId);
|
||||
ArgumentNullException.ThrowIfNull(snapshots);
|
||||
|
||||
// Step 1 — filter to Live snapshots only; suspension/flip is a live phenomenon.
|
||||
var liveSnapshots = snapshots
|
||||
.Where(s => s.Source == OddsSource.Live)
|
||||
.OrderBy(s => s.CapturedAt)
|
||||
.ToList();
|
||||
|
||||
// Step 2 — guard: need a minimum count to form meaningful intervals.
|
||||
if (liveSnapshots.Count < _minSnapshotCount)
|
||||
return Array.Empty<Anomaly>();
|
||||
|
||||
var anomalies = new List<Anomaly>();
|
||||
var suspensionGap = TimeSpan.FromSeconds(_suspensionGapSeconds);
|
||||
|
||||
// Step 3 — identify suspension intervals.
|
||||
for (int i = 0; i < liveSnapshots.Count - 1; i++)
|
||||
{
|
||||
var pre = liveSnapshots[i];
|
||||
var post = liveSnapshots[i + 1];
|
||||
|
||||
var gap = post.CapturedAt - pre.CapturedAt;
|
||||
if (gap <= suspensionGap)
|
||||
continue;
|
||||
|
||||
var interval = new SuspensionInterval(pre, post);
|
||||
var anomaly = TryDetectFlip(eventId, interval);
|
||||
if (anomaly is not null)
|
||||
anomalies.Add(anomaly);
|
||||
}
|
||||
|
||||
return anomalies.AsReadOnly();
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private Anomaly? TryDetectFlip(EventId eventId, SuspensionInterval interval)
|
||||
{
|
||||
// Extract Match-Win bets from each snapshot.
|
||||
var preProbs = ExtractMatchWinProbabilities(interval.PreSuspension);
|
||||
var postProbs = ExtractMatchWinProbabilities(interval.PostSuspension);
|
||||
|
||||
// Cannot compute flip if either snapshot lacks Win bets.
|
||||
if (preProbs is null || postProbs is null)
|
||||
return null;
|
||||
|
||||
// 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));
|
||||
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
|
||||
{
|
||||
flipScore = Math.Max(flipScore,
|
||||
Math.Abs(postProbs.PDraw.Value - preProbs.PDraw.Value));
|
||||
}
|
||||
|
||||
// Step 5 — favourite-changed test: argmax of implied probability must differ.
|
||||
bool favouriteChanged = DetermineFavourite(preProbs) != DetermineFavourite(postProbs);
|
||||
|
||||
if (flipScore < _oddsFlipThreshold || !favouriteChanged)
|
||||
return null;
|
||||
|
||||
// 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);
|
||||
|
||||
return new Anomaly(
|
||||
Id: Guid.NewGuid(),
|
||||
EventId: eventId,
|
||||
DetectedAt: DateTimeOffset.UtcNow.ToOffset(MoscowOffset),
|
||||
Kind: AnomalyKind.SuspensionFlip,
|
||||
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 : (decimal?)null ?? 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,15 @@
|
||||
using Marathon.Domain.Entities;
|
||||
|
||||
namespace Marathon.Domain.AnomalyDetection;
|
||||
|
||||
/// <summary>
|
||||
/// A pair of adjacent <see cref="OddsSnapshot"/> records that bracket a suspension gap —
|
||||
/// i.e. the time between them exceeded the configured <c>SuspensionGapSeconds</c> threshold.
|
||||
/// </summary>
|
||||
/// <param name="PreSuspension">The last snapshot captured before the gap.</param>
|
||||
/// <param name="PostSuspension">The first snapshot captured after the gap.</param>
|
||||
internal sealed record SuspensionInterval(OddsSnapshot PreSuspension, OddsSnapshot PostSuspension)
|
||||
{
|
||||
/// <summary>Duration of the observed suspension gap.</summary>
|
||||
public TimeSpan Gap => PostSuspension.CapturedAt - PreSuspension.CapturedAt;
|
||||
}
|
||||
@@ -24,7 +24,8 @@
|
||||
"UpcomingPollerEnabled": true,
|
||||
"LivePollIntervalSeconds": 30,
|
||||
"ResultsPollIntervalSeconds": 300,
|
||||
"ResultsPollerEnabled": false
|
||||
"ResultsPollerEnabled": false,
|
||||
"AnomalyDetectionEnabled": true
|
||||
},
|
||||
"Sports": {
|
||||
"Basketball": {
|
||||
|
||||
@@ -40,4 +40,11 @@ public sealed class WorkerOptions
|
||||
/// Flip to <c>true</c> only after Phase 8 is complete.
|
||||
/// </summary>
|
||||
public bool ResultsPollerEnabled { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the anomaly-detection poller should run.
|
||||
/// Default: <c>true</c> — this is the product's primary differentiator and
|
||||
/// should be enabled by default.
|
||||
/// </summary>
|
||||
public bool AnomalyDetectionEnabled { get; init; } = true;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Marathon.Application.Configuration;
|
||||
using Marathon.Infrastructure.Configuration;
|
||||
using Marathon.Infrastructure.Persistence;
|
||||
using Marathon.Infrastructure.Scraping;
|
||||
@@ -41,9 +42,14 @@ public static class InfrastructureModule
|
||||
.AddOptions<WorkerOptions>()
|
||||
.Bind(config.GetSection(WorkerOptions.SectionName));
|
||||
|
||||
services
|
||||
.AddOptions<AnomalyOptions>()
|
||||
.Bind(config.GetSection(AnomalyOptions.SectionName));
|
||||
|
||||
services.AddHostedService<UpcomingEventsPoller>();
|
||||
services.AddHostedService<LiveOddsPoller>();
|
||||
services.AddHostedService<ResultsWatchListPoller>();
|
||||
services.AddHostedService<AnomalyDetectionPoller>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using Marathon.Application.Configuration;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Infrastructure.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Marathon.Infrastructure.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Continuously runs the anomaly-detection cycle on a fixed interval controlled by
|
||||
/// <see cref="AnomalyOptions.DetectionIntervalSeconds"/> (default: 60 s).
|
||||
/// Can be disabled at runtime via <see cref="WorkerOptions.AnomalyDetectionEnabled"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Registered as a <see cref="BackgroundService"/> (singleton lifetime).
|
||||
/// <see cref="DetectAnomaliesUseCase"/> is resolved in a fresh <see cref="IServiceScope"/>
|
||||
/// per cycle so that EF Core's scoped <c>DbContext</c> is correctly managed.
|
||||
/// </remarks>
|
||||
internal sealed class AnomalyDetectionPoller : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IOptionsMonitor<WorkerOptions> _workerOpts;
|
||||
private readonly IOptionsMonitor<AnomalyOptions> _anomalyOpts;
|
||||
private readonly ILogger<AnomalyDetectionPoller> _logger;
|
||||
|
||||
public AnomalyDetectionPoller(
|
||||
IServiceProvider services,
|
||||
IOptionsMonitor<WorkerOptions> workerOpts,
|
||||
IOptionsMonitor<AnomalyOptions> anomalyOpts,
|
||||
ILogger<AnomalyDetectionPoller> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_workerOpts = workerOpts ?? throw new ArgumentNullException(nameof(workerOpts));
|
||||
_anomalyOpts = anomalyOpts ?? throw new ArgumentNullException(nameof(anomalyOpts));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("AnomalyDetectionPoller: started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (!_workerOpts.CurrentValue.AnomalyDetectionEnabled)
|
||||
{
|
||||
_logger.LogDebug("AnomalyDetectionPoller: disabled — sleeping 10 s before re-check");
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
var useCase = scope.ServiceProvider.GetRequiredService<DetectAnomaliesUseCase>();
|
||||
var newAnomalies = await useCase.ExecuteAsync(stoppingToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AnomalyDetectionPoller: cycle complete — newAnomalies={NewAnomalies}",
|
||||
newAnomalies);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"AnomalyDetectionPoller: unhandled exception during cycle — will retry after interval");
|
||||
}
|
||||
|
||||
var interval = TimeSpan.FromSeconds(
|
||||
Math.Max(1, _anomalyOpts.CurrentValue.DetectionIntervalSeconds));
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("AnomalyDetectionPoller: stopping");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user