feat(paper-trading): forward-test ledger engine

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.
This commit is contained in:
2026-05-29 02:25:54 +03:00
parent 2a0ea7b3a6
commit f622dadf95
23 changed files with 1525 additions and 0 deletions
@@ -0,0 +1,110 @@
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>();
}
}
@@ -0,0 +1,86 @@
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 SettlePaperBetsUseCaseTests
{
private static readonly TimeSpan Msk = TimeSpan.FromHours(3);
private static readonly DateTimeOffset T0 = new(2026, 5, 20, 18, 0, 0, Msk);
private readonly IPaperBetRepository _paperBets = Substitute.For<IPaperBetRepository>();
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
private SettlePaperBetsUseCase CreateSut() =>
new(_paperBets, _results, NullLogger<SettlePaperBetsUseCase>.Instance);
private static PaperBet Open(string eventCode, Side pick) =>
PaperBet.Open(Guid.NewGuid(), new EventId(eventCode), pick, 2.0m, 10m, T0);
private static EventResult Result(string eventCode, Side winner) =>
new(new EventId(eventCode), 2, 1, winner, T0.AddHours(2));
private void HaveOpen(params PaperBet[] bets) =>
_paperBets.ListByOutcomeAsync(BetOutcome.Pending, Arg.Any<CancellationToken>()).Returns(bets);
private void HaveResults(params EventResult[] results) =>
_results
.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(results.ToDictionary(r => r.EventId));
[Fact]
public async Task Settles_Won_When_PickMatchesWinner()
{
HaveOpen(Open("e1", Side.Side1));
HaveResults(Result("e1", Side.Side1));
var settled = await CreateSut().ExecuteAsync();
settled.Should().Be(1);
await _paperBets.Received(1).UpdateAsync(
Arg.Is<PaperBet>(b => b.Outcome == BetOutcome.Won && b.Payout == 20m),
Arg.Any<CancellationToken>());
await _paperBets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Settles_Lost_When_PickMisses()
{
HaveOpen(Open("e1", Side.Side1));
HaveResults(Result("e1", Side.Side2));
await CreateSut().ExecuteAsync();
await _paperBets.Received(1).UpdateAsync(
Arg.Is<PaperBet>(b => b.Outcome == BetOutcome.Lost && b.Payout == 0m),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task LeavesOpen_When_EventNotGradedYet()
{
HaveOpen(Open("e1", Side.Side1));
HaveResults(); // none graded
var settled = await CreateSut().ExecuteAsync();
settled.Should().Be(0);
await _paperBets.DidNotReceive().UpdateAsync(Arg.Any<PaperBet>(), Arg.Any<CancellationToken>());
await _paperBets.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task NoOp_When_NoOpenBets()
{
HaveOpen();
(await CreateSut().ExecuteAsync()).Should().Be(0);
await _results.DidNotReceive().GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,81 @@
using FluentAssertions;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Tests.Entities;
public sealed class PaperBetTests
{
private static readonly DateTimeOffset Opened = new(2026, 5, 20, 18, 0, 0, TimeSpan.FromHours(3));
private static PaperBet OpenBet(Side pick = Side.Side1, decimal rate = 2.00m, decimal stake = 10m) =>
PaperBet.Open(Guid.NewGuid(), new EventId("26000001"), pick, rate, stake, Opened);
[Fact]
public void Open_CreatesPendingBet_WithIdentity()
{
var bet = OpenBet();
bet.Id.Should().NotBe(Guid.Empty);
bet.Outcome.Should().Be(BetOutcome.Pending);
bet.IsOpen.Should().BeTrue();
bet.SettledAt.Should().BeNull();
bet.Payout.Should().BeNull();
}
[Theory]
[InlineData(1.0)]
[InlineData(0.5)]
public void Constructor_Throws_When_RateNotAboveOne(double rate)
{
var act = () => OpenBet(rate: (decimal)rate);
act.Should().Throw<ArgumentOutOfRangeException>();
}
[Theory]
[InlineData(0)]
[InlineData(-5)]
public void Constructor_Throws_When_StakeNotPositive(double stake)
{
var act = () => OpenBet(stake: (decimal)stake);
act.Should().Throw<ArgumentOutOfRangeException>();
}
[Fact]
public void SettleAgainst_Win_PaysStakeTimesRate()
{
var bet = OpenBet(pick: Side.Side1, rate: 2.00m, stake: 10m);
var settledAt = Opened.AddHours(2);
var settled = bet.SettleAgainst(Side.Side1, settledAt);
settled.Outcome.Should().Be(BetOutcome.Won);
settled.Payout.Should().Be(20m);
settled.SettledAt.Should().Be(settledAt);
settled.IsOpen.Should().BeFalse();
}
[Fact]
public void SettleAgainst_Loss_PaysZero()
{
var settled = OpenBet(pick: Side.Side1).SettleAgainst(Side.Side2, Opened.AddHours(2));
settled.Outcome.Should().Be(BetOutcome.Lost);
settled.Payout.Should().Be(0m);
}
[Fact]
public void SettleAgainst_PreservesIdentityStakeAndRate()
{
var bet = OpenBet(rate: 2.00m, stake: 10m);
var settled = bet.SettleAgainst(Side.Side1, Opened.AddHours(1));
settled.Id.Should().Be(bet.Id);
settled.AnomalyId.Should().Be(bet.AnomalyId);
settled.EventId.Should().Be(bet.EventId);
settled.Stake.Should().Be(10m);
settled.Rate.Should().Be(2.00m);
}
}
@@ -0,0 +1,139 @@
using FluentAssertions;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Marathon.Infrastructure.Persistence.Repositories;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Tests.Persistence;
/// <summary>
/// Round-trip + query tests for <see cref="PaperBetRepository"/>. Uses the in-memory
/// SQLite fixture so the table + unique AnomalyId / Outcome indexes are exercised.
/// </summary>
public sealed class PaperBetRoundTripTests : IDisposable
{
private static readonly TimeSpan Msk = TimeSpan.FromHours(3);
private static readonly DateTimeOffset Opened = new(2026, 5, 20, 18, 0, 0, Msk);
private readonly InMemoryDbFixture _fixture;
private readonly PaperBetRepository _repo;
public PaperBetRoundTripTests()
{
_fixture = new InMemoryDbFixture();
_repo = new PaperBetRepository(_fixture.DbContext);
}
public void Dispose() => _fixture.Dispose();
private static PaperBet Open(
Guid? anomalyId = null, string eventCode = "26000001", Side pick = Side.Side1) =>
PaperBet.Open(anomalyId ?? Guid.NewGuid(), new EventId(eventCode), pick, 1.95m, 10m, Opened);
[Fact]
public async Task RoundTrip_PreservesAllFields_ForSettledBet()
{
var settled = Open(pick: Side.Side2).SettleAgainst(Side.Side2, Opened.AddHours(2));
await _repo.AddAsync(settled);
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
var got = await _repo.GetAsync(settled.Id);
got.Should().NotBeNull();
got!.Id.Should().Be(settled.Id);
got.AnomalyId.Should().Be(settled.AnomalyId);
got.EventId.Value.Should().Be("26000001");
got.PickedSide.Should().Be(Side.Side2);
got.Rate.Should().Be(1.95m);
got.Stake.Should().Be(10m);
got.OpenedAt.Should().Be(Opened);
got.OpenedAt.Offset.Should().Be(Msk);
got.Outcome.Should().Be(BetOutcome.Won);
got.Payout.Should().Be(19.5m);
got.SettledAt.Should().Be(Opened.AddHours(2));
}
[Fact]
public async Task RoundTrip_OpenBet_HasNullSettlementFields()
{
var open = Open();
await _repo.AddAsync(open);
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
var got = await _repo.GetAsync(open.Id);
got!.Outcome.Should().Be(BetOutcome.Pending);
got.SettledAt.Should().BeNull();
got.Payout.Should().BeNull();
}
[Fact]
public async Task ListByOutcomeAsync_ReturnsOnlyMatching()
{
await _repo.AddAsync(Open(eventCode: "open1"));
await _repo.AddAsync(Open(eventCode: "open2"));
await _repo.AddAsync(Open(eventCode: "won1").SettleAgainst(Side.Side1, Opened.AddHours(1)));
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
var open = await _repo.ListByOutcomeAsync(BetOutcome.Pending);
open.Should().HaveCount(2);
open.Should().OnlyContain(b => b.Outcome == BetOutcome.Pending);
}
[Fact]
public async Task GetExistingAnomalyIdsAsync_ReturnsKnownSubset()
{
var known = Guid.NewGuid();
await _repo.AddAsync(Open(anomalyId: known));
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
var unknown = Guid.NewGuid();
var existing = await _repo.GetExistingAnomalyIdsAsync(new[] { known, unknown });
existing.Should().ContainSingle().And.Contain(known);
existing.Should().NotContain(unknown);
}
[Fact]
public async Task GetExistingAnomalyIdsAsync_EmptyInput_ReturnsEmpty_WithoutQuery()
{
(await _repo.GetExistingAnomalyIdsAsync(Array.Empty<Guid>())).Should().BeEmpty();
}
[Fact]
public async Task UpdateAsync_PersistsSettlement()
{
var bet = Open(pick: Side.Side1);
await _repo.AddAsync(bet);
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
await _repo.UpdateAsync(bet.SettleAgainst(Side.Side1, Opened.AddHours(3)));
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
var got = await _repo.GetAsync(bet.Id);
got!.Outcome.Should().Be(BetOutcome.Won);
got.Payout.Should().Be(19.5m);
}
[Fact]
public async Task UniqueAnomalyIdIndex_RejectsSecondBet_ForSameAnomaly()
{
var anomalyId = Guid.NewGuid();
await _repo.AddAsync(Open(anomalyId: anomalyId, eventCode: "a"));
await _repo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
await _repo.AddAsync(Open(anomalyId: anomalyId, eventCode: "b"));
var act = async () => await _repo.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>();
}
}