fix(anomaly): exclude non-directional kinds from grading and backtest

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.
This commit is contained in:
2026-05-29 01:25:16 +03:00
parent e307a54bec
commit c9eee9f907
6 changed files with 105 additions and 0 deletions
@@ -85,6 +85,25 @@ public sealed class RunBacktestUseCaseTests
result.BetsPlaced.Should().BeGreaterThan(0, "the in-range graded anomaly produces a bet");
}
[Fact]
public async Task Should_SkipNonDirectionalAnomalies_When_BuildingCandidates()
{
// A SuspensionFreeze anomaly (favourite unchanged) must not be staked.
var id = new EventId("88888888");
var freeze = new Anomaly(
Guid.NewGuid(), id, BaseTime, AnomalyKind.SuspensionFreeze, 0.9m, FlipEvidence);
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { freeze }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None);
result.BetsPlaced.Should().Be(0, "SuspensionFreeze is non-directional and must not be staked");
}
[Fact]
public async Task Should_ReturnEmptyResult_When_NoAnomaliesExist()
{
@@ -73,6 +73,27 @@ public sealed class AnomalyOutcomeEvaluatorTests
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()
{
@@ -0,0 +1,16 @@
using FluentAssertions;
using Marathon.Domain.Enums;
namespace Marathon.Domain.Tests.Enums;
public sealed class AnomalyKindExtensionsTests
{
[Theory]
[InlineData(AnomalyKind.SuspensionFlip, true)]
[InlineData(AnomalyKind.SteamMove, true)]
[InlineData(AnomalyKind.SuspensionFreeze, false)]
public void IsDirectional_Should_ClassifyKinds(AnomalyKind kind, bool expected)
{
kind.IsDirectional().Should().Be(expected);
}
}