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.
}
}
}