Compare commits
2 Commits
e307a54bec
...
b67030ae7f
| Author | SHA1 | Date | |
|---|---|---|---|
| b67030ae7f | |||
| c9eee9f907 |
@@ -14,7 +14,7 @@ namespace Marathon.Application.UseCases;
|
|||||||
/// <item>Loads all tracked events.</item>
|
/// <item>Loads all tracked events.</item>
|
||||||
/// <item>For each event, fetches its last-24-hour live snapshots.</item>
|
/// <item>For each event, fetches its last-24-hour live snapshots.</item>
|
||||||
/// <item>Runs <see cref="AnomalyDetector"/> over the snapshot timeline.</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>
|
/// <item>Persists any new anomalies that have not already been stored (dedup by EventId + Kind + DetectedAt minute-window).</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -138,8 +138,8 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
List<Anomaly> existingForEvent,
|
List<Anomaly> existingForEvent,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Fan out over every detector kind; dedup below keys on EventId + Kind so the
|
// Fan out over every detector; dedup below keys on EventId + Kind so the flip,
|
||||||
// flip and steam signals for one event persist independently.
|
// steam, and freeze signals for one event persist independently.
|
||||||
var detected = detectors
|
var detected = detectors
|
||||||
.SelectMany(d => d.Detect(ev.Id, snapshots))
|
.SelectMany(d => d.Detect(ev.Id, snapshots))
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -154,11 +154,15 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
await _anomalyRepo.AddAsync(anomaly, ct);
|
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.
|
existingForEvent.Add(anomaly); // Keep local list in sync so the same cycle doesn't re-add.
|
||||||
persisted++;
|
persisted++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One write per event rather than per anomaly — with three detectors an event
|
||||||
|
// can yield several new anomalies in a single cycle.
|
||||||
|
if (persisted > 0)
|
||||||
|
await _anomalyRepo.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return persisted;
|
return persisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Marathon.Application.Storage;
|
|||||||
using Marathon.Domain.AnomalyDetection;
|
using Marathon.Domain.AnomalyDetection;
|
||||||
using Marathon.Domain.Backtesting;
|
using Marathon.Domain.Backtesting;
|
||||||
using Marathon.Domain.Entities;
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
@@ -95,6 +96,11 @@ public sealed class RunBacktestUseCase
|
|||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// Only directional kinds are betting signals; SuspensionFreeze (favourite
|
||||||
|
// unchanged) is informational and must not be staked or it would skew ROI.
|
||||||
|
if (!anomaly.Kind.IsDirectional())
|
||||||
|
continue;
|
||||||
|
|
||||||
// Cannot simulate a bet whose event hasn't been graded yet.
|
// Cannot simulate a bet whose event hasn't been graded yet.
|
||||||
if (!resultLookup.TryGetValue(anomaly.EventId, out var result))
|
if (!resultLookup.TryGetValue(anomaly.EventId, out var result))
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -63,6 +63,25 @@ public static class AnomalyOutcomeEvaluator
|
|||||||
var preFav = data.PreSuspension.Favourite;
|
var preFav = data.PreSuspension.Favourite;
|
||||||
var postFav = data.PostSuspension.Favourite;
|
var postFav = data.PostSuspension.Favourite;
|
||||||
|
|
||||||
|
// Non-directional kinds (e.g. SuspensionFreeze — the favourite did NOT change)
|
||||||
|
// make no side prediction. Grading them as "favourite won" would just measure the
|
||||||
|
// base favourite-win rate, polluting the hit-rate and score-bin calibration, so we
|
||||||
|
// leave them Unresolved (the favourites are still surfaced for display).
|
||||||
|
if (!anomaly.Kind.IsDirectional())
|
||||||
|
{
|
||||||
|
return new ResolvedAnomaly(
|
||||||
|
AnomalyId: anomaly.Id,
|
||||||
|
EventId: anomaly.EventId,
|
||||||
|
DetectedAt: anomaly.DetectedAt,
|
||||||
|
Score: anomaly.Score,
|
||||||
|
Kind: anomaly.Kind,
|
||||||
|
Sport: sport,
|
||||||
|
PreFlipFavourite: preFav,
|
||||||
|
PostFlipFavourite: postFav,
|
||||||
|
ActualWinner: result?.WinnerSide,
|
||||||
|
Outcome: AnomalyOutcomeKind.Unresolved);
|
||||||
|
}
|
||||||
|
|
||||||
if (result is null)
|
if (result is null)
|
||||||
{
|
{
|
||||||
return new ResolvedAnomaly(
|
return new ResolvedAnomaly(
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>Semantic classification of anomaly kinds.</summary>
|
||||||
|
public static class AnomalyKindExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the kind makes a <i>directional</i> prediction — a specific side/favourite
|
||||||
|
/// expected to win — that can be graded against the result and bet on in a backtest.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="AnomalyKind.SuspensionFlip"/> and <see cref="AnomalyKind.SteamMove"/> are
|
||||||
|
/// directional (they point at a favourite). <see cref="AnomalyKind.SuspensionFreeze"/> is
|
||||||
|
/// informational — the line did NOT move — so "predicting" the unchanged favourite would
|
||||||
|
/// merely measure the base favourite-win rate; it is excluded from outcome grading and
|
||||||
|
/// from backtest staking so it does not distort detector calibration.
|
||||||
|
/// </remarks>
|
||||||
|
public static bool IsDirectional(this AnomalyKind kind) => kind switch
|
||||||
|
{
|
||||||
|
AnomalyKind.SuspensionFlip => true,
|
||||||
|
AnomalyKind.SteamMove => true,
|
||||||
|
AnomalyKind.SuspensionFreeze => false,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,9 +25,10 @@ internal sealed class ExcelExporter : IExcelExporter
|
|||||||
string outputPath,
|
string outputPath,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// Load all snapshots in the date range with their bets eagerly
|
// Load all snapshots in the date range with their bets eagerly. Bounds use the
|
||||||
var fromStr = range.From.ToString("O");
|
// shared SqliteDateText encoding so they match the persisted CapturedAt keys.
|
||||||
var toStr = range.To.ToString("O");
|
var fromStr = SqliteDateText.Key(range.From);
|
||||||
|
var toStr = SqliteDateText.Key(range.To);
|
||||||
|
|
||||||
var snapshotEntities = await _db.Snapshots.AsNoTracking()
|
var snapshotEntities = await _db.Snapshots.AsNoTracking()
|
||||||
.Include(s => s.Bets)
|
.Include(s => s.Bets)
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ internal sealed class PlacedBetRepository : IPlacedBetRepository
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<PlacedBet>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
|
public async Task<IReadOnlyList<PlacedBet>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// PlacedAt is stored as ISO 8601 TEXT — same lexical-equals-chronological ordering
|
// PlacedAt is stored via SqliteDateText (O-format TEXT) — same lexical-equals-
|
||||||
// trick used in EventRepository.ListByDateRangeAsync.
|
// chronological ordering used across the repositories.
|
||||||
var fromStr = range.From.ToString("O");
|
var fromStr = SqliteDateText.Key(range.From);
|
||||||
var toStr = range.To.ToString("O");
|
var toStr = SqliteDateText.Key(range.To);
|
||||||
|
|
||||||
var entities = await _db.PlacedBets.AsNoTracking()
|
var entities = await _db.PlacedBets.AsNoTracking()
|
||||||
.Where(b => b.PlacedAt.CompareTo(fromStr) >= 0
|
.Where(b => b.PlacedAt.CompareTo(fromStr) >= 0
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ public sealed class BacktestForm
|
|||||||
{
|
{
|
||||||
if (StartingBankroll <= 0m) { error = "Bankroll must be positive."; return false; }
|
if (StartingBankroll <= 0m) { error = "Bankroll must be positive."; return false; }
|
||||||
if (MinScore is < 0m or > 1m) { error = "Min score must be in [0, 1]."; return false; }
|
if (MinScore is < 0m or > 1m) { error = "Min score must be in [0, 1]."; return false; }
|
||||||
|
// A one-sided range would be silently ignored (ToDateRange needs both bounds), so
|
||||||
|
// require both-or-neither and give the user explicit feedback.
|
||||||
|
if (From.HasValue != To.HasValue)
|
||||||
|
{ error = "Set both From and To dates, or leave both empty."; return false; }
|
||||||
if (From is { } f && To is { } t && f.Date > t.Date)
|
if (From is { } f && To is { } t && f.Date > t.Date)
|
||||||
{ error = "From date must be on or before To date."; return false; }
|
{ error = "From date must be on or before To date."; return false; }
|
||||||
switch (StakeRule)
|
switch (StakeRule)
|
||||||
|
|||||||
@@ -85,6 +85,25 @@ public sealed class RunBacktestUseCaseTests
|
|||||||
result.BetsPlaced.Should().BeGreaterThan(0, "the in-range graded anomaly produces a bet");
|
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]
|
[Fact]
|
||||||
public async Task Should_ReturnEmptyResult_When_NoAnomaliesExist()
|
public async Task Should_ReturnEmptyResult_When_NoAnomaliesExist()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,6 +73,27 @@ public sealed class AnomalyOutcomeEvaluatorTests
|
|||||||
verdict.ActualWinner.Should().Be(Side.Side1);
|
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]
|
[Fact]
|
||||||
public void Should_ReportMiss_When_DrawOccurred_AndPostFlipFavouriteIsNotDraw()
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
using Marathon.Domain.Entities;
|
using Marathon.Domain.Entities;
|
||||||
using Marathon.Domain.Enums;
|
using Marathon.Domain.Enums;
|
||||||
using Marathon.Domain.ValueObjects;
|
using Marathon.Domain.ValueObjects;
|
||||||
@@ -279,6 +280,92 @@ public sealed class RoundTripTests : IDisposable
|
|||||||
exception.Should().BeNull("PRAGMA journal_mode=WAL should execute without error");
|
exception.Should().BeNull("PRAGMA journal_mode=WAL should execute without error");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── New query methods — verified against real SQLite (lexical O-format ordering) ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Anomaly_DateRangeAndCountSince_FilterByDetectedAt()
|
||||||
|
{
|
||||||
|
DateTimeOffset At(int day) => new(2026, 5, day, 12, 0, 0, MoscowOffset);
|
||||||
|
|
||||||
|
await _eventRepo.AddAsync(BuildEvent("D100"));
|
||||||
|
await _eventRepo.SaveChangesAsync();
|
||||||
|
|
||||||
|
async Task AddAnomaly(DateTimeOffset at, decimal score) =>
|
||||||
|
await _anomalyRepo.AddAsync(new Anomaly(
|
||||||
|
Guid.NewGuid(), new EventId("D100"), at, AnomalyKind.SuspensionFlip, score, "{}"));
|
||||||
|
|
||||||
|
await AddAnomaly(At(1), 0.50m);
|
||||||
|
await AddAnomaly(At(5), 0.60m);
|
||||||
|
await AddAnomaly(At(10), 0.70m);
|
||||||
|
await _anomalyRepo.SaveChangesAsync();
|
||||||
|
_fixture.DbContext.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
// Inclusive [day3..day7] → only the day-5 anomaly.
|
||||||
|
var inRange = await _anomalyRepo.ListByDateRangeAsync(At(3), At(7));
|
||||||
|
inRange.Should().ContainSingle();
|
||||||
|
inRange[0].Score.Should().Be(0.60m);
|
||||||
|
|
||||||
|
// Open-ended returns newest-first.
|
||||||
|
var all = await _anomalyRepo.ListByDateRangeAsync(null, null);
|
||||||
|
all.Select(a => a.DetectedAt).Should().BeInDescendingOrder();
|
||||||
|
|
||||||
|
// CountSinceAsync is strictly-after → only day-10.
|
||||||
|
(await _anomalyRepo.CountSinceAsync(At(5))).Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Snapshot_CountSinceAndListByEvents_FilterAndGroup()
|
||||||
|
{
|
||||||
|
DateTimeOffset At(int day) => new(2026, 5, day, 12, 0, 0, MoscowOffset);
|
||||||
|
|
||||||
|
await _eventRepo.AddAsync(BuildEvent("S100"));
|
||||||
|
await _eventRepo.AddAsync(BuildEvent("S200"));
|
||||||
|
await _eventRepo.SaveChangesAsync();
|
||||||
|
|
||||||
|
OddsSnapshot Snap(string id, DateTimeOffset at) =>
|
||||||
|
new(new EventId(id), at, OddsSource.Live,
|
||||||
|
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.90m)) });
|
||||||
|
|
||||||
|
await _snapshotRepo.AddAsync(Snap("S100", At(1)));
|
||||||
|
await _snapshotRepo.AddAsync(Snap("S100", At(5)));
|
||||||
|
await _snapshotRepo.AddAsync(Snap("S200", At(6)));
|
||||||
|
await _snapshotRepo.SaveChangesAsync();
|
||||||
|
_fixture.DbContext.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
// Inclusive >= day3 → day5 (S100) + day6 (S200).
|
||||||
|
(await _snapshotRepo.CountSinceAsync(At(3))).Should().Be(2);
|
||||||
|
|
||||||
|
var byEvent = await _snapshotRepo.ListByEventsAsync(
|
||||||
|
new[] { new EventId("S100"), new EventId("S200") }, At(1).AddDays(-1), At(10));
|
||||||
|
byEvent[new EventId("S100")].Should().HaveCount(2);
|
||||||
|
byEvent[new EventId("S200")].Should().HaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Event_QueryAndGetMany_FilterAndBatch()
|
||||||
|
{
|
||||||
|
DateTimeOffset At(int day) => new(2026, 5, day, 12, 0, 0, MoscowOffset);
|
||||||
|
|
||||||
|
await _eventRepo.AddAsync(BuildEvent("Q1", At(1))); // sport 11
|
||||||
|
await _eventRepo.AddAsync(BuildEvent("Q2", At(5))); // sport 11, in range
|
||||||
|
await _eventRepo.AddAsync(new Event(
|
||||||
|
new EventId("Q3"), new SportCode(6), "England", "premier-league", "", At(5), "Home", "Away"));
|
||||||
|
await _eventRepo.SaveChangesAsync();
|
||||||
|
_fixture.DbContext.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
// QueryAsync: [day3..day7] + sport 11 → only Q2 (Q3 is sport 6).
|
||||||
|
var queried = await _eventRepo.QueryAsync(
|
||||||
|
new EventQuery(new DateRange(At(3), At(7)), SportCodes: new[] { 11 }));
|
||||||
|
queried.Should().ContainSingle();
|
||||||
|
queried[0].Id.Value.Should().Be("Q2");
|
||||||
|
|
||||||
|
// GetManyAsync: batched; unknown id is simply absent.
|
||||||
|
var many = await _eventRepo.GetManyAsync(
|
||||||
|
new[] { new EventId("Q1"), new EventId("Q3"), new EventId("missing") });
|
||||||
|
many.Should().HaveCount(2);
|
||||||
|
many.Keys.Select(k => k.Value).Should().BeEquivalentTo(new[] { "Q1", "Q3" });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static Event BuildEvent(string id, DateTimeOffset? scheduledAt = null) =>
|
private static Event BuildEvent(string id, DateTimeOffset? scheduledAt = null) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user