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
@@ -0,0 +1,24 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="PaperBet"/> entities — the forward-test ledger written
/// by the paper-trading worker.
/// </summary>
public interface IPaperBetRepository : IRepository<Guid, PaperBet>
{
/// <summary>
/// Paper bets in a given settlement state — <see cref="BetOutcome.Pending"/> is
/// the open set the settler scans each cycle.
/// </summary>
Task<IReadOnlyList<PaperBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default);
/// <summary>
/// The subset of <paramref name="anomalyIds"/> that already have a paper bet —
/// lets the opener skip anomalies it has already forward-tested (one bet per anomaly).
/// </summary>
Task<IReadOnlySet<Guid>> GetExistingAnomalyIdsAsync(
IReadOnlyCollection<Guid> anomalyIds, CancellationToken ct = default);
}
@@ -42,6 +42,9 @@ public static class ApplicationModule
services.AddScoped<SaveStrategyUseCase>();
services.AddScoped<DeleteStrategyUseCase>();
services.AddScoped<OpenPaperBetsUseCase>();
services.AddScoped<SettlePaperBetsUseCase>();
return services;
}
}
@@ -0,0 +1,84 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Opens flat-stake paper bets for directional anomalies detected in
/// (<c>since</c>..<c>until</c>] whose score clears the threshold and that don't
/// already have one. The picked side is the post-flip favourite; the rate is that
/// side's post-suspension rate — locking in the price the moment the signal fired.
/// </summary>
public sealed class OpenPaperBetsUseCase
{
private readonly IAnomalyRepository _anomalies;
private readonly IPaperBetRepository _paperBets;
private readonly ILogger<OpenPaperBetsUseCase> _logger;
public OpenPaperBetsUseCase(
IAnomalyRepository anomalies,
IPaperBetRepository paperBets,
ILogger<OpenPaperBetsUseCase> logger)
{
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Returns the paper bets opened this pass (empty when nothing qualified).</summary>
public async Task<IReadOnlyList<PaperBet>> ExecuteAsync(
DateTimeOffset since,
DateTimeOffset until,
decimal minScore,
decimal flatStake,
CancellationToken ct = default)
{
if (flatStake <= 0m)
throw new ArgumentOutOfRangeException(nameof(flatStake), flatStake, "Flat stake must be positive.");
var anomalies = await _anomalies.ListByDateRangeAsync(since, until, ct).ConfigureAwait(false);
// Only directional kinds make a side prediction worth forward-testing; the rest
// are informational and would just measure the base favourite-win rate.
var candidates = anomalies
.Where(a => a.Kind.IsDirectional() && a.Score >= minScore)
.ToList();
if (candidates.Count == 0)
return Array.Empty<PaperBet>();
var existing = await _paperBets
.GetExistingAnomalyIdsAsync(candidates.Select(a => a.Id).ToList(), ct)
.ConfigureAwait(false);
var opened = new List<PaperBet>();
foreach (var anomaly in candidates)
{
ct.ThrowIfCancellationRequested();
if (existing.Contains(anomaly.Id))
continue;
if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var evidence))
continue;
var pick = evidence.PostSuspension.Favourite;
if (evidence.PostSuspension.RateFor(pick) is not { } rate || rate <= 1m)
continue;
opened.Add(PaperBet.Open(anomaly.Id, anomaly.EventId, pick, rate, flatStake, anomaly.DetectedAt));
}
if (opened.Count == 0)
return Array.Empty<PaperBet>();
foreach (var bet in opened)
await _paperBets.AddAsync(bet, ct).ConfigureAwait(false);
await _paperBets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation("OpenPaperBetsUseCase: opened {Count} paper bet(s)", opened.Count);
return opened;
}
}
@@ -0,0 +1,61 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Settles every open (<see cref="BetOutcome.Pending"/>) paper bet whose event now has
/// a final result — Won when the picked side matches the winner, otherwise Lost. Bets
/// on events that aren't graded yet stay open and are retried next cycle.
/// </summary>
public sealed class SettlePaperBetsUseCase
{
private readonly IPaperBetRepository _paperBets;
private readonly IResultRepository _results;
private readonly ILogger<SettlePaperBetsUseCase> _logger;
public SettlePaperBetsUseCase(
IPaperBetRepository paperBets,
IResultRepository results,
ILogger<SettlePaperBetsUseCase> logger)
{
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Returns the number of paper bets settled this pass.</summary>
public async Task<int> ExecuteAsync(CancellationToken ct = default)
{
var open = await _paperBets.ListByOutcomeAsync(BetOutcome.Pending, ct).ConfigureAwait(false);
if (open.Count == 0)
return 0;
// Batched result lookup — one query, not one per open bet.
var eventIds = open.Select(b => b.EventId).Distinct().ToList();
var results = await _results.GetManyAsync(eventIds, ct).ConfigureAwait(false);
var settledAt = MoscowTime.Now;
var settled = 0;
foreach (var bet in open)
{
ct.ThrowIfCancellationRequested();
if (!results.TryGetValue(bet.EventId, out var result))
continue; // event not graded yet
await _paperBets.UpdateAsync(bet.SettleAgainst(result.WinnerSide, settledAt), ct).ConfigureAwait(false);
settled++;
}
if (settled > 0)
{
await _paperBets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation("SettlePaperBetsUseCase: settled {Count} paper bet(s)", settled);
}
return settled;
}
}
@@ -57,6 +57,18 @@ public sealed record AnomalyEvidenceSide(
return best;
}
}
/// <summary>
/// The decimal rate offered on <paramref name="side"/> at this snapshot, or null
/// for a non-win side (Less/More) or an absent Draw market.
/// </summary>
public decimal? RateFor(Side side) => side switch
{
Side.Side1 => Rate1,
Side.Side2 => Rate2,
Side.Draw => RateDraw,
_ => null,
};
}
/// <summary>
+72
View File
@@ -0,0 +1,72 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Entities;
/// <summary>
/// A hypothetical "paper" wager opened automatically by the forward-test worker the
/// moment a directional anomaly fires, then settled when the event result arrives.
/// </summary>
/// <remarks>
/// Unlike <see cref="PlacedBet"/> (the user's real journal), a paper bet is
/// system-generated and exists only to measure the detector's live, out-of-sample
/// edge — the antidote to backtest overfitting. Exactly one paper bet is opened per
/// anomaly (enforced by a unique index on <see cref="AnomalyId"/>).
/// </remarks>
public sealed record PaperBet(
Guid Id,
Guid AnomalyId,
EventId EventId,
Side PickedSide,
decimal Rate,
decimal Stake,
DateTimeOffset OpenedAt,
BetOutcome Outcome,
DateTimeOffset? SettledAt,
decimal? Payout)
{
public decimal Rate { get; } = Rate > 1m
? Rate
: throw new ArgumentOutOfRangeException(nameof(Rate), Rate, "Decimal odds must be greater than 1.");
public decimal Stake { get; } = Stake > 0m
? Stake
: throw new ArgumentOutOfRangeException(nameof(Stake), Stake, "Stake must be positive.");
/// <summary>Whether the bet is still awaiting a result.</summary>
public bool IsOpen => Outcome == BetOutcome.Pending;
/// <summary>Opens a fresh, unsettled paper bet with a new identity.</summary>
public static PaperBet Open(
Guid anomalyId, EventId eventId, Side pickedSide, decimal rate, decimal stake, DateTimeOffset openedAt)
{
ArgumentNullException.ThrowIfNull(eventId);
return new PaperBet(
Id: Guid.NewGuid(),
AnomalyId: anomalyId,
EventId: eventId,
PickedSide: pickedSide,
Rate: rate,
Stake: stake,
OpenedAt: openedAt,
Outcome: BetOutcome.Pending,
SettledAt: null,
Payout: null);
}
/// <summary>
/// Settles the bet against the actual winner: Won (payout = stake × rate) when
/// <paramref name="winnerSide"/> equals <see cref="PickedSide"/>, otherwise Lost
/// (payout 0). A win-market pick that draws simply loses.
/// </summary>
public PaperBet SettleAgainst(Side winnerSide, DateTimeOffset settledAt)
{
var won = winnerSide == PickedSide;
return this with
{
Outcome = won ? BetOutcome.Won : BetOutcome.Lost,
SettledAt = settledAt,
Payout = won ? Stake * Rate : 0m,
};
}
}
@@ -53,6 +53,12 @@
"MinScore": 0.45,
"PollIntervalSeconds": 60
},
"PaperTrading": {
"Enabled": false,
"MinScore": 0.55,
"FlatStake": 10,
"PollIntervalSeconds": 60
},
"Localization": {
"DefaultCulture": "ru-RU"
},
@@ -0,0 +1,22 @@
namespace Marathon.Infrastructure.Configuration;
/// <summary>
/// Options for the forward-test (paper-trading) worker, bound to the
/// <c>PaperTrading</c> configuration section.
/// </summary>
public sealed class PaperTradingOptions
{
public const string SectionName = "PaperTrading";
/// <summary>Master switch. When false the worker idles (cheap re-check). Default false.</summary>
public bool Enabled { get; init; }
/// <summary>Minimum anomaly score required to open a paper bet. Default 0.55.</summary>
public decimal MinScore { get; init; } = 0.55m;
/// <summary>Flat stake placed on every paper bet (currency-agnostic units). Default 10.</summary>
public decimal FlatStake { get; init; } = 10m;
/// <summary>Seconds between open/settle cycles. Floored at 5. Default 60.</summary>
public int PollIntervalSeconds { get; init; } = 60;
}
@@ -56,6 +56,10 @@ public static class InfrastructureModule
.AddOptions<NotificationOptions>()
.Bind(config.GetSection(NotificationOptions.SectionName));
services
.AddOptions<PaperTradingOptions>()
.Bind(config.GetSection(PaperTradingOptions.SectionName));
services.AddHostedService<UpcomingEventsPoller>();
services.AddHostedService<LiveOddsPoller>();
services.AddHostedService<ResultsWatchListPoller>();
@@ -69,6 +73,9 @@ public static class InfrastructureModule
services.AddSingleton<INotificationSink, TelegramNotificationSink>();
services.AddHostedService<AnomalyNotificationDispatcher>();
// Forward-test (paper-trading) engine. Idles until PaperTrading:Enabled is true.
services.AddHostedService<PaperTradingWorker>();
return services;
}
}
@@ -0,0 +1,439 @@
// <auto-generated />
using System;
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Marathon.Infrastructure.Migrations
{
[DbContext(typeof(MarathonDbContext))]
[Migration("20260528232145_AddPaperBets")]
partial class AddPaperBets
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("DetectedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EvidenceJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<decimal>("Score")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_Anomalies_EventCode");
b.ToTable("Anomalies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<int>("Scope")
.HasColumnType("INTEGER");
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<long>("SnapshotId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SnapshotId")
.HasDatabaseName("IX_Bets_SnapshotId");
b.ToTable("Bets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CountryCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventPath")
.HasColumnType("TEXT");
b.Property<string>("LeagueId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ScheduledAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side1Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side2Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.HasIndex("ScheduledAt")
.HasDatabaseName("IX_Events_ScheduledAt");
b.HasIndex("SportCode", "ScheduledAt")
.HasDatabaseName("IX_Events_SportCode_ScheduledAt");
b.ToTable("Events", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("CompletedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Side1Score")
.HasColumnType("INTEGER");
b.Property<int>("Side2Score")
.HasColumnType("INTEGER");
b.Property<int>("WinnerSide")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.ToTable("EventResults", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("Country")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("Id");
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")
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Outcome")
.HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
b.Property<string>("PlacedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<int>("Scope")
.HasColumnType("INTEGER");
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<decimal>("Stake")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_PlacedBets_EventCode");
b.HasIndex("Outcome")
.HasDatabaseName("IX_PlacedBets_Outcome");
b.ToTable("PlacedBets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SavedStrategyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("FlatStake")
.HasColumnType("TEXT");
b.Property<decimal>("KellyFraction")
.HasColumnType("TEXT");
b.Property<decimal>("MinScore")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<decimal>("PercentOfBankroll")
.HasColumnType("TEXT");
b.Property<int>("StakeRule")
.HasColumnType("INTEGER");
b.Property<decimal>("StartingBankroll")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("IX_SavedStrategies_Name");
b.ToTable("SavedStrategies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CapturedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_Snapshots_EventCode");
b.HasIndex("EventCode", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
b.HasIndex("EventCode", "Source", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
b.ToTable("Snapshots", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
{
b.Property<int>("Code")
.HasColumnType("INTEGER");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Code");
b.ToTable("Sports", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Anomalies")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", "Snapshot")
.WithMany("Bets")
.HasForeignKey("SnapshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Snapshot");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithOne("Result")
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Snapshots")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Navigation("Anomalies");
b.Navigation("Result");
b.Navigation("Snapshots");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Navigation("Bets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPaperBets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PaperBets",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
AnomalyId = table.Column<string>(type: "TEXT", nullable: false),
EventCode = table.Column<string>(type: "TEXT", nullable: false),
PickedSide = table.Column<int>(type: "INTEGER", nullable: false),
Rate = table.Column<decimal>(type: "TEXT", nullable: false),
Stake = table.Column<decimal>(type: "TEXT", nullable: false),
OpenedAt = table.Column<string>(type: "TEXT", nullable: false),
Outcome = table.Column<int>(type: "INTEGER", nullable: false),
SettledAt = table.Column<string>(type: "TEXT", nullable: true),
Payout = table.Column<decimal>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PaperBets", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_PaperBets_AnomalyId",
table: "PaperBets",
column: "AnomalyId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PaperBets_Outcome",
table: "PaperBets",
column: "Outcome");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PaperBets");
}
}
}
@@ -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")
@@ -0,0 +1,38 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class PaperBetConfiguration : IEntityTypeConfiguration<PaperBetEntity>
{
public void Configure(EntityTypeBuilder<PaperBetEntity> builder)
{
builder.ToTable("PaperBets");
builder.HasKey(b => b.Id);
builder.Property(b => b.Id).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.AnomalyId).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.PickedSide).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Rate).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.Stake).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.OpenedAt).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.Outcome).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.SettledAt).HasColumnType("TEXT");
builder.Property(b => b.Payout).HasColumnType("TEXT");
// One paper bet per anomaly — the opener skips existing ids, and this index is
// the hard backstop against a double-open race.
builder.HasIndex(b => b.AnomalyId)
.IsUnique()
.HasDatabaseName("IX_PaperBets_AnomalyId");
// The settler scans the open (Pending) set every cycle.
builder.HasIndex(b => b.Outcome).HasDatabaseName("IX_PaperBets_Outcome");
// No FK to Events/Anomalies — the ledger is analysis data and must survive
// snapshot-retention pruning of the source rows (same rationale as PlacedBets).
}
}
@@ -0,0 +1,39 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a <see cref="Marathon.Domain.Entities.PaperBet"/> —
/// the system-generated forward-test ledger. Decimals are stored as TEXT (invariant
/// round-trip) to match the rest of the schema.
/// </summary>
public sealed class PaperBetEntity
{
/// <summary>GUID primary key stored as TEXT.</summary>
public string Id { get; set; } = default!;
/// <summary>The anomaly that triggered this paper bet (unique — one bet per anomaly).</summary>
public string AnomalyId { get; set; } = default!;
/// <summary>The event the bet is on.</summary>
public string EventCode { get; set; } = default!;
/// <summary>Picked Side as int (Side1 / Side2 / Draw).</summary>
public int PickedSide { get; set; }
/// <summary>Decimal odds locked in at the moment the signal fired.</summary>
public decimal Rate { get; set; }
/// <summary>Flat stake.</summary>
public decimal Stake { get; set; }
/// <summary>ISO 8601 timestamp of the originating anomaly's detection (Moscow time).</summary>
public string OpenedAt { get; set; } = default!;
/// <summary>BetOutcome as int (Pending = open / Won / Lost / Void).</summary>
public int Outcome { get; set; }
/// <summary>ISO 8601 settlement timestamp, or null while open.</summary>
public string? SettledAt { get; set; }
/// <summary>Realised payout once settled (stake × rate on a win, 0 on a loss), else null.</summary>
public decimal? Payout { get; set; }
}
@@ -252,4 +252,34 @@ internal static class Mapping
PercentOfBankroll: entity.PercentOfBankroll,
KellyFraction: entity.KellyFraction),
CreatedAt: SqliteDateText.Parse(entity.CreatedAt));
// ─── PaperBet ──────────────────────────────────────────────────────────────
public static PaperBetEntity ToEntity(PaperBet domain) =>
new()
{
Id = domain.Id.ToString(),
AnomalyId = domain.AnomalyId.ToString(),
EventCode = domain.EventId.Value,
PickedSide = (int)domain.PickedSide,
Rate = domain.Rate,
Stake = domain.Stake,
OpenedAt = SqliteDateText.Key(domain.OpenedAt),
Outcome = (int)domain.Outcome,
SettledAt = domain.SettledAt is { } s ? SqliteDateText.Key(s) : null,
Payout = domain.Payout,
};
public static PaperBet ToDomain(PaperBetEntity entity) =>
new(
Id: Guid.Parse(entity.Id),
AnomalyId: Guid.Parse(entity.AnomalyId),
EventId: new EventId(entity.EventCode),
PickedSide: (Side)entity.PickedSide,
Rate: entity.Rate,
Stake: entity.Stake,
OpenedAt: SqliteDateText.Parse(entity.OpenedAt),
Outcome: (BetOutcome)entity.Outcome,
SettledAt: entity.SettledAt is { } s ? SqliteDateText.Parse(s) : null,
Payout: entity.Payout);
}
@@ -20,6 +20,7 @@ public sealed class MarathonDbContext : DbContext
public DbSet<LeagueEntity> Leagues => Set<LeagueEntity>();
public DbSet<PlacedBetEntity> PlacedBets => Set<PlacedBetEntity>();
public DbSet<SavedStrategyEntity> SavedStrategies => Set<SavedStrategyEntity>();
public DbSet<PaperBetEntity> PaperBets => Set<PaperBetEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -55,6 +55,7 @@ public static class PersistenceModule
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
services.AddScoped<IPlacedBetRepository, PlacedBetRepository>();
services.AddScoped<ISavedStrategyRepository, SavedStrategyRepository>();
services.AddScoped<IPaperBetRepository, PaperBetRepository>();
services.AddScoped<IExcelExporter, ExcelExporter>();
return services;
@@ -0,0 +1,77 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class PaperBetRepository : IPaperBetRepository
{
private readonly MarathonDbContext _db;
public PaperBetRepository(MarathonDbContext db) => _db = db;
public async Task<PaperBet?> GetAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.PaperBets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == idStr, ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<PaperBet>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.PaperBets.AsNoTracking()
.OrderByDescending(b => b.OpenedAt)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<PaperBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default)
{
var outcomeInt = (int)outcome;
var entities = await _db.PaperBets.AsNoTracking()
.Where(b => b.Outcome == outcomeInt)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlySet<Guid>> GetExistingAnomalyIdsAsync(
IReadOnlyCollection<Guid> anomalyIds, CancellationToken ct = default)
{
if (anomalyIds.Count == 0)
return new HashSet<Guid>();
var idStrings = anomalyIds.Select(id => id.ToString()).ToList();
var existing = await _db.PaperBets.AsNoTracking()
.Where(b => idStrings.Contains(b.AnomalyId))
.Select(b => b.AnomalyId)
.ToListAsync(ct);
return existing.Select(Guid.Parse).ToHashSet();
}
public async Task AddAsync(PaperBet entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.PaperBets.AddAsync(efEntity, ct);
}
public Task UpdateAsync(PaperBet entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
_db.PaperBets.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.PaperBets.FirstOrDefaultAsync(b => b.Id == idStr, ct);
if (entity is not null)
_db.PaperBets.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}
@@ -0,0 +1,94 @@
using Marathon.Application.UseCases;
using Marathon.Domain.ValueObjects;
using Marathon.Infrastructure.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Marathon.Infrastructure.Workers;
/// <summary>
/// Forward-test engine: each cycle opens flat-stake paper bets for newly detected
/// directional anomalies, then settles any open bets whose events have been graded.
/// Idle (cheap re-check) while <see cref="PaperTradingOptions.Enabled"/> is false.
/// </summary>
/// <remarks>
/// The "since" marker is baselined to startup so pre-existing anomalies are not
/// retro-traded, and advances to each cycle's upper bound only after the open pass
/// succeeds. A unique index on <c>PaperBets.AnomalyId</c> backstops any double-open.
/// Scoped use cases are resolved per cycle (EF Core DbContext lifetime).
/// </remarks>
internal sealed class PaperTradingWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly IOptionsMonitor<PaperTradingOptions> _opts;
private readonly ILogger<PaperTradingWorker> _logger;
private DateTimeOffset _since;
public PaperTradingWorker(
IServiceProvider services,
IOptionsMonitor<PaperTradingOptions> opts,
ILogger<PaperTradingWorker> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Baseline: only forward-test anomalies detected after this worker started.
_since = MoscowTime.Now;
_logger.LogInformation("PaperTradingWorker: started");
while (!stoppingToken.IsCancellationRequested)
{
var opts = _opts.CurrentValue;
if (!opts.Enabled)
{
await DelayQuietly(TimeSpan.FromSeconds(10), stoppingToken);
continue;
}
try
{
var until = MoscowTime.Now;
await using var scope = _services.CreateAsyncScope();
var open = scope.ServiceProvider.GetRequiredService<OpenPaperBetsUseCase>();
await open.ExecuteAsync(_since, until, opts.MinScore, opts.FlatStake, stoppingToken);
// Advance only after a successful open pass, so a failure replays the window.
_since = until;
var settle = scope.ServiceProvider.GetRequiredService<SettlePaperBetsUseCase>();
await settle.ExecuteAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "PaperTradingWorker: cycle failed — will retry after interval");
}
await DelayQuietly(TimeSpan.FromSeconds(Math.Max(5, opts.PollIntervalSeconds)), stoppingToken);
}
_logger.LogInformation("PaperTradingWorker: stopping");
}
private static async Task DelayQuietly(TimeSpan delay, CancellationToken ct)
{
try
{
await Task.Delay(delay, ct);
}
catch (OperationCanceledException)
{
// Shutting down — swallow so ExecuteAsync's loop check exits cleanly.
}
}
}
@@ -0,0 +1,110 @@
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 OpenPaperBetsUseCaseTests
{
private static readonly TimeSpan Msk = TimeSpan.FromHours(3);
private static readonly DateTimeOffset T0 = new(2026, 5, 20, 18, 0, 0, Msk);
private readonly IAnomalyRepository _anomalies = Substitute.For<IAnomalyRepository>();
private readonly IPaperBetRepository _paperBets = Substitute.For<IPaperBetRepository>();
public OpenPaperBetsUseCaseTests()
{
// Default: nothing already forward-tested.
_paperBets
.GetExistingAnomalyIdsAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(new HashSet<Guid>());
}
private OpenPaperBetsUseCase CreateSut() =>
new(_anomalies, _paperBets, NullLogger<OpenPaperBetsUseCase>.Instance);
// Flip: pre favourite Side1, post favourite Side2 @ 1.60.
private const string FlipToSide2 = """
{"suspensionGapSeconds":90,
"preSuspension":{"capturedAt":"2026-05-20T18:00:00+03:00","p1":0.6,"p2":0.4,"rate1":1.6,"rate2":2.5},
"postSuspension":{"capturedAt":"2026-05-20T18:02:00+03:00","p1":0.4,"p2":0.6,"rate1":2.5,"rate2":1.6}}
""";
private static Anomaly Anomaly(AnomalyKind kind, decimal score, string evidence) =>
new(Guid.NewGuid(), new EventId("26000001"), T0.AddMinutes(2), kind, score, evidence);
private void HaveAnomalies(params Anomaly[] anomalies) =>
_anomalies
.ListByDateRangeAsync(Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
.Returns(anomalies);
[Fact]
public async Task Opens_DirectionalAboveThreshold_WithPostFavouriteAndRate()
{
var a = Anomaly(AnomalyKind.SuspensionFlip, 0.7m, FlipToSide2);
HaveAnomalies(a);
var opened = await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), minScore: 0.5m, flatStake: 10m);
opened.Should().ContainSingle();
opened[0].AnomalyId.Should().Be(a.Id);
opened[0].PickedSide.Should().Be(Side.Side2);
opened[0].Rate.Should().Be(1.6m);
opened[0].Stake.Should().Be(10m);
opened[0].Outcome.Should().Be(BetOutcome.Pending);
await _paperBets.Received(1).AddAsync(Arg.Any<PaperBet>(), Arg.Any<CancellationToken>());
await _paperBets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Skips_NonDirectionalKinds()
{
HaveAnomalies(Anomaly(AnomalyKind.SuspensionFreeze, 0.9m, FlipToSide2));
var opened = await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m);
opened.Should().BeEmpty();
await _paperBets.DidNotReceive().AddAsync(Arg.Any<PaperBet>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Skips_BelowThreshold()
{
HaveAnomalies(Anomaly(AnomalyKind.SuspensionFlip, 0.3m, FlipToSide2));
(await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty();
}
[Fact]
public async Task Skips_AnomaliesThatAlreadyHaveAPaperBet()
{
var a = Anomaly(AnomalyKind.SuspensionFlip, 0.7m, FlipToSide2);
HaveAnomalies(a);
_paperBets
.GetExistingAnomalyIdsAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(new HashSet<Guid> { a.Id });
(await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty();
await _paperBets.DidNotReceive().AddAsync(Arg.Any<PaperBet>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Skips_AnomaliesWithUnparseableEvidence()
{
HaveAnomalies(Anomaly(AnomalyKind.SuspensionFlip, 0.7m, "not json"));
(await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty();
}
[Fact]
public async Task Throws_When_FlatStakeNotPositive()
{
var act = async () => await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 0m);
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
}
}
@@ -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>());
}
}
@@ -0,0 +1,81 @@
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);
}
}
@@ -0,0 +1,139 @@
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>();
}
}