Files
maraphon-app/tests/Marathon.Infrastructure.Tests/Persistence/PaperBetRoundTripTests.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

140 lines
4.8 KiB
C#

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