Files
maraphon-app/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs
T
alexei.dolgolyov 68f3229c35 feat(anomaly): suspension-freeze detector
- Add SuspensionFreezeDetector via the IAnomalyDetector seam: a suspension gap with
  the favourite unchanged and a negligible (< threshold) price move — the mirror of
  the flip. Score = how completely the line froze. Reuses MatchWinEvidence so UI +
  evaluator handle it unchanged. 6 tests.
- Add AnomalyKind.SuspensionFreeze + localized card/detail label, SuspensionFreezeThreshold
  option, and fan it into DetectAnomaliesUseCase.
2026-05-29 01:03:47 +03:00

176 lines
7.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Marathon.Application.Abstractions;
using Marathon.Application.Configuration;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
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 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 detectors = new IAnomalyDetector[]
{
new AnomalyDetector(
_options.SuspensionGapSeconds,
_options.OddsFlipThreshold,
_options.MinSnapshotCount),
new SteamMoveDetector(
_options.SteamMoveWindowSeconds,
_options.SteamMoveDriftThreshold,
_options.MinSnapshotCount,
_options.SuspensionGapSeconds),
new SuspensionFreezeDetector(
_options.SuspensionGapSeconds,
_options.SuspensionFreezeThreshold,
_options.MinSnapshotCount),
};
var events = await _eventRepo.ListAsync(ct);
int newAnomalyCount = 0;
var now = MoscowTime.Now;
var from = now - SnapshotLookback;
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle
// and index them by event so dedup is O(1) per event instead of scanning the
// whole list each time (was O(events × anomalies)). Reviewer W1, Phase 7.
var existingAnomalies = await _anomalyRepo.ListAsync(ct);
var existingByEvent = existingAnomalies
.GroupBy(a => a.EventId)
.ToDictionary(g => g.Key, g => g.ToList());
// Single batched query for all events' snapshots — replaces the prior
// per-event ListByEventAsync round-trip (O(N) SQLite hits + N Include(Bets)
// payloads). Returns an empty list for events with no snapshots in range.
var eventIds = events.Select(e => e.Id).ToList();
var snapshotsByEvent = await _snapshotRepo.ListByEventsAsync(eventIds, from, now, ct);
foreach (var ev in events)
{
ct.ThrowIfCancellationRequested();
try
{
var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found)
? found
: Array.Empty<OddsSnapshot>();
var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
? slice
: new List<Anomaly>();
newAnomalyCount += await ProcessEventAsync(detectors, ev, snapshots, existingForEvent, 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(
IReadOnlyList<IAnomalyDetector> detectors,
Event ev,
IReadOnlyList<OddsSnapshot> snapshots,
List<Anomaly> existingForEvent,
CancellationToken ct)
{
// 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;
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);
}
}