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:
@@ -0,0 +1,217 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.Configuration;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
|
||||
namespace Marathon.Application.Tests.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DetectAnomaliesUseCase"/> using NSubstitute mocks.
|
||||
/// </summary>
|
||||
public sealed class DetectAnomaliesUseCaseTests
|
||||
{
|
||||
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
|
||||
private readonly ISnapshotRepository _snapshotRepo = Substitute.For<ISnapshotRepository>();
|
||||
private readonly IAnomalyRepository _anomalyRepo = Substitute.For<IAnomalyRepository>();
|
||||
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
private static readonly DateTimeOffset BaseTime =
|
||||
new(2026, 5, 10, 18, 0, 0, MoscowOffset);
|
||||
|
||||
/// <summary>Default options matching appsettings.json.</summary>
|
||||
private static IOptions<AnomalyOptions> DefaultOptions(
|
||||
int gapSeconds = 60,
|
||||
decimal threshold = 0.30m,
|
||||
int minSnapshots = 3) =>
|
||||
Options.Create(new AnomalyOptions
|
||||
{
|
||||
SuspensionGapSeconds = gapSeconds,
|
||||
OddsFlipThreshold = threshold,
|
||||
MinSnapshotCount = minSnapshots,
|
||||
DetectionIntervalSeconds = 60,
|
||||
});
|
||||
|
||||
private DetectAnomaliesUseCase CreateSut(IOptions<AnomalyOptions>? opts = null) =>
|
||||
new(_eventRepo, _snapshotRepo, _anomalyRepo,
|
||||
opts ?? DefaultOptions(),
|
||||
NullLogger<DetectAnomaliesUseCase>.Instance);
|
||||
|
||||
// ── Helper: build a snapshot timeline with a clear flip ───────────────────
|
||||
|
||||
private static IReadOnlyList<OddsSnapshot> BuildFlipTimeline(EventId eventId)
|
||||
{
|
||||
// 4 live snapshots: first two normal, then 90s gap (>60s threshold),
|
||||
// then two with flipped odds. Rates 1.3/4.0 → 4.0/1.3 produce a large flip score.
|
||||
static OddsSnapshot MakeSnap(EventId id, DateTimeOffset at, decimal r1, decimal r2) =>
|
||||
new(id, at, 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)),
|
||||
});
|
||||
|
||||
return new[]
|
||||
{
|
||||
MakeSnap(eventId, BaseTime, 1.3m, 4.0m),
|
||||
MakeSnap(eventId, BaseTime.AddSeconds(30), 1.3m, 4.0m),
|
||||
MakeSnap(eventId, BaseTime.AddSeconds(120), 4.0m, 1.3m), // flipped — 90 s gap
|
||||
MakeSnap(eventId, BaseTime.AddSeconds(150), 4.0m, 1.3m),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test 1: Iterates events, detects, and persists new anomalies ──────────
|
||||
|
||||
[Fact]
|
||||
public async Task Should_PersistNewAnomalies_When_FlipDetectedForEvent()
|
||||
{
|
||||
// Arrange
|
||||
var eventId = new EventId("11111111");
|
||||
var ev = TestFixtures.MakeEvent(eventId.Value);
|
||||
|
||||
_eventRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { ev }.ToList().AsReadOnly());
|
||||
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(eventId, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(BuildFlipTimeline(eventId));
|
||||
|
||||
// No existing anomalies → dedup will not filter anything.
|
||||
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
var count = await sut.ExecuteAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
count.Should().Be(1, "one flip was detected");
|
||||
await _anomalyRepo.Received(1).AddAsync(Arg.Any<Anomaly>(), Arg.Any<CancellationToken>());
|
||||
await _anomalyRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Test 2: Deduplication — already-persisted anomaly is not re-added ─────
|
||||
|
||||
[Fact]
|
||||
public async Task Should_SkipAlreadyPersistedAnomalies_When_DuplicateDetected()
|
||||
{
|
||||
// Arrange
|
||||
var eventId = new EventId("22222222");
|
||||
var ev = TestFixtures.MakeEvent(eventId.Value);
|
||||
|
||||
_eventRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { ev }.ToList().AsReadOnly());
|
||||
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(eventId, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(BuildFlipTimeline(eventId));
|
||||
|
||||
// Existing anomaly with same EventId, Kind=SuspensionFlip, and DetectedAt ≈ now (within dedup window).
|
||||
var existingAnomaly = new Anomaly(
|
||||
Id: Guid.NewGuid(),
|
||||
EventId: eventId,
|
||||
DetectedAt: DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3)),
|
||||
Kind: AnomalyKind.SuspensionFlip,
|
||||
Score: 0.5m,
|
||||
EvidenceJson: "{\"dummy\":true}");
|
||||
|
||||
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { existingAnomaly }.ToList().AsReadOnly());
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
var count = await sut.ExecuteAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
count.Should().Be(0, "anomaly already exists — dedup prevents re-adding");
|
||||
await _anomalyRepo.DidNotReceive().AddAsync(Arg.Any<Anomaly>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Test 3: Per-event failure does not abort the cycle ────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Should_ContinueAfterPerEventFailure_And_ReturnPartialCount()
|
||||
{
|
||||
// Arrange: two events — first throws on snapshot load, second has a detectable flip.
|
||||
var ev1Id = new EventId("33333333");
|
||||
var ev2Id = new EventId("44444444");
|
||||
var ev1 = TestFixtures.MakeEvent(ev1Id.Value);
|
||||
var ev2 = TestFixtures.MakeEvent(ev2Id.Value);
|
||||
|
||||
_eventRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { ev1, ev2 }.ToList().AsReadOnly());
|
||||
|
||||
// Event 1 — snapshot load throws.
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(ev1Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("DB error for event 1"));
|
||||
|
||||
// Event 2 — clean flip timeline.
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(ev2Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(BuildFlipTimeline(ev2Id));
|
||||
|
||||
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act — must not throw despite event 1 failing.
|
||||
var act = async () => await sut.ExecuteAsync(CancellationToken.None);
|
||||
await act.Should().NotThrowAsync();
|
||||
|
||||
var count = await sut.ExecuteAsync(CancellationToken.None);
|
||||
|
||||
// Assert: event 2 succeeded — at least 1 anomaly persisted.
|
||||
count.Should().BeGreaterThan(0, "event 2 should succeed despite event 1 failure");
|
||||
}
|
||||
|
||||
// ── Test 4: Returns count of new anomalies across multiple events ──────────
|
||||
|
||||
[Fact]
|
||||
public async Task Should_ReturnTotalNewAnomalyCount_When_MultipleEventsHaveFlips()
|
||||
{
|
||||
// Arrange: two events, both with a flip.
|
||||
var ev1Id = new EventId("55555555");
|
||||
var ev2Id = new EventId("66666666");
|
||||
var ev1 = TestFixtures.MakeEvent(ev1Id.Value);
|
||||
var ev2 = TestFixtures.MakeEvent(ev2Id.Value);
|
||||
|
||||
_eventRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { ev1, ev2 }.ToList().AsReadOnly());
|
||||
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(ev1Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(BuildFlipTimeline(ev1Id));
|
||||
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(ev2Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(BuildFlipTimeline(ev2Id));
|
||||
|
||||
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
var count = await sut.ExecuteAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
count.Should().Be(2, "two events, one flip each → 2 new anomalies");
|
||||
await _anomalyRepo.Received(2).AddAsync(Arg.Any<Anomaly>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user