diff --git a/src/Marathon.Application/Abstractions/IPaperBetRepository.cs b/src/Marathon.Application/Abstractions/IPaperBetRepository.cs
new file mode 100644
index 0000000..a0121a3
--- /dev/null
+++ b/src/Marathon.Application/Abstractions/IPaperBetRepository.cs
@@ -0,0 +1,24 @@
+using Marathon.Domain.Entities;
+using Marathon.Domain.Enums;
+
+namespace Marathon.Application.Abstractions;
+
+///
+/// Repository for entities — the forward-test ledger written
+/// by the paper-trading worker.
+///
+public interface IPaperBetRepository : IRepository
+{
+ ///
+ /// Paper bets in a given settlement state — is
+ /// the open set the settler scans each cycle.
+ ///
+ Task> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default);
+
+ ///
+ /// The subset of that already have a paper bet —
+ /// lets the opener skip anomalies it has already forward-tested (one bet per anomaly).
+ ///
+ Task> GetExistingAnomalyIdsAsync(
+ IReadOnlyCollection anomalyIds, CancellationToken ct = default);
+}
diff --git a/src/Marathon.Application/ApplicationModule.cs b/src/Marathon.Application/ApplicationModule.cs
index 3a4edc6..be60de8 100644
--- a/src/Marathon.Application/ApplicationModule.cs
+++ b/src/Marathon.Application/ApplicationModule.cs
@@ -42,6 +42,9 @@ public static class ApplicationModule
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
return services;
}
}
diff --git a/src/Marathon.Application/UseCases/OpenPaperBetsUseCase.cs b/src/Marathon.Application/UseCases/OpenPaperBetsUseCase.cs
new file mode 100644
index 0000000..3c16581
--- /dev/null
+++ b/src/Marathon.Application/UseCases/OpenPaperBetsUseCase.cs
@@ -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;
+
+///
+/// Opens flat-stake paper bets for directional anomalies detected in
+/// (since..until] 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.
+///
+public sealed class OpenPaperBetsUseCase
+{
+ private readonly IAnomalyRepository _anomalies;
+ private readonly IPaperBetRepository _paperBets;
+ private readonly ILogger _logger;
+
+ public OpenPaperBetsUseCase(
+ IAnomalyRepository anomalies,
+ IPaperBetRepository paperBets,
+ ILogger logger)
+ {
+ _anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
+ _paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ /// Returns the paper bets opened this pass (empty when nothing qualified).
+ public async Task> 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();
+
+ var existing = await _paperBets
+ .GetExistingAnomalyIdsAsync(candidates.Select(a => a.Id).ToList(), ct)
+ .ConfigureAwait(false);
+
+ var opened = new List();
+ 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();
+
+ 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;
+ }
+}
diff --git a/src/Marathon.Application/UseCases/SettlePaperBetsUseCase.cs b/src/Marathon.Application/UseCases/SettlePaperBetsUseCase.cs
new file mode 100644
index 0000000..d417cc8
--- /dev/null
+++ b/src/Marathon.Application/UseCases/SettlePaperBetsUseCase.cs
@@ -0,0 +1,61 @@
+using Marathon.Application.Abstractions;
+using Marathon.Domain.Enums;
+using Marathon.Domain.ValueObjects;
+using Microsoft.Extensions.Logging;
+
+namespace Marathon.Application.UseCases;
+
+///
+/// Settles every open () 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.
+///
+public sealed class SettlePaperBetsUseCase
+{
+ private readonly IPaperBetRepository _paperBets;
+ private readonly IResultRepository _results;
+ private readonly ILogger _logger;
+
+ public SettlePaperBetsUseCase(
+ IPaperBetRepository paperBets,
+ IResultRepository results,
+ ILogger logger)
+ {
+ _paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
+ _results = results ?? throw new ArgumentNullException(nameof(results));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ /// Returns the number of paper bets settled this pass.
+ public async Task 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;
+ }
+}
diff --git a/src/Marathon.Domain/AnomalyDetection/AnomalyEvidenceData.cs b/src/Marathon.Domain/AnomalyDetection/AnomalyEvidenceData.cs
index 17d1883..95e42be 100644
--- a/src/Marathon.Domain/AnomalyDetection/AnomalyEvidenceData.cs
+++ b/src/Marathon.Domain/AnomalyDetection/AnomalyEvidenceData.cs
@@ -57,6 +57,18 @@ public sealed record AnomalyEvidenceSide(
return best;
}
}
+
+ ///
+ /// The decimal rate offered on at this snapshot, or null
+ /// for a non-win side (Less/More) or an absent Draw market.
+ ///
+ public decimal? RateFor(Side side) => side switch
+ {
+ Side.Side1 => Rate1,
+ Side.Side2 => Rate2,
+ Side.Draw => RateDraw,
+ _ => null,
+ };
}
///
diff --git a/src/Marathon.Domain/Entities/PaperBet.cs b/src/Marathon.Domain/Entities/PaperBet.cs
new file mode 100644
index 0000000..ee8b441
--- /dev/null
+++ b/src/Marathon.Domain/Entities/PaperBet.cs
@@ -0,0 +1,72 @@
+using Marathon.Domain.Enums;
+using Marathon.Domain.ValueObjects;
+
+namespace Marathon.Domain.Entities;
+
+///
+/// A hypothetical "paper" wager opened automatically by the forward-test worker the
+/// moment a directional anomaly fires, then settled when the event result arrives.
+///
+///
+/// Unlike (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 ).
+///
+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.");
+
+ /// Whether the bet is still awaiting a result.
+ public bool IsOpen => Outcome == BetOutcome.Pending;
+
+ /// Opens a fresh, unsettled paper bet with a new identity.
+ 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);
+ }
+
+ ///
+ /// Settles the bet against the actual winner: Won (payout = stake × rate) when
+ /// equals , otherwise Lost
+ /// (payout 0). A win-market pick that draws simply loses.
+ ///
+ 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,
+ };
+ }
+}
diff --git a/src/Marathon.Hosts.WpfBlazor/appsettings.json b/src/Marathon.Hosts.WpfBlazor/appsettings.json
index c9e4209..5aff74f 100644
--- a/src/Marathon.Hosts.WpfBlazor/appsettings.json
+++ b/src/Marathon.Hosts.WpfBlazor/appsettings.json
@@ -53,6 +53,12 @@
"MinScore": 0.45,
"PollIntervalSeconds": 60
},
+ "PaperTrading": {
+ "Enabled": false,
+ "MinScore": 0.55,
+ "FlatStake": 10,
+ "PollIntervalSeconds": 60
+ },
"Localization": {
"DefaultCulture": "ru-RU"
},
diff --git a/src/Marathon.Infrastructure/Configuration/PaperTradingOptions.cs b/src/Marathon.Infrastructure/Configuration/PaperTradingOptions.cs
new file mode 100644
index 0000000..4dd38a7
--- /dev/null
+++ b/src/Marathon.Infrastructure/Configuration/PaperTradingOptions.cs
@@ -0,0 +1,22 @@
+namespace Marathon.Infrastructure.Configuration;
+
+///
+/// Options for the forward-test (paper-trading) worker, bound to the
+/// PaperTrading configuration section.
+///
+public sealed class PaperTradingOptions
+{
+ public const string SectionName = "PaperTrading";
+
+ /// Master switch. When false the worker idles (cheap re-check). Default false.
+ public bool Enabled { get; init; }
+
+ /// Minimum anomaly score required to open a paper bet. Default 0.55.
+ public decimal MinScore { get; init; } = 0.55m;
+
+ /// Flat stake placed on every paper bet (currency-agnostic units). Default 10.
+ public decimal FlatStake { get; init; } = 10m;
+
+ /// Seconds between open/settle cycles. Floored at 5. Default 60.
+ public int PollIntervalSeconds { get; init; } = 60;
+}
diff --git a/src/Marathon.Infrastructure/InfrastructureModule.cs b/src/Marathon.Infrastructure/InfrastructureModule.cs
index d70cc75..0372024 100644
--- a/src/Marathon.Infrastructure/InfrastructureModule.cs
+++ b/src/Marathon.Infrastructure/InfrastructureModule.cs
@@ -56,6 +56,10 @@ public static class InfrastructureModule
.AddOptions()
.Bind(config.GetSection(NotificationOptions.SectionName));
+ services
+ .AddOptions()
+ .Bind(config.GetSection(PaperTradingOptions.SectionName));
+
services.AddHostedService();
services.AddHostedService();
services.AddHostedService();
@@ -69,6 +73,9 @@ public static class InfrastructureModule
services.AddSingleton();
services.AddHostedService();
+ // Forward-test (paper-trading) engine. Idles until PaperTrading:Enabled is true.
+ services.AddHostedService();
+
return services;
}
}
diff --git a/src/Marathon.Infrastructure/Migrations/20260528232145_AddPaperBets.Designer.cs b/src/Marathon.Infrastructure/Migrations/20260528232145_AddPaperBets.Designer.cs
new file mode 100644
index 0000000..e9a390f
--- /dev/null
+++ b/src/Marathon.Infrastructure/Migrations/20260528232145_AddPaperBets.Designer.cs
@@ -0,0 +1,439 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("DetectedAt")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("EventCode")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("EvidenceJson")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("PeriodNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rate")
+ .HasColumnType("TEXT");
+
+ b.Property("Scope")
+ .HasColumnType("INTEGER");
+
+ b.Property("Side")
+ .HasColumnType("INTEGER");
+
+ b.Property("SnapshotId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("EventCode")
+ .HasColumnType("TEXT");
+
+ b.Property("Category")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("");
+
+ b.Property("CountryCode")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("EventPath")
+ .HasColumnType("TEXT");
+
+ b.Property("LeagueId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ScheduledAt")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Side1Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Side2Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("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("EventCode")
+ .HasColumnType("TEXT");
+
+ b.Property("CompletedAt")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Side1Score")
+ .HasColumnType("INTEGER");
+
+ b.Property("Side2Score")
+ .HasColumnType("INTEGER");
+
+ b.Property("WinnerSide")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("EventCode");
+
+ b.ToTable("EventResults", (string)null);
+ });
+
+ modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("Category")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("");
+
+ b.Property("Country")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("NameEn")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("NameRu")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("SportCode")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Leagues", (string)null);
+ });
+
+ modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PaperBetEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AnomalyId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("EventCode")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("OpenedAt")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Outcome")
+ .HasColumnType("INTEGER");
+
+ b.Property("Payout")
+ .HasColumnType("TEXT");
+
+ b.Property("PickedSide")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rate")
+ .HasColumnType("TEXT");
+
+ b.Property("SettledAt")
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("EventCode")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("Outcome")
+ .HasColumnType("INTEGER");
+
+ b.Property("PeriodNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlacedAt")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Rate")
+ .HasColumnType("TEXT");
+
+ b.Property("Scope")
+ .HasColumnType("INTEGER");
+
+ b.Property("Side")
+ .HasColumnType("INTEGER");
+
+ b.Property("Stake")
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("FlatStake")
+ .HasColumnType("TEXT");
+
+ b.Property("KellyFraction")
+ .HasColumnType("TEXT");
+
+ b.Property("MinScore")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .UseCollation("NOCASE");
+
+ b.Property("PercentOfBankroll")
+ .HasColumnType("TEXT");
+
+ b.Property("StakeRule")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CapturedAt")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("EventCode")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("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("Code")
+ .HasColumnType("INTEGER");
+
+ b.Property("NameEn")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/src/Marathon.Infrastructure/Migrations/20260528232145_AddPaperBets.cs b/src/Marathon.Infrastructure/Migrations/20260528232145_AddPaperBets.cs
new file mode 100644
index 0000000..71c4755
--- /dev/null
+++ b/src/Marathon.Infrastructure/Migrations/20260528232145_AddPaperBets.cs
@@ -0,0 +1,52 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Marathon.Infrastructure.Migrations
+{
+ ///
+ public partial class AddPaperBets : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "PaperBets",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ AnomalyId = table.Column(type: "TEXT", nullable: false),
+ EventCode = table.Column(type: "TEXT", nullable: false),
+ PickedSide = table.Column(type: "INTEGER", nullable: false),
+ Rate = table.Column(type: "TEXT", nullable: false),
+ Stake = table.Column(type: "TEXT", nullable: false),
+ OpenedAt = table.Column(type: "TEXT", nullable: false),
+ Outcome = table.Column(type: "INTEGER", nullable: false),
+ SettledAt = table.Column(type: "TEXT", nullable: true),
+ Payout = table.Column(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");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "PaperBets");
+ }
+ }
+}
diff --git a/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs b/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs
index ebf2ca2..6e5620d 100644
--- a/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs
+++ b/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs
@@ -185,6 +185,53 @@ namespace Marathon.Infrastructure.Migrations
b.ToTable("Leagues", (string)null);
});
+ modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PaperBetEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AnomalyId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("EventCode")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("OpenedAt")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Outcome")
+ .HasColumnType("INTEGER");
+
+ b.Property("Payout")
+ .HasColumnType("TEXT");
+
+ b.Property("PickedSide")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rate")
+ .HasColumnType("TEXT");
+
+ b.Property("SettledAt")
+ .HasColumnType("TEXT");
+
+ b.Property("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("Id")
diff --git a/src/Marathon.Infrastructure/Persistence/Configurations/PaperBetConfiguration.cs b/src/Marathon.Infrastructure/Persistence/Configurations/PaperBetConfiguration.cs
new file mode 100644
index 0000000..5433c1c
--- /dev/null
+++ b/src/Marathon.Infrastructure/Persistence/Configurations/PaperBetConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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).
+ }
+}
diff --git a/src/Marathon.Infrastructure/Persistence/Entities/PaperBetEntity.cs b/src/Marathon.Infrastructure/Persistence/Entities/PaperBetEntity.cs
new file mode 100644
index 0000000..0483db9
--- /dev/null
+++ b/src/Marathon.Infrastructure/Persistence/Entities/PaperBetEntity.cs
@@ -0,0 +1,39 @@
+namespace Marathon.Infrastructure.Persistence.Entities;
+
+///
+/// EF Core persistence entity for a —
+/// the system-generated forward-test ledger. Decimals are stored as TEXT (invariant
+/// round-trip) to match the rest of the schema.
+///
+public sealed class PaperBetEntity
+{
+ /// GUID primary key stored as TEXT.
+ public string Id { get; set; } = default!;
+
+ /// The anomaly that triggered this paper bet (unique — one bet per anomaly).
+ public string AnomalyId { get; set; } = default!;
+
+ /// The event the bet is on.
+ public string EventCode { get; set; } = default!;
+
+ /// Picked Side as int (Side1 / Side2 / Draw).
+ public int PickedSide { get; set; }
+
+ /// Decimal odds locked in at the moment the signal fired.
+ public decimal Rate { get; set; }
+
+ /// Flat stake.
+ public decimal Stake { get; set; }
+
+ /// ISO 8601 timestamp of the originating anomaly's detection (Moscow time).
+ public string OpenedAt { get; set; } = default!;
+
+ /// BetOutcome as int (Pending = open / Won / Lost / Void).
+ public int Outcome { get; set; }
+
+ /// ISO 8601 settlement timestamp, or null while open.
+ public string? SettledAt { get; set; }
+
+ /// Realised payout once settled (stake × rate on a win, 0 on a loss), else null.
+ public decimal? Payout { get; set; }
+}
diff --git a/src/Marathon.Infrastructure/Persistence/Mapping.cs b/src/Marathon.Infrastructure/Persistence/Mapping.cs
index f360f42..df160e8 100644
--- a/src/Marathon.Infrastructure/Persistence/Mapping.cs
+++ b/src/Marathon.Infrastructure/Persistence/Mapping.cs
@@ -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);
}
diff --git a/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs b/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs
index 916b251..a286208 100644
--- a/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs
+++ b/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs
@@ -20,6 +20,7 @@ public sealed class MarathonDbContext : DbContext
public DbSet Leagues => Set();
public DbSet PlacedBets => Set();
public DbSet SavedStrategies => Set();
+ public DbSet PaperBets => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
diff --git a/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs b/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs
index 79ebd4a..97d02c2 100644
--- a/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs
+++ b/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs
@@ -55,6 +55,7 @@ public static class PersistenceModule
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
return services;
diff --git a/src/Marathon.Infrastructure/Persistence/Repositories/PaperBetRepository.cs b/src/Marathon.Infrastructure/Persistence/Repositories/PaperBetRepository.cs
new file mode 100644
index 0000000..3ff0d1a
--- /dev/null
+++ b/src/Marathon.Infrastructure/Persistence/Repositories/PaperBetRepository.cs
@@ -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 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> 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> 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> GetExistingAnomalyIdsAsync(
+ IReadOnlyCollection anomalyIds, CancellationToken ct = default)
+ {
+ if (anomalyIds.Count == 0)
+ return new HashSet();
+
+ 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);
+}
diff --git a/src/Marathon.Infrastructure/Workers/PaperTradingWorker.cs b/src/Marathon.Infrastructure/Workers/PaperTradingWorker.cs
new file mode 100644
index 0000000..aa72385
--- /dev/null
+++ b/src/Marathon.Infrastructure/Workers/PaperTradingWorker.cs
@@ -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;
+
+///
+/// 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 is false.
+///
+///
+/// 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 PaperBets.AnomalyId backstops any double-open.
+/// Scoped use cases are resolved per cycle (EF Core DbContext lifetime).
+///
+internal sealed class PaperTradingWorker : BackgroundService
+{
+ private readonly IServiceProvider _services;
+ private readonly IOptionsMonitor _opts;
+ private readonly ILogger _logger;
+
+ private DateTimeOffset _since;
+
+ public PaperTradingWorker(
+ IServiceProvider services,
+ IOptionsMonitor opts,
+ ILogger 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();
+ 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();
+ 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.
+ }
+ }
+}
diff --git a/tests/Marathon.Application.Tests/UseCases/OpenPaperBetsUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/OpenPaperBetsUseCaseTests.cs
new file mode 100644
index 0000000..f7504a7
--- /dev/null
+++ b/tests/Marathon.Application.Tests/UseCases/OpenPaperBetsUseCaseTests.cs
@@ -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();
+ private readonly IPaperBetRepository _paperBets = Substitute.For();
+
+ public OpenPaperBetsUseCaseTests()
+ {
+ // Default: nothing already forward-tested.
+ _paperBets
+ .GetExistingAnomalyIdsAsync(Arg.Any>(), Arg.Any())
+ .Returns(new HashSet());
+ }
+
+ private OpenPaperBetsUseCase CreateSut() =>
+ new(_anomalies, _paperBets, NullLogger.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(), Arg.Any(), Arg.Any())
+ .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(), Arg.Any());
+ await _paperBets.Received(1).SaveChangesAsync(Arg.Any());
+ }
+
+ [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(), Arg.Any());
+ }
+
+ [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>(), Arg.Any())
+ .Returns(new HashSet { a.Id });
+
+ (await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty();
+ await _paperBets.DidNotReceive().AddAsync(Arg.Any(), Arg.Any());
+ }
+
+ [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();
+ }
+}
diff --git a/tests/Marathon.Application.Tests/UseCases/SettlePaperBetsUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/SettlePaperBetsUseCaseTests.cs
new file mode 100644
index 0000000..2e45b30
--- /dev/null
+++ b/tests/Marathon.Application.Tests/UseCases/SettlePaperBetsUseCaseTests.cs
@@ -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();
+ private readonly IResultRepository _results = Substitute.For();
+
+ private SettlePaperBetsUseCase CreateSut() =>
+ new(_paperBets, _results, NullLogger.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()).Returns(bets);
+
+ private void HaveResults(params EventResult[] results) =>
+ _results
+ .GetManyAsync(Arg.Any>(), Arg.Any())
+ .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(b => b.Outcome == BetOutcome.Won && b.Payout == 20m),
+ Arg.Any());
+ await _paperBets.Received(1).SaveChangesAsync(Arg.Any());
+ }
+
+ [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(b => b.Outcome == BetOutcome.Lost && b.Payout == 0m),
+ Arg.Any());
+ }
+
+ [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(), Arg.Any());
+ await _paperBets.DidNotReceive().SaveChangesAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task NoOp_When_NoOpenBets()
+ {
+ HaveOpen();
+
+ (await CreateSut().ExecuteAsync()).Should().Be(0);
+ await _results.DidNotReceive().GetManyAsync(Arg.Any>(), Arg.Any());
+ }
+}
diff --git a/tests/Marathon.Domain.Tests/Entities/PaperBetTests.cs b/tests/Marathon.Domain.Tests/Entities/PaperBetTests.cs
new file mode 100644
index 0000000..cc0e54a
--- /dev/null
+++ b/tests/Marathon.Domain.Tests/Entities/PaperBetTests.cs
@@ -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();
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-5)]
+ public void Constructor_Throws_When_StakeNotPositive(double stake)
+ {
+ var act = () => OpenBet(stake: (decimal)stake);
+ act.Should().Throw();
+ }
+
+ [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);
+ }
+}
diff --git a/tests/Marathon.Infrastructure.Tests/Persistence/PaperBetRoundTripTests.cs b/tests/Marathon.Infrastructure.Tests/Persistence/PaperBetRoundTripTests.cs
new file mode 100644
index 0000000..11e2887
--- /dev/null
+++ b/tests/Marathon.Infrastructure.Tests/Persistence/PaperBetRoundTripTests.cs
@@ -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;
+
+///
+/// Round-trip + query tests for . Uses the in-memory
+/// SQLite fixture so the table + unique AnomalyId / Outcome indexes are exercised.
+///
+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())).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();
+ }
+}