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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user