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
@@ -185,6 +185,53 @@ namespace Marathon.Infrastructure.Migrations
b.ToTable("Leagues", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PaperBetEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("AnomalyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OpenedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Outcome")
.HasColumnType("INTEGER");
b.Property<decimal?>("Payout")
.HasColumnType("TEXT");
b.Property<int>("PickedSide")
.HasColumnType("INTEGER");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<string>("SettledAt")
.HasColumnType("TEXT");
b.Property<decimal>("Stake")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AnomalyId")
.IsUnique()
.HasDatabaseName("IX_PaperBets_AnomalyId");
b.HasIndex("Outcome")
.HasDatabaseName("IX_PaperBets_Outcome");
b.ToTable("PaperBets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
{
b.Property<string>("Id")