From f622dadf95baed2e85cee202134127ec72f1bc34 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 02:25:54 +0300 Subject: [PATCH] feat(paper-trading): forward-test ledger engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Abstractions/IPaperBetRepository.cs | 24 + src/Marathon.Application/ApplicationModule.cs | 3 + .../UseCases/OpenPaperBetsUseCase.cs | 84 ++++ .../UseCases/SettlePaperBetsUseCase.cs | 61 +++ .../AnomalyDetection/AnomalyEvidenceData.cs | 12 + src/Marathon.Domain/Entities/PaperBet.cs | 72 +++ src/Marathon.Hosts.WpfBlazor/appsettings.json | 6 + .../Configuration/PaperTradingOptions.cs | 22 + .../InfrastructureModule.cs | 7 + .../20260528232145_AddPaperBets.Designer.cs | 439 ++++++++++++++++++ .../Migrations/20260528232145_AddPaperBets.cs | 52 +++ .../MarathonDbContextModelSnapshot.cs | 47 ++ .../Configurations/PaperBetConfiguration.cs | 38 ++ .../Persistence/Entities/PaperBetEntity.cs | 39 ++ .../Persistence/Mapping.cs | 30 ++ .../Persistence/MarathonDbContext.cs | 1 + .../Persistence/PersistenceModule.cs | 1 + .../Repositories/PaperBetRepository.cs | 77 +++ .../Workers/PaperTradingWorker.cs | 94 ++++ .../UseCases/OpenPaperBetsUseCaseTests.cs | 110 +++++ .../UseCases/SettlePaperBetsUseCaseTests.cs | 86 ++++ .../Entities/PaperBetTests.cs | 81 ++++ .../Persistence/PaperBetRoundTripTests.cs | 139 ++++++ 23 files changed, 1525 insertions(+) create mode 100644 src/Marathon.Application/Abstractions/IPaperBetRepository.cs create mode 100644 src/Marathon.Application/UseCases/OpenPaperBetsUseCase.cs create mode 100644 src/Marathon.Application/UseCases/SettlePaperBetsUseCase.cs create mode 100644 src/Marathon.Domain/Entities/PaperBet.cs create mode 100644 src/Marathon.Infrastructure/Configuration/PaperTradingOptions.cs create mode 100644 src/Marathon.Infrastructure/Migrations/20260528232145_AddPaperBets.Designer.cs create mode 100644 src/Marathon.Infrastructure/Migrations/20260528232145_AddPaperBets.cs create mode 100644 src/Marathon.Infrastructure/Persistence/Configurations/PaperBetConfiguration.cs create mode 100644 src/Marathon.Infrastructure/Persistence/Entities/PaperBetEntity.cs create mode 100644 src/Marathon.Infrastructure/Persistence/Repositories/PaperBetRepository.cs create mode 100644 src/Marathon.Infrastructure/Workers/PaperTradingWorker.cs create mode 100644 tests/Marathon.Application.Tests/UseCases/OpenPaperBetsUseCaseTests.cs create mode 100644 tests/Marathon.Application.Tests/UseCases/SettlePaperBetsUseCaseTests.cs create mode 100644 tests/Marathon.Domain.Tests/Entities/PaperBetTests.cs create mode 100644 tests/Marathon.Infrastructure.Tests/Persistence/PaperBetRoundTripTests.cs 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(); + } +}