f622dadf95
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.
140 lines
4.8 KiB
C#
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>();
|
|
}
|
|
}
|