c9eee9f907
Review follow-up (HIGH): the three detectors fed the same evaluator/backtest, but SuspensionFreeze is non-directional (favourite unchanged) — grading it as "favourite won" polluted the hit-rate with the base favourite-win rate, and its high frozen-ness score always cleared the backtest threshold. - Add AnomalyKind.IsDirectional() (flip + steam = true, freeze = false). - AnomalyOutcomeEvaluator returns Unresolved for non-directional kinds (favourites still surfaced for display) so they don't distort calibration. - RunBacktestUseCase skips non-directional anomalies when building candidates. - Tests for the classification, the evaluator path, and the backtest skip.
200 lines
7.6 KiB
C#
200 lines
7.6 KiB
C#
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="AnomalyOutcomeEvaluator"/> covering the join with
|
|
/// <see cref="EventResult"/> for hit / miss / unresolved verdicts.
|
|
/// </summary>
|
|
public sealed class AnomalyOutcomeEvaluatorTests
|
|
{
|
|
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
|
private static readonly EventId DefaultEventId = new("12345678");
|
|
|
|
private const string ThreeWayFlipJson = """
|
|
{
|
|
"suspensionGapSeconds": 90,
|
|
"preSuspension": {
|
|
"capturedAt": "2026-05-10T18:00:00+03:00",
|
|
"p1": 0.55, "pDraw": 0.20, "p2": 0.25,
|
|
"rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0
|
|
},
|
|
"postSuspension": {
|
|
"capturedAt": "2026-05-10T18:02:30+03:00",
|
|
"p1": 0.25, "pDraw": 0.20, "p2": 0.55,
|
|
"rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8
|
|
}
|
|
}
|
|
""";
|
|
|
|
private static Anomaly MakeAnomaly(string evidenceJson, decimal score = 0.5m) =>
|
|
new(
|
|
Id: Guid.NewGuid(),
|
|
EventId: DefaultEventId,
|
|
DetectedAt: new DateTimeOffset(2026, 5, 10, 18, 5, 0, MoscowOffset),
|
|
Kind: AnomalyKind.SuspensionFlip,
|
|
Score: score,
|
|
EvidenceJson: evidenceJson);
|
|
|
|
private static EventResult MakeResult(Side winner, int s1 = 1, int s2 = 1) =>
|
|
new(DefaultEventId, s1, s2, winner, DateTimeOffset.UtcNow);
|
|
|
|
[Fact]
|
|
public void Should_ReportHit_When_PostFlipFavourite_Wins()
|
|
{
|
|
// Post-flip favourite = Side2; result = Side2 wins → Hit.
|
|
var anomaly = MakeAnomaly(ThreeWayFlipJson, score: 0.65m);
|
|
var result = MakeResult(Side.Side2, s1: 0, s2: 2);
|
|
|
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(6), result);
|
|
|
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit);
|
|
verdict.PreFlipFavourite.Should().Be(Side.Side1);
|
|
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
|
verdict.ActualWinner.Should().Be(Side.Side2);
|
|
verdict.Sport!.Value.Should().Be(6);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_ReportMiss_When_PostFlipFavourite_Loses()
|
|
{
|
|
// Post-flip favourite = Side2; result = Side1 wins → Miss (detector wrong).
|
|
var anomaly = MakeAnomaly(ThreeWayFlipJson);
|
|
var result = MakeResult(Side.Side1, s1: 2, s2: 0);
|
|
|
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(11), result);
|
|
|
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss);
|
|
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
|
verdict.ActualWinner.Should().Be(Side.Side1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_ReportUnresolved_When_KindIsNonDirectional()
|
|
{
|
|
// SuspensionFreeze is informational (the favourite did not change) — it must NOT be
|
|
// graded hit/miss even when a result exists, or its base favourite-win rate would
|
|
// pollute the calibration. Favourites are still surfaced for display.
|
|
var anomaly = new Anomaly(
|
|
Id: Guid.NewGuid(),
|
|
EventId: DefaultEventId,
|
|
DetectedAt: new DateTimeOffset(2026, 5, 10, 18, 5, 0, MoscowOffset),
|
|
Kind: AnomalyKind.SuspensionFreeze,
|
|
Score: 0.9m,
|
|
EvidenceJson: ThreeWayFlipJson);
|
|
var result = MakeResult(Side.Side2, s1: 0, s2: 2);
|
|
|
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(6), result);
|
|
|
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved);
|
|
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_ReportMiss_When_DrawOccurred_AndPostFlipFavouriteIsNotDraw()
|
|
{
|
|
var anomaly = MakeAnomaly(ThreeWayFlipJson);
|
|
var result = MakeResult(Side.Draw, s1: 1, s2: 1);
|
|
|
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result);
|
|
|
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss);
|
|
verdict.ActualWinner.Should().Be(Side.Draw);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_ReportUnresolved_When_ResultIsNull()
|
|
{
|
|
var anomaly = MakeAnomaly(ThreeWayFlipJson);
|
|
|
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result: null);
|
|
|
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved);
|
|
verdict.ActualWinner.Should().BeNull();
|
|
// Pre/post favourites still computed for display.
|
|
verdict.PreFlipFavourite.Should().Be(Side.Side1);
|
|
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_ReportUnresolved_When_EvidenceJsonIsMalformed()
|
|
{
|
|
var anomaly = MakeAnomaly("{malformed");
|
|
var result = MakeResult(Side.Side1);
|
|
|
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result);
|
|
|
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved,
|
|
"evidence cannot be parsed so we cannot judge the prediction");
|
|
verdict.PreFlipFavourite.Should().BeNull(
|
|
"fabricated favourites would mislead any consumer that reads the unresolved branch");
|
|
verdict.PostFlipFavourite.Should().BeNull();
|
|
verdict.ActualWinner.Should().Be(Side.Side1, "the result side is still known and surfaced");
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_ReportHit_For_TwoWayTennis_When_PostFlipFavouriteWins()
|
|
{
|
|
const string twoWayJson = """
|
|
{
|
|
"suspensionGapSeconds": 75,
|
|
"preSuspension": {
|
|
"capturedAt": "2026-05-10T18:00:00+03:00",
|
|
"p1": 0.70, "p2": 0.30, "rate1": 1.4, "rate2": 3.3
|
|
},
|
|
"postSuspension": {
|
|
"capturedAt": "2026-05-10T18:01:30+03:00",
|
|
"p1": 0.30, "p2": 0.70, "rate1": 3.3, "rate2": 1.4
|
|
}
|
|
}
|
|
""";
|
|
var anomaly = MakeAnomaly(twoWayJson, score: 0.55m);
|
|
var result = MakeResult(Side.Side2, s1: 0, s2: 2);
|
|
|
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result);
|
|
|
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit);
|
|
verdict.PostFlipFavourite.Should().Be(Side.Side2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_ReportUnresolved_When_TwoWayMarket_Has_DrawWinner()
|
|
{
|
|
// Tennis cannot draw — if the result is Draw the data is inconsistent
|
|
// with the evidence and we refuse to grade rather than silently miss-classify.
|
|
const string twoWayJson = """
|
|
{
|
|
"suspensionGapSeconds": 75,
|
|
"preSuspension": {
|
|
"capturedAt": "2026-05-10T18:00:00+03:00",
|
|
"p1": 0.70, "p2": 0.30, "rate1": 1.4, "rate2": 3.3
|
|
},
|
|
"postSuspension": {
|
|
"capturedAt": "2026-05-10T18:01:30+03:00",
|
|
"p1": 0.30, "p2": 0.70, "rate1": 3.3, "rate2": 1.4
|
|
}
|
|
}
|
|
""";
|
|
var anomaly = MakeAnomaly(twoWayJson, score: 0.55m);
|
|
var result = MakeResult(Side.Draw);
|
|
|
|
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result);
|
|
|
|
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved);
|
|
verdict.ActualWinner.Should().Be(Side.Draw);
|
|
verdict.PostFlipFavourite.Should().Be(Side.Side2,
|
|
"favourite is still computed for display, just not graded");
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_Throw_When_AnomalyIsNull()
|
|
{
|
|
var act = () => AnomalyOutcomeEvaluator.Evaluate(null!, null, null);
|
|
act.Should().Throw<ArgumentNullException>();
|
|
}
|
|
}
|