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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Marathon.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Marathon.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(MarathonDbContext))]
|
||||
[Migration("20260528232145_AddPaperBets")]
|
||||
partial class AddPaperBets
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DetectedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EvidenceJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Score")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EventCode")
|
||||
.HasDatabaseName("IX_Anomalies_EventCode");
|
||||
|
||||
b.ToTable("Anomalies", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("PeriodNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Rate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Side")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SnapshotId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal?>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SnapshotId")
|
||||
.HasDatabaseName("IX_Bets_SnapshotId");
|
||||
|
||||
b.ToTable("Bets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
|
||||
{
|
||||
b.Property<string>("EventCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventPath")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LeagueId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ScheduledAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Side1Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Side2Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SportCode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("EventCode");
|
||||
|
||||
b.HasIndex("ScheduledAt")
|
||||
.HasDatabaseName("IX_Events_ScheduledAt");
|
||||
|
||||
b.HasIndex("SportCode", "ScheduledAt")
|
||||
.HasDatabaseName("IX_Events_SportCode_ScheduledAt");
|
||||
|
||||
b.ToTable("Events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("EventCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CompletedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Side1Score")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Side2Score")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WinnerSide")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("EventCode");
|
||||
|
||||
b.ToTable("EventResults", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Country")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NameEn")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NameRu")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SportCode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Leagues", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PaperBetEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AnomalyId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OpenedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Outcome")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal?>("Payout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PickedSide")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Rate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SettledAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Stake")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnomalyId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_PaperBets_AnomalyId");
|
||||
|
||||
b.HasIndex("Outcome")
|
||||
.HasDatabaseName("IX_PaperBets_Outcome");
|
||||
|
||||
b.ToTable("PaperBets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Outcome")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("PeriodNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlacedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Rate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Side")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Stake")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal?>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EventCode")
|
||||
.HasDatabaseName("IX_PlacedBets_EventCode");
|
||||
|
||||
b.HasIndex("Outcome")
|
||||
.HasDatabaseName("IX_PlacedBets_Outcome");
|
||||
|
||||
b.ToTable("PlacedBets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SavedStrategyEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("FlatStake")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KellyFraction")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("MinScore")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<decimal>("PercentOfBankroll")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("StakeRule")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("StartingBankroll")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_SavedStrategies_Name");
|
||||
|
||||
b.ToTable("SavedStrategies", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CapturedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Source")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EventCode")
|
||||
.HasDatabaseName("IX_Snapshots_EventCode");
|
||||
|
||||
b.HasIndex("EventCode", "CapturedAt")
|
||||
.HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
|
||||
|
||||
b.HasIndex("EventCode", "Source", "CapturedAt")
|
||||
.HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
|
||||
|
||||
b.ToTable("Snapshots", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
|
||||
{
|
||||
b.Property<int>("Code")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NameEn")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NameRu")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Code");
|
||||
|
||||
b.ToTable("Sports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
|
||||
{
|
||||
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
|
||||
.WithMany("Anomalies")
|
||||
.HasForeignKey("EventCode")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Event");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
|
||||
{
|
||||
b.HasOne("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", "Snapshot")
|
||||
.WithMany("Bets")
|
||||
.HasForeignKey("SnapshotId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Snapshot");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
|
||||
{
|
||||
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
|
||||
.WithOne("Result")
|
||||
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Event");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
|
||||
{
|
||||
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
|
||||
.WithMany("Snapshots")
|
||||
.HasForeignKey("EventCode")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Event");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
|
||||
{
|
||||
b.Navigation("Anomalies");
|
||||
|
||||
b.Navigation("Result");
|
||||
|
||||
b.Navigation("Snapshots");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
|
||||
{
|
||||
b.Navigation("Bets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Marathon.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPaperBets : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PaperBets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
AnomalyId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
EventCode = table.Column<string>(type: "TEXT", nullable: false),
|
||||
PickedSide = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Rate = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
Stake = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
OpenedAt = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Outcome = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
SettledAt = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Payout = table.Column<decimal>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PaperBets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PaperBets_AnomalyId",
|
||||
table: "PaperBets",
|
||||
column: "AnomalyId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PaperBets_Outcome",
|
||||
table: "PaperBets",
|
||||
column: "Outcome");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PaperBets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,53 @@ namespace Marathon.Infrastructure.Migrations
|
||||
b.ToTable("Leagues", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PaperBetEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AnomalyId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OpenedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Outcome")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal?>("Payout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PickedSide")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Rate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SettledAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Stake")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnomalyId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_PaperBets_AnomalyId");
|
||||
|
||||
b.HasIndex("Outcome")
|
||||
.HasDatabaseName("IX_PaperBets_Outcome");
|
||||
|
||||
b.ToTable("PaperBets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Marathon.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Marathon.Infrastructure.Persistence.Configurations;
|
||||
|
||||
internal sealed class PaperBetConfiguration : IEntityTypeConfiguration<PaperBetEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PaperBetEntity> builder)
|
||||
{
|
||||
builder.ToTable("PaperBets");
|
||||
|
||||
builder.HasKey(b => b.Id);
|
||||
builder.Property(b => b.Id).HasColumnType("TEXT").IsRequired();
|
||||
builder.Property(b => b.AnomalyId).HasColumnType("TEXT").IsRequired();
|
||||
builder.Property(b => b.EventCode).HasColumnType("TEXT").IsRequired();
|
||||
|
||||
builder.Property(b => b.PickedSide).HasColumnType("INTEGER").IsRequired();
|
||||
builder.Property(b => b.Rate).HasColumnType("TEXT").IsRequired();
|
||||
builder.Property(b => b.Stake).HasColumnType("TEXT").IsRequired();
|
||||
builder.Property(b => b.OpenedAt).HasColumnType("TEXT").IsRequired();
|
||||
builder.Property(b => b.Outcome).HasColumnType("INTEGER").IsRequired();
|
||||
builder.Property(b => b.SettledAt).HasColumnType("TEXT");
|
||||
builder.Property(b => b.Payout).HasColumnType("TEXT");
|
||||
|
||||
// One paper bet per anomaly — the opener skips existing ids, and this index is
|
||||
// the hard backstop against a double-open race.
|
||||
builder.HasIndex(b => b.AnomalyId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_PaperBets_AnomalyId");
|
||||
|
||||
// The settler scans the open (Pending) set every cycle.
|
||||
builder.HasIndex(b => b.Outcome).HasDatabaseName("IX_PaperBets_Outcome");
|
||||
|
||||
// No FK to Events/Anomalies — the ledger is analysis data and must survive
|
||||
// snapshot-retention pruning of the source rows (same rationale as PlacedBets).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace Marathon.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core persistence entity for a <see cref="Marathon.Domain.Entities.PaperBet"/> —
|
||||
/// the system-generated forward-test ledger. Decimals are stored as TEXT (invariant
|
||||
/// round-trip) to match the rest of the schema.
|
||||
/// </summary>
|
||||
public sealed class PaperBetEntity
|
||||
{
|
||||
/// <summary>GUID primary key stored as TEXT.</summary>
|
||||
public string Id { get; set; } = default!;
|
||||
|
||||
/// <summary>The anomaly that triggered this paper bet (unique — one bet per anomaly).</summary>
|
||||
public string AnomalyId { get; set; } = default!;
|
||||
|
||||
/// <summary>The event the bet is on.</summary>
|
||||
public string EventCode { get; set; } = default!;
|
||||
|
||||
/// <summary>Picked Side as int (Side1 / Side2 / Draw).</summary>
|
||||
public int PickedSide { get; set; }
|
||||
|
||||
/// <summary>Decimal odds locked in at the moment the signal fired.</summary>
|
||||
public decimal Rate { get; set; }
|
||||
|
||||
/// <summary>Flat stake.</summary>
|
||||
public decimal Stake { get; set; }
|
||||
|
||||
/// <summary>ISO 8601 timestamp of the originating anomaly's detection (Moscow time).</summary>
|
||||
public string OpenedAt { get; set; } = default!;
|
||||
|
||||
/// <summary>BetOutcome as int (Pending = open / Won / Lost / Void).</summary>
|
||||
public int Outcome { get; set; }
|
||||
|
||||
/// <summary>ISO 8601 settlement timestamp, or null while open.</summary>
|
||||
public string? SettledAt { get; set; }
|
||||
|
||||
/// <summary>Realised payout once settled (stake × rate on a win, 0 on a loss), else null.</summary>
|
||||
public decimal? Payout { get; set; }
|
||||
}
|
||||
@@ -252,4 +252,34 @@ internal static class Mapping
|
||||
PercentOfBankroll: entity.PercentOfBankroll,
|
||||
KellyFraction: entity.KellyFraction),
|
||||
CreatedAt: SqliteDateText.Parse(entity.CreatedAt));
|
||||
|
||||
// ─── PaperBet ──────────────────────────────────────────────────────────────
|
||||
|
||||
public static PaperBetEntity ToEntity(PaperBet domain) =>
|
||||
new()
|
||||
{
|
||||
Id = domain.Id.ToString(),
|
||||
AnomalyId = domain.AnomalyId.ToString(),
|
||||
EventCode = domain.EventId.Value,
|
||||
PickedSide = (int)domain.PickedSide,
|
||||
Rate = domain.Rate,
|
||||
Stake = domain.Stake,
|
||||
OpenedAt = SqliteDateText.Key(domain.OpenedAt),
|
||||
Outcome = (int)domain.Outcome,
|
||||
SettledAt = domain.SettledAt is { } s ? SqliteDateText.Key(s) : null,
|
||||
Payout = domain.Payout,
|
||||
};
|
||||
|
||||
public static PaperBet ToDomain(PaperBetEntity entity) =>
|
||||
new(
|
||||
Id: Guid.Parse(entity.Id),
|
||||
AnomalyId: Guid.Parse(entity.AnomalyId),
|
||||
EventId: new EventId(entity.EventCode),
|
||||
PickedSide: (Side)entity.PickedSide,
|
||||
Rate: entity.Rate,
|
||||
Stake: entity.Stake,
|
||||
OpenedAt: SqliteDateText.Parse(entity.OpenedAt),
|
||||
Outcome: (BetOutcome)entity.Outcome,
|
||||
SettledAt: entity.SettledAt is { } s ? SqliteDateText.Parse(s) : null,
|
||||
Payout: entity.Payout);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class MarathonDbContext : DbContext
|
||||
public DbSet<LeagueEntity> Leagues => Set<LeagueEntity>();
|
||||
public DbSet<PlacedBetEntity> PlacedBets => Set<PlacedBetEntity>();
|
||||
public DbSet<SavedStrategyEntity> SavedStrategies => Set<SavedStrategyEntity>();
|
||||
public DbSet<PaperBetEntity> PaperBets => Set<PaperBetEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -55,6 +55,7 @@ public static class PersistenceModule
|
||||
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
|
||||
services.AddScoped<IPlacedBetRepository, PlacedBetRepository>();
|
||||
services.AddScoped<ISavedStrategyRepository, SavedStrategyRepository>();
|
||||
services.AddScoped<IPaperBetRepository, PaperBetRepository>();
|
||||
services.AddScoped<IExcelExporter, ExcelExporter>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Marathon.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class PaperBetRepository : IPaperBetRepository
|
||||
{
|
||||
private readonly MarathonDbContext _db;
|
||||
|
||||
public PaperBetRepository(MarathonDbContext db) => _db = db;
|
||||
|
||||
public async Task<PaperBet?> GetAsync(Guid key, CancellationToken ct = default)
|
||||
{
|
||||
var idStr = key.ToString();
|
||||
var entity = await _db.PaperBets.AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.Id == idStr, ct);
|
||||
return entity is null ? null : Mapping.ToDomain(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PaperBet>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
var entities = await _db.PaperBets.AsNoTracking()
|
||||
.OrderByDescending(b => b.OpenedAt)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PaperBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default)
|
||||
{
|
||||
var outcomeInt = (int)outcome;
|
||||
var entities = await _db.PaperBets.AsNoTracking()
|
||||
.Where(b => b.Outcome == outcomeInt)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlySet<Guid>> GetExistingAnomalyIdsAsync(
|
||||
IReadOnlyCollection<Guid> anomalyIds, CancellationToken ct = default)
|
||||
{
|
||||
if (anomalyIds.Count == 0)
|
||||
return new HashSet<Guid>();
|
||||
|
||||
var idStrings = anomalyIds.Select(id => id.ToString()).ToList();
|
||||
var existing = await _db.PaperBets.AsNoTracking()
|
||||
.Where(b => idStrings.Contains(b.AnomalyId))
|
||||
.Select(b => b.AnomalyId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return existing.Select(Guid.Parse).ToHashSet();
|
||||
}
|
||||
|
||||
public async Task AddAsync(PaperBet entity, CancellationToken ct = default)
|
||||
{
|
||||
var efEntity = Mapping.ToEntity(entity);
|
||||
await _db.PaperBets.AddAsync(efEntity, ct);
|
||||
}
|
||||
|
||||
public Task UpdateAsync(PaperBet entity, CancellationToken ct = default)
|
||||
{
|
||||
var efEntity = Mapping.ToEntity(entity);
|
||||
_db.PaperBets.Update(efEntity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid key, CancellationToken ct = default)
|
||||
{
|
||||
var idStr = key.ToString();
|
||||
var entity = await _db.PaperBets.FirstOrDefaultAsync(b => b.Id == idStr, ct);
|
||||
if (entity is not null)
|
||||
_db.PaperBets.Remove(entity);
|
||||
}
|
||||
|
||||
public async Task SaveChangesAsync(CancellationToken ct = default) =>
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Marathon.Infrastructure.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Marathon.Infrastructure.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Forward-test engine: each cycle opens flat-stake paper bets for newly detected
|
||||
/// directional anomalies, then settles any open bets whose events have been graded.
|
||||
/// Idle (cheap re-check) while <see cref="PaperTradingOptions.Enabled"/> is false.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The "since" marker is baselined to startup so pre-existing anomalies are not
|
||||
/// retro-traded, and advances to each cycle's upper bound only after the open pass
|
||||
/// succeeds. A unique index on <c>PaperBets.AnomalyId</c> backstops any double-open.
|
||||
/// Scoped use cases are resolved per cycle (EF Core DbContext lifetime).
|
||||
/// </remarks>
|
||||
internal sealed class PaperTradingWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IOptionsMonitor<PaperTradingOptions> _opts;
|
||||
private readonly ILogger<PaperTradingWorker> _logger;
|
||||
|
||||
private DateTimeOffset _since;
|
||||
|
||||
public PaperTradingWorker(
|
||||
IServiceProvider services,
|
||||
IOptionsMonitor<PaperTradingOptions> opts,
|
||||
ILogger<PaperTradingWorker> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Baseline: only forward-test anomalies detected after this worker started.
|
||||
_since = MoscowTime.Now;
|
||||
_logger.LogInformation("PaperTradingWorker: started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var opts = _opts.CurrentValue;
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
await DelayQuietly(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var until = MoscowTime.Now;
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
|
||||
var open = scope.ServiceProvider.GetRequiredService<OpenPaperBetsUseCase>();
|
||||
await open.ExecuteAsync(_since, until, opts.MinScore, opts.FlatStake, stoppingToken);
|
||||
// Advance only after a successful open pass, so a failure replays the window.
|
||||
_since = until;
|
||||
|
||||
var settle = scope.ServiceProvider.GetRequiredService<SettlePaperBetsUseCase>();
|
||||
await settle.ExecuteAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "PaperTradingWorker: cycle failed — will retry after interval");
|
||||
}
|
||||
|
||||
await DelayQuietly(TimeSpan.FromSeconds(Math.Max(5, opts.PollIntervalSeconds)), stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("PaperTradingWorker: stopping");
|
||||
}
|
||||
|
||||
private static async Task DelayQuietly(TimeSpan delay, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutting down — swallow so ExecuteAsync's loop check exits cleanly.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Marathon.Application.Tests.UseCases;
|
||||
|
||||
public sealed class OpenPaperBetsUseCaseTests
|
||||
{
|
||||
private static readonly TimeSpan Msk = TimeSpan.FromHours(3);
|
||||
private static readonly DateTimeOffset T0 = new(2026, 5, 20, 18, 0, 0, Msk);
|
||||
|
||||
private readonly IAnomalyRepository _anomalies = Substitute.For<IAnomalyRepository>();
|
||||
private readonly IPaperBetRepository _paperBets = Substitute.For<IPaperBetRepository>();
|
||||
|
||||
public OpenPaperBetsUseCaseTests()
|
||||
{
|
||||
// Default: nothing already forward-tested.
|
||||
_paperBets
|
||||
.GetExistingAnomalyIdsAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new HashSet<Guid>());
|
||||
}
|
||||
|
||||
private OpenPaperBetsUseCase CreateSut() =>
|
||||
new(_anomalies, _paperBets, NullLogger<OpenPaperBetsUseCase>.Instance);
|
||||
|
||||
// Flip: pre favourite Side1, post favourite Side2 @ 1.60.
|
||||
private const string FlipToSide2 = """
|
||||
{"suspensionGapSeconds":90,
|
||||
"preSuspension":{"capturedAt":"2026-05-20T18:00:00+03:00","p1":0.6,"p2":0.4,"rate1":1.6,"rate2":2.5},
|
||||
"postSuspension":{"capturedAt":"2026-05-20T18:02:00+03:00","p1":0.4,"p2":0.6,"rate1":2.5,"rate2":1.6}}
|
||||
""";
|
||||
|
||||
private static Anomaly Anomaly(AnomalyKind kind, decimal score, string evidence) =>
|
||||
new(Guid.NewGuid(), new EventId("26000001"), T0.AddMinutes(2), kind, score, evidence);
|
||||
|
||||
private void HaveAnomalies(params Anomaly[] anomalies) =>
|
||||
_anomalies
|
||||
.ListByDateRangeAsync(Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(anomalies);
|
||||
|
||||
[Fact]
|
||||
public async Task Opens_DirectionalAboveThreshold_WithPostFavouriteAndRate()
|
||||
{
|
||||
var a = Anomaly(AnomalyKind.SuspensionFlip, 0.7m, FlipToSide2);
|
||||
HaveAnomalies(a);
|
||||
|
||||
var opened = await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), minScore: 0.5m, flatStake: 10m);
|
||||
|
||||
opened.Should().ContainSingle();
|
||||
opened[0].AnomalyId.Should().Be(a.Id);
|
||||
opened[0].PickedSide.Should().Be(Side.Side2);
|
||||
opened[0].Rate.Should().Be(1.6m);
|
||||
opened[0].Stake.Should().Be(10m);
|
||||
opened[0].Outcome.Should().Be(BetOutcome.Pending);
|
||||
await _paperBets.Received(1).AddAsync(Arg.Any<PaperBet>(), Arg.Any<CancellationToken>());
|
||||
await _paperBets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_NonDirectionalKinds()
|
||||
{
|
||||
HaveAnomalies(Anomaly(AnomalyKind.SuspensionFreeze, 0.9m, FlipToSide2));
|
||||
|
||||
var opened = await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m);
|
||||
|
||||
opened.Should().BeEmpty();
|
||||
await _paperBets.DidNotReceive().AddAsync(Arg.Any<PaperBet>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_BelowThreshold()
|
||||
{
|
||||
HaveAnomalies(Anomaly(AnomalyKind.SuspensionFlip, 0.3m, FlipToSide2));
|
||||
|
||||
(await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_AnomaliesThatAlreadyHaveAPaperBet()
|
||||
{
|
||||
var a = Anomaly(AnomalyKind.SuspensionFlip, 0.7m, FlipToSide2);
|
||||
HaveAnomalies(a);
|
||||
_paperBets
|
||||
.GetExistingAnomalyIdsAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new HashSet<Guid> { a.Id });
|
||||
|
||||
(await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty();
|
||||
await _paperBets.DidNotReceive().AddAsync(Arg.Any<PaperBet>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_AnomaliesWithUnparseableEvidence()
|
||||
{
|
||||
HaveAnomalies(Anomaly(AnomalyKind.SuspensionFlip, 0.7m, "not json"));
|
||||
|
||||
(await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 10m)).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throws_When_FlatStakeNotPositive()
|
||||
{
|
||||
var act = async () => await CreateSut().ExecuteAsync(T0, T0.AddMinutes(5), 0.5m, 0m);
|
||||
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Marathon.Application.Tests.UseCases;
|
||||
|
||||
public sealed class SettlePaperBetsUseCaseTests
|
||||
{
|
||||
private static readonly TimeSpan Msk = TimeSpan.FromHours(3);
|
||||
private static readonly DateTimeOffset T0 = new(2026, 5, 20, 18, 0, 0, Msk);
|
||||
|
||||
private readonly IPaperBetRepository _paperBets = Substitute.For<IPaperBetRepository>();
|
||||
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
|
||||
|
||||
private SettlePaperBetsUseCase CreateSut() =>
|
||||
new(_paperBets, _results, NullLogger<SettlePaperBetsUseCase>.Instance);
|
||||
|
||||
private static PaperBet Open(string eventCode, Side pick) =>
|
||||
PaperBet.Open(Guid.NewGuid(), new EventId(eventCode), pick, 2.0m, 10m, T0);
|
||||
|
||||
private static EventResult Result(string eventCode, Side winner) =>
|
||||
new(new EventId(eventCode), 2, 1, winner, T0.AddHours(2));
|
||||
|
||||
private void HaveOpen(params PaperBet[] bets) =>
|
||||
_paperBets.ListByOutcomeAsync(BetOutcome.Pending, Arg.Any<CancellationToken>()).Returns(bets);
|
||||
|
||||
private void HaveResults(params EventResult[] results) =>
|
||||
_results
|
||||
.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(results.ToDictionary(r => r.EventId));
|
||||
|
||||
[Fact]
|
||||
public async Task Settles_Won_When_PickMatchesWinner()
|
||||
{
|
||||
HaveOpen(Open("e1", Side.Side1));
|
||||
HaveResults(Result("e1", Side.Side1));
|
||||
|
||||
var settled = await CreateSut().ExecuteAsync();
|
||||
|
||||
settled.Should().Be(1);
|
||||
await _paperBets.Received(1).UpdateAsync(
|
||||
Arg.Is<PaperBet>(b => b.Outcome == BetOutcome.Won && b.Payout == 20m),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _paperBets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Settles_Lost_When_PickMisses()
|
||||
{
|
||||
HaveOpen(Open("e1", Side.Side1));
|
||||
HaveResults(Result("e1", Side.Side2));
|
||||
|
||||
await CreateSut().ExecuteAsync();
|
||||
|
||||
await _paperBets.Received(1).UpdateAsync(
|
||||
Arg.Is<PaperBet>(b => b.Outcome == BetOutcome.Lost && b.Payout == 0m),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeavesOpen_When_EventNotGradedYet()
|
||||
{
|
||||
HaveOpen(Open("e1", Side.Side1));
|
||||
HaveResults(); // none graded
|
||||
|
||||
var settled = await CreateSut().ExecuteAsync();
|
||||
|
||||
settled.Should().Be(0);
|
||||
await _paperBets.DidNotReceive().UpdateAsync(Arg.Any<PaperBet>(), Arg.Any<CancellationToken>());
|
||||
await _paperBets.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoOp_When_NoOpenBets()
|
||||
{
|
||||
HaveOpen();
|
||||
|
||||
(await CreateSut().ExecuteAsync()).Should().Be(0);
|
||||
await _results.DidNotReceive().GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.Tests.Entities;
|
||||
|
||||
public sealed class PaperBetTests
|
||||
{
|
||||
private static readonly DateTimeOffset Opened = new(2026, 5, 20, 18, 0, 0, TimeSpan.FromHours(3));
|
||||
|
||||
private static PaperBet OpenBet(Side pick = Side.Side1, decimal rate = 2.00m, decimal stake = 10m) =>
|
||||
PaperBet.Open(Guid.NewGuid(), new EventId("26000001"), pick, rate, stake, Opened);
|
||||
|
||||
[Fact]
|
||||
public void Open_CreatesPendingBet_WithIdentity()
|
||||
{
|
||||
var bet = OpenBet();
|
||||
|
||||
bet.Id.Should().NotBe(Guid.Empty);
|
||||
bet.Outcome.Should().Be(BetOutcome.Pending);
|
||||
bet.IsOpen.Should().BeTrue();
|
||||
bet.SettledAt.Should().BeNull();
|
||||
bet.Payout.Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1.0)]
|
||||
[InlineData(0.5)]
|
||||
public void Constructor_Throws_When_RateNotAboveOne(double rate)
|
||||
{
|
||||
var act = () => OpenBet(rate: (decimal)rate);
|
||||
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-5)]
|
||||
public void Constructor_Throws_When_StakeNotPositive(double stake)
|
||||
{
|
||||
var act = () => OpenBet(stake: (decimal)stake);
|
||||
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettleAgainst_Win_PaysStakeTimesRate()
|
||||
{
|
||||
var bet = OpenBet(pick: Side.Side1, rate: 2.00m, stake: 10m);
|
||||
var settledAt = Opened.AddHours(2);
|
||||
|
||||
var settled = bet.SettleAgainst(Side.Side1, settledAt);
|
||||
|
||||
settled.Outcome.Should().Be(BetOutcome.Won);
|
||||
settled.Payout.Should().Be(20m);
|
||||
settled.SettledAt.Should().Be(settledAt);
|
||||
settled.IsOpen.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettleAgainst_Loss_PaysZero()
|
||||
{
|
||||
var settled = OpenBet(pick: Side.Side1).SettleAgainst(Side.Side2, Opened.AddHours(2));
|
||||
|
||||
settled.Outcome.Should().Be(BetOutcome.Lost);
|
||||
settled.Payout.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettleAgainst_PreservesIdentityStakeAndRate()
|
||||
{
|
||||
var bet = OpenBet(rate: 2.00m, stake: 10m);
|
||||
|
||||
var settled = bet.SettleAgainst(Side.Side1, Opened.AddHours(1));
|
||||
|
||||
settled.Id.Should().Be(bet.Id);
|
||||
settled.AnomalyId.Should().Be(bet.AnomalyId);
|
||||
settled.EventId.Should().Be(bet.EventId);
|
||||
settled.Stake.Should().Be(10m);
|
||||
settled.Rate.Should().Be(2.00m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Marathon.Infrastructure.Persistence.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Marathon.Infrastructure.Tests.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip + query tests for <see cref="PaperBetRepository"/>. Uses the in-memory
|
||||
/// SQLite fixture so the table + unique AnomalyId / Outcome indexes are exercised.
|
||||
/// </summary>
|
||||
public sealed class PaperBetRoundTripTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Msk = TimeSpan.FromHours(3);
|
||||
private static readonly DateTimeOffset Opened = new(2026, 5, 20, 18, 0, 0, Msk);
|
||||
|
||||
private readonly InMemoryDbFixture _fixture;
|
||||
private readonly PaperBetRepository _repo;
|
||||
|
||||
public PaperBetRoundTripTests()
|
||||
{
|
||||
_fixture = new InMemoryDbFixture();
|
||||
_repo = new PaperBetRepository(_fixture.DbContext);
|
||||
}
|
||||
|
||||
public void Dispose() => _fixture.Dispose();
|
||||
|
||||
private static PaperBet Open(
|
||||
Guid? anomalyId = null, string eventCode = "26000001", Side pick = Side.Side1) =>
|
||||
PaperBet.Open(anomalyId ?? Guid.NewGuid(), new EventId(eventCode), pick, 1.95m, 10m, Opened);
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_PreservesAllFields_ForSettledBet()
|
||||
{
|
||||
var settled = Open(pick: Side.Side2).SettleAgainst(Side.Side2, Opened.AddHours(2));
|
||||
|
||||
await _repo.AddAsync(settled);
|
||||
await _repo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
var got = await _repo.GetAsync(settled.Id);
|
||||
|
||||
got.Should().NotBeNull();
|
||||
got!.Id.Should().Be(settled.Id);
|
||||
got.AnomalyId.Should().Be(settled.AnomalyId);
|
||||
got.EventId.Value.Should().Be("26000001");
|
||||
got.PickedSide.Should().Be(Side.Side2);
|
||||
got.Rate.Should().Be(1.95m);
|
||||
got.Stake.Should().Be(10m);
|
||||
got.OpenedAt.Should().Be(Opened);
|
||||
got.OpenedAt.Offset.Should().Be(Msk);
|
||||
got.Outcome.Should().Be(BetOutcome.Won);
|
||||
got.Payout.Should().Be(19.5m);
|
||||
got.SettledAt.Should().Be(Opened.AddHours(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_OpenBet_HasNullSettlementFields()
|
||||
{
|
||||
var open = Open();
|
||||
await _repo.AddAsync(open);
|
||||
await _repo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
var got = await _repo.GetAsync(open.Id);
|
||||
got!.Outcome.Should().Be(BetOutcome.Pending);
|
||||
got.SettledAt.Should().BeNull();
|
||||
got.Payout.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByOutcomeAsync_ReturnsOnlyMatching()
|
||||
{
|
||||
await _repo.AddAsync(Open(eventCode: "open1"));
|
||||
await _repo.AddAsync(Open(eventCode: "open2"));
|
||||
await _repo.AddAsync(Open(eventCode: "won1").SettleAgainst(Side.Side1, Opened.AddHours(1)));
|
||||
await _repo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
var open = await _repo.ListByOutcomeAsync(BetOutcome.Pending);
|
||||
|
||||
open.Should().HaveCount(2);
|
||||
open.Should().OnlyContain(b => b.Outcome == BetOutcome.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExistingAnomalyIdsAsync_ReturnsKnownSubset()
|
||||
{
|
||||
var known = Guid.NewGuid();
|
||||
await _repo.AddAsync(Open(anomalyId: known));
|
||||
await _repo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
var unknown = Guid.NewGuid();
|
||||
var existing = await _repo.GetExistingAnomalyIdsAsync(new[] { known, unknown });
|
||||
|
||||
existing.Should().ContainSingle().And.Contain(known);
|
||||
existing.Should().NotContain(unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExistingAnomalyIdsAsync_EmptyInput_ReturnsEmpty_WithoutQuery()
|
||||
{
|
||||
(await _repo.GetExistingAnomalyIdsAsync(Array.Empty<Guid>())).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_PersistsSettlement()
|
||||
{
|
||||
var bet = Open(pick: Side.Side1);
|
||||
await _repo.AddAsync(bet);
|
||||
await _repo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
await _repo.UpdateAsync(bet.SettleAgainst(Side.Side1, Opened.AddHours(3)));
|
||||
await _repo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
var got = await _repo.GetAsync(bet.Id);
|
||||
got!.Outcome.Should().Be(BetOutcome.Won);
|
||||
got.Payout.Should().Be(19.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UniqueAnomalyIdIndex_RejectsSecondBet_ForSameAnomaly()
|
||||
{
|
||||
var anomalyId = Guid.NewGuid();
|
||||
await _repo.AddAsync(Open(anomalyId: anomalyId, eventCode: "a"));
|
||||
await _repo.SaveChangesAsync();
|
||||
_fixture.DbContext.ChangeTracker.Clear();
|
||||
|
||||
await _repo.AddAsync(Open(anomalyId: anomalyId, eventCode: "b"));
|
||||
var act = async () => await _repo.SaveChangesAsync();
|
||||
|
||||
await act.Should().ThrowAsync<DbUpdateException>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user