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:
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user