Files
maraphon-app/tests/Marathon.Domain.Tests/Entities/PaperBetTests.cs
T
alexei.dolgolyov f622dadf95 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.
2026-05-29 02:25:54 +03:00

82 lines
2.4 KiB
C#

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);
}
}