f622dadf95
Adds a background forward-test engine that records flat-stake "paper" bets for directional anomalies as they fire and settles them when results arrive, measuring the detector's live, out-of-sample edge — the antidote to backtest overfitting. The results UI is a follow-up. - Domain: PaperBet entity (Rate>1 / Stake>0 invariants, Open factory, SettleAgainst — Won pays stake x rate, else Lost) + AnomalyEvidenceSide.RateFor. - Application: OpenPaperBetsUseCase (directional + score gate, dedups by AnomalyId, picks the post-flip favourite and its locked-in rate) and SettlePaperBetsUseCase (Won when pick == winner else Lost; ungraded events stay open; batched result lookup). - Infrastructure: PaperBetEntity + config (TEXT decimals, unique AnomalyId index, Outcome index), repository, mapping, additive AddPaperBets migration, and PaperTradingWorker (config-gated, baseline since-marker, open+settle per cycle). - Config: PaperTradingOptions / appsettings PaperTrading (Enabled:false default). - 25 tests: domain settlement, both use cases, and a real-SQLite round-trip incl. the unique-AnomalyId double-open backstop.
111 lines
4.3 KiB
C#
111 lines
4.3 KiB
C#
using FluentAssertions;
|
|
using Marathon.Application.Abstractions;
|
|
using Marathon.Application.UseCases;
|
|
using Marathon.Domain.Entities;
|
|
using Marathon.Domain.Enums;
|
|
using Marathon.Domain.ValueObjects;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
|
|
namespace Marathon.Application.Tests.UseCases;
|
|
|
|
public sealed class OpenPaperBetsUseCaseTests
|
|
{
|
|
private static readonly TimeSpan Msk = TimeSpan.FromHours(3);
|
|
private static readonly DateTimeOffset T0 = new(2026, 5, 20, 18, 0, 0, Msk);
|
|
|
|
private readonly IAnomalyRepository _anomalies = Substitute.For<IAnomalyRepository>();
|
|
private readonly IPaperBetRepository _paperBets = Substitute.For<IPaperBetRepository>();
|
|
|
|
public OpenPaperBetsUseCaseTests()
|
|
{
|
|
// Default: nothing already forward-tested.
|
|
_paperBets
|
|
.GetExistingAnomalyIdsAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
|
|
.Returns(new HashSet<Guid>());
|
|
}
|
|
|
|
private OpenPaperBetsUseCase CreateSut() =>
|
|
new(_anomalies, _paperBets, NullLogger<OpenPaperBetsUseCase>.Instance);
|
|
|
|
// Flip: pre favourite Side1, post favourite Side2 @ 1.60.
|
|
private const string FlipToSide2 = """
|
|
{"suspensionGapSeconds":90,
|
|
"preSuspension":{"capturedAt":"2026-05-20T18:00:00+03:00","p1":0.6,"p2":0.4,"rate1":1.6,"rate2":2.5},
|
|
"postSuspension":{"capturedAt":"2026-05-20T18:02:00+03:00","p1":0.4,"p2":0.6,"rate1":2.5,"rate2":1.6}}
|
|
""";
|
|
|
|
private static Anomaly Anomaly(AnomalyKind kind, decimal score, string evidence) =>
|
|
new(Guid.NewGuid(), new EventId("26000001"), T0.AddMinutes(2), kind, score, evidence);
|
|
|
|
private void HaveAnomalies(params Anomaly[] anomalies) =>
|
|
_anomalies
|
|
.ListByDateRangeAsync(Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
|
|
.Returns(anomalies);
|
|
|
|
[Fact]
|
|
public async Task Opens_DirectionalAboveThreshold_WithPostFavouriteAndRate()
|
|
{
|
|
var a = Anomaly(AnomalyKind.SuspensionFlip, 0.7m, FlipToSide2);
|
|
HaveAnomalies(a);
|
|
|
|
var opened = await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), minScore: 0.5m, flatStake: 10m);
|
|
|
|
opened.Should().ContainSingle();
|
|
opened[0].AnomalyId.Should().Be(a.Id);
|
|
opened[0].PickedSide.Should().Be(Side.Side2);
|
|
opened[0].Rate.Should().Be(1.6m);
|
|
opened[0].Stake.Should().Be(10m);
|
|
opened[0].Outcome.Should().Be(BetOutcome.Pending);
|
|
await _paperBets.Received(1).AddAsync(Arg.Any<PaperBet>(), Arg.Any<CancellationToken>());
|
|
await _paperBets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Skips_NonDirectionalKinds()
|
|
{
|
|
HaveAnomalies(Anomaly(AnomalyKind.SuspensionFreeze, 0.9m, FlipToSide2));
|
|
|
|
var opened = await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m);
|
|
|
|
opened.Should().BeEmpty();
|
|
await _paperBets.DidNotReceive().AddAsync(Arg.Any<PaperBet>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Skips_BelowThreshold()
|
|
{
|
|
HaveAnomalies(Anomaly(AnomalyKind.SuspensionFlip, 0.3m, FlipToSide2));
|
|
|
|
(await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Skips_AnomaliesThatAlreadyHaveAPaperBet()
|
|
{
|
|
var a = Anomaly(AnomalyKind.SuspensionFlip, 0.7m, FlipToSide2);
|
|
HaveAnomalies(a);
|
|
_paperBets
|
|
.GetExistingAnomalyIdsAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
|
|
.Returns(new HashSet<Guid> { a.Id });
|
|
|
|
(await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty();
|
|
await _paperBets.DidNotReceive().AddAsync(Arg.Any<PaperBet>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Skips_AnomaliesWithUnparseableEvidence()
|
|
{
|
|
HaveAnomalies(Anomaly(AnomalyKind.SuspensionFlip, 0.7m, "not json"));
|
|
|
|
(await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Throws_When_FlatStakeNotPositive()
|
|
{
|
|
var act = async () => await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 0m);
|
|
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
|
|
}
|
|
}
|