Files
alexei.dolgolyov a6ff368015 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)
2026-05-05 13:15:50 +03:00

392 lines
17 KiB
C#

using System.Text.Json;
using FluentAssertions;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Tests.AnomalyDetection;
/// <summary>
/// Unit tests for <see cref="AnomalyDetector"/>.
/// All tests use synthetic snapshot timelines to verify the detection algorithm
/// without any I/O or database dependencies.
/// </summary>
public sealed class AnomalyDetectorTests
{
// Default thresholds matching appsettings.json defaults.
private const int DefaultGapSeconds = 60;
private const decimal DefaultThreshold = 0.30m;
private const int DefaultMinSnapshots = 3;
private static readonly EventId TestEventId = new("99999999");
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly DateTimeOffset BaseTime =
new(2026, 5, 10, 18, 0, 0, MoscowOffset);
private static AnomalyDetector DefaultDetector() =>
new(DefaultGapSeconds, DefaultThreshold, DefaultMinSnapshots);
// ── Helper factory methods ────────────────────────────────────────────────
/// <summary>Creates a live OddsSnapshot with Match Win bets for both sides.</summary>
private static OddsSnapshot MakeLiveSnapshot(
DateTimeOffset capturedAt,
decimal rate1,
decimal rate2,
decimal? rateDraw = null)
{
var bets = new List<Bet>
{
new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(rate1)),
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(rate2)),
};
if (rateDraw.HasValue)
bets.Add(new Bet(MatchScope.Instance, BetType.Draw, Side.Draw, null, new OddsRate(rateDraw.Value)));
return new OddsSnapshot(TestEventId, capturedAt, OddsSource.Live, bets);
}
/// <summary>Creates a pre-match OddsSnapshot (should be ignored by detector).</summary>
private static OddsSnapshot MakePreMatchSnapshot(DateTimeOffset capturedAt) =>
new(TestEventId, capturedAt, 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(2.5m)),
});
// ── Test: empty snapshot list → 0 anomalies ──────────────────────────────
[Fact]
public void Should_ReturnEmpty_When_SnapshotListIsEmpty()
{
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, Array.Empty<OddsSnapshot>());
result.Should().BeEmpty();
}
// ── Test: below minSnapshotCount → 0 anomalies ───────────────────────────
[Fact]
public void Should_ReturnEmpty_When_FewerThanMinSnapshotCountLiveSnapshots()
{
// Only 2 live snapshots; minSnapshotCount = 3.
var snapshots = new[]
{
MakeLiveSnapshot(BaseTime, rate1: 1.5m, rate2: 2.5m),
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.55m, rate2: 2.45m),
};
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, snapshots);
result.Should().BeEmpty("fewer than minSnapshotCount=3 live snapshots");
}
// ── Test: pre-match-only snapshots → 0 anomalies ─────────────────────────
[Fact]
public void Should_ReturnEmpty_When_AllSnapshotsArePreMatch()
{
// 5 pre-match snapshots — should all be ignored.
var snapshots = Enumerable.Range(0, 5)
.Select(i => MakePreMatchSnapshot(BaseTime.AddSeconds(i * 30)))
.ToArray();
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, snapshots);
result.Should().BeEmpty("only pre-match snapshots — live filter returns 0");
}
// ── Test: no suspension (regular intervals) → 0 anomalies ────────────────
[Fact]
public void Should_ReturnEmpty_When_NoSuspensionGapExists()
{
// 5 snapshots spaced 30 s apart — well below the 60 s gap threshold.
var snapshots = Enumerable.Range(0, 5)
.Select(i => MakeLiveSnapshot(
BaseTime.AddSeconds(i * 30),
rate1: 1.5m, rate2: 2.5m))
.ToArray();
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, snapshots);
result.Should().BeEmpty("no gap exceeds suspensionGapSeconds=60");
}
// ── Test: suspension but odds shift below threshold → 0 anomalies ─────────
[Fact]
public void Should_ReturnEmpty_When_SuspensionButOddsShiftBelowThreshold()
{
// Pre-suspension: Side1 slightly favoured.
// Post-suspension: Side1 still favoured, tiny shift well below 0.30 threshold.
var snapshots = new[]
{
MakeLiveSnapshot(BaseTime, rate1: 1.5m, rate2: 2.5m), // pre
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.52m, rate2: 2.48m), // pre (within normal gap)
// 90 s gap = suspension
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 1.55m, rate2: 2.45m), // post — small shift
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 1.56m, rate2: 2.44m),
};
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, snapshots);
result.Should().BeEmpty("odds shift is far below 0.30 threshold");
}
// ── Test: suspension + favourite flipped → 1 anomaly ─────────────────────
[Fact]
public void Should_DetectOneAnomaly_When_SuspensionWithFavouriteFlip()
{
// Pre-suspension: Side1 favourite — rate1=1.3, rate2=4.0
// rawP1=1/1.3≈0.769, rawP2=1/4.0=0.25, total≈1.019
// p1_pre≈0.755, p2_pre≈0.245 → favourite = Side1
//
// Post-suspension: Side2 favourite — rate1=4.0, rate2=1.3
// rawP1=0.25, rawP2≈0.769, total≈1.019
// p1_post≈0.245, p2_post≈0.755 → favourite = Side2 (changed!)
//
// flipScore = max(|0.245-0.755|, |0.755-0.245|) ≈ 0.510 ≥ 0.30 ✓
var snapshots = new[]
{
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m),
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m),
// 90 s gap = suspension (> 60 s threshold)
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), // flipped
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m),
};
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, snapshots);
result.Should().HaveCount(1);
result[0].EventId.Should().Be(TestEventId);
result[0].Kind.Should().Be(AnomalyKind.SuspensionFlip);
result[0].Score.Should().BeGreaterThanOrEqualTo(0.30m);
result[0].Score.Should().BeLessThanOrEqualTo(1.0m);
}
// ── Test: score calculation is accurate ──────────────────────────────────
[Fact]
public void Should_ComputeCorrectFlipScore_ForKnownInputs()
{
// Pre: rate1=1.5, rate2=2.5
// rawP1 = 1/1.5 ≈ 0.6667, rawP2 = 1/2.5 = 0.4, total ≈ 1.0667
// p1_pre ≈ 0.6667/1.0667 ≈ 0.625, p2_pre ≈ 0.4/1.0667 ≈ 0.375
//
// Post: rate1=2.5, rate2=1.5
// rawP1 = 0.4, rawP2 ≈ 0.6667, total ≈ 1.0667
// p1_post ≈ 0.375, p2_post ≈ 0.625
//
// flipScore = max(|0.375-0.625|, |0.625-0.375|) = 0.25 → but wait, with
// the normalised values both deltas equal |0.625-0.375|=0.25. Hmm that's < 0.30.
//
// Use steeper rates to guarantee > 0.30:
// Pre: rate1=1.3, rate2=4.0 → rawP1=0.769, rawP2=0.25, total=1.019 → p1≈0.755, p2≈0.245
// Post: rate1=4.0, rate2=1.3 → rawP1=0.25, rawP2=0.769, total=1.019 → p1≈0.245, p2≈0.755
// flipScore = max(|0.245-0.755|, |0.755-0.245|) = 0.510
var snapshots = new[]
{
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m),
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m),
// suspension gap
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m),
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m),
};
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, snapshots);
result.Should().HaveCount(1);
// Expected flip score ≈ 0.510 — verify within a reasonable tolerance.
result[0].Score.Should().BeGreaterThan(0.45m, "steeper rates produce a large flip");
result[0].Score.Should().BeLessThanOrEqualTo(1.0m);
}
// ── Test: tennis (no draw) → works correctly ──────────────────────────────
[Fact]
public void Should_DetectAnomaly_When_TwoWayMarketWithFavouriteFlip()
{
// Tennis-style: no draw market. Rates flip from 1.3/4.0 to 4.0/1.3.
var snapshots = new[]
{
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m, rateDraw: null),
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m, rateDraw: null),
// suspension gap
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m, rateDraw: null),
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m, rateDraw: null),
};
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, snapshots);
result.Should().HaveCount(1, "2-way market flip should be detected");
result[0].Score.Should().BeGreaterThan(0.45m);
}
// ── Test: multiple suspensions → multiple anomalies ───────────────────────
[Fact]
public void Should_DetectMultipleAnomalies_When_MultipleSuspensionsOccur()
{
// Two separate suspension intervals, each with a clear flip.
var snapshots = new[]
{
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m), // period A start
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m),
// Suspension 1
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), // flipped
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m),
// Suspension 2
MakeLiveSnapshot(BaseTime.AddSeconds(240), rate1: 1.3m, rate2: 4.0m), // flipped back
MakeLiveSnapshot(BaseTime.AddSeconds(270), rate1: 1.3m, rate2: 4.0m),
};
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, snapshots);
result.Should().HaveCount(2, "two qualifying suspension intervals produce two anomalies");
}
// ── Test: EvidenceJson contains expected fields ───────────────────────────
[Fact]
public void Should_IncludeEvidenceJson_WithProbabilityVectorsAndRates()
{
var snapshots = new[]
{
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m),
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m),
// suspension gap
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m),
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m),
};
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, snapshots);
result.Should().HaveCount(1);
var evidenceJson = result[0].EvidenceJson;
evidenceJson.Should().NotBeNullOrWhiteSpace();
// Parse and verify key fields.
using var doc = JsonDocument.Parse(evidenceJson);
var root = doc.RootElement;
root.TryGetProperty("suspensionGapSeconds", out _).Should().BeTrue("gap seconds required");
root.TryGetProperty("preSuspension", out var pre).Should().BeTrue();
root.TryGetProperty("postSuspension", out var post).Should().BeTrue();
pre.TryGetProperty("capturedAt", out _).Should().BeTrue();
pre.TryGetProperty("p1", out _).Should().BeTrue();
pre.TryGetProperty("p2", out _).Should().BeTrue();
pre.TryGetProperty("rate1", out _).Should().BeTrue();
pre.TryGetProperty("rate2", out _).Should().BeTrue();
post.TryGetProperty("p1", out _).Should().BeTrue();
post.TryGetProperty("p2", out _).Should().BeTrue();
post.TryGetProperty("rate1", out _).Should().BeTrue();
post.TryGetProperty("rate2", out _).Should().BeTrue();
}
// ── Test: determinism — same input produces same output ───────────────────
[Fact]
public void Should_BeDeterministic_SameInputProducesSameOutput()
{
var snapshots = new[]
{
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m),
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m),
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m),
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m),
};
var sut = DefaultDetector();
var result1 = sut.Detect(TestEventId, snapshots);
var result2 = sut.Detect(TestEventId, snapshots);
result1.Should().HaveCount(result2.Count);
result1[0].Score.Should().Be(result2[0].Score);
result1[0].Kind.Should().Be(result2[0].Kind);
result1[0].EventId.Should().Be(result2[0].EventId);
}
// ── Test: three-way market (with draw) — flip when draw becomes favourite ─
[Fact]
public void Should_DetectAnomaly_When_FavouriteChangesFromSide1ToDraw()
{
// Pre: Side1 is slight favourite with rate 1.6 (draw 3.5, side2 4.0).
// Post: Draw becomes favourite with rate 2.2 (side1 2.8, side2 3.5).
// This represents an unusual suspension flip where the draw becomes favourite.
// We need enough of a flip in p1 vs pDraw.
// Pre: raw p1=1/1.6=0.625, pDraw=1/3.5=0.286, p2=1/4.0=0.25, total=1.161
// norm: p1=0.539, pDraw=0.246, p2=0.215 → favourite=Side1
// Post: raw p1=1/1.3=0.769, pDraw=1/2.0=0.5, p2=1/6.0=0.167, total=1.436
// norm: p1=0.535, pDraw=0.348, p2=0.116 → favourite still Side1
// Need to make draw the favourite:
// Post: raw p1=1/4.0=0.25, pDraw=1/1.5=0.667, p2=1/6.0=0.167, total=1.083
// norm: p1=0.231, pDraw=0.616, p2=0.154 → favourite=Draw
// flipScore = max(|0.231-0.539|, |0.616-0.246|, |0.154-0.215|)
// = max(0.308, 0.370, 0.061) = 0.370 ≥ 0.30 ✓ AND favourite changed ✓
var snapshots = new[]
{
MakeLiveSnapshot(BaseTime, rate1: 1.6m, rate2: 4.0m, rateDraw: 3.5m),
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.6m, rate2: 4.0m, rateDraw: 3.5m),
// suspension gap
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 6.0m, rateDraw: 1.5m),
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 6.0m, rateDraw: 1.5m),
};
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, snapshots);
result.Should().HaveCount(1, "favourite changed from Side1 to Draw → qualifies");
result[0].Score.Should().BeGreaterThanOrEqualTo(0.30m);
// Verify that pDraw is present in the evidence JSON.
using var doc = JsonDocument.Parse(result[0].EvidenceJson);
var root = doc.RootElement;
root.GetProperty("preSuspension").TryGetProperty("pDraw", out _).Should().BeTrue();
root.GetProperty("postSuspension").TryGetProperty("pDraw", out _).Should().BeTrue();
}
// ── Test: mixed pre-match and live snapshots — only live are analysed ─────
[Fact]
public void Should_IgnorePreMatchSnapshots_When_MixedSourcesProvided()
{
// 3 pre-match snapshots (should be ignored) + 2 live (below minSnapshotCount=3).
var snapshots = new[]
{
MakePreMatchSnapshot(BaseTime),
MakePreMatchSnapshot(BaseTime.AddSeconds(30)),
MakePreMatchSnapshot(BaseTime.AddSeconds(60)),
MakeLiveSnapshot(BaseTime.AddSeconds(90), rate1: 1.3m, rate2: 4.0m),
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), // would be a flip if 3+ snapshots
};
var sut = DefaultDetector();
var result = sut.Detect(TestEventId, snapshots);
result.Should().BeEmpty("only 2 live snapshots after filtering — below minSnapshotCount=3");
}
}