From 39aef449f7c68d40d11809e4705c86c976ce0152 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 02:33:42 +0300 Subject: [PATCH] feat(paper-trading): forward-test results page + worker hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the read-only paper-trading page (/paper-trading): settled-only P&L KPIs (net profit, ROI, hit rate, open count) plus a per-bet ledger table, with a Forward-test nav entry under Analysis. PaperTradingService batches the event-title join (no N+1) and folds settled bets into the summary. Also hardens PaperTradingWorker (review finding): settle now runs in its own catch so a transient settle failure can't advance the since-marker past an open window — the window replays until its opens succeed. - IPaperTradingService / PaperTradingService / PaperTradingVm + PaperBetRowVm. - en/ru resx (full parity), service registration, nav entry. - 2 service tests: empty ledger + settled-only aggregation incl. title fallback. --- .../Workers/PaperTradingWorker.cs | 19 +- src/Marathon.UI/Components/NavBody.razor | 4 + .../Pages/Anomalies/PaperTrading.razor | 311 ++++++++++++++++++ .../Resources/SharedResource.en.resx | 26 ++ .../Resources/SharedResource.ru.resx | 26 ++ .../Services/IPaperTradingService.cs | 11 + .../Services/PaperTradingService.cs | 78 +++++ .../Services/PaperTradingViewModels.cs | 36 ++ .../Services/UiServicesExtensions.cs | 1 + .../Services/PaperTradingServiceTests.cs | 74 +++++ 10 files changed, 582 insertions(+), 4 deletions(-) create mode 100644 src/Marathon.UI/Pages/Anomalies/PaperTrading.razor create mode 100644 src/Marathon.UI/Services/IPaperTradingService.cs create mode 100644 src/Marathon.UI/Services/PaperTradingService.cs create mode 100644 src/Marathon.UI/Services/PaperTradingViewModels.cs create mode 100644 tests/Marathon.UI.Tests/Services/PaperTradingServiceTests.cs diff --git a/src/Marathon.Infrastructure/Workers/PaperTradingWorker.cs b/src/Marathon.Infrastructure/Workers/PaperTradingWorker.cs index aa72385..a520702 100644 --- a/src/Marathon.Infrastructure/Workers/PaperTradingWorker.cs +++ b/src/Marathon.Infrastructure/Workers/PaperTradingWorker.cs @@ -59,11 +59,22 @@ internal sealed class PaperTradingWorker : BackgroundService var open = scope.ServiceProvider.GetRequiredService(); await open.ExecuteAsync(_since, until, opts.MinScore, opts.FlatStake, stoppingToken); - // Advance only after a successful open pass, so a failure replays the window. + // Advance only after a successful open pass, so an open failure replays the window. _since = until; - var settle = scope.ServiceProvider.GetRequiredService(); - await settle.ExecuteAsync(stoppingToken); + // Settle in its own catch: it rescans every Pending bet each cycle (idempotent), + // so a transient settle failure must NOT strand the marker — otherwise the window + // just opened above would be lost to a settle-only error. Shutdown cancellation is + // excluded so it propagates to the outer break. + try + { + var settle = scope.ServiceProvider.GetRequiredService(); + await settle.ExecuteAsync(stoppingToken); + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + _logger.LogError(ex, "PaperTradingWorker: settle failed — open bets retried next cycle"); + } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -71,7 +82,7 @@ internal sealed class PaperTradingWorker : BackgroundService } catch (Exception ex) { - _logger.LogError(ex, "PaperTradingWorker: cycle failed — will retry after interval"); + _logger.LogError(ex, "PaperTradingWorker: open cycle failed — will retry after interval"); } await DelayQuietly(TimeSpan.FromSeconds(Math.Max(5, opts.PollIntervalSeconds)), stoppingToken); diff --git a/src/Marathon.UI/Components/NavBody.razor b/src/Marathon.UI/Components/NavBody.razor index 4c39222..b36d83d 100644 --- a/src/Marathon.UI/Components/NavBody.razor +++ b/src/Marathon.UI/Components/NavBody.razor @@ -51,6 +51,10 @@ @L["Nav.Backtest"] + + + @L["Nav.PaperTrading"] + diff --git a/src/Marathon.UI/Pages/Anomalies/PaperTrading.razor b/src/Marathon.UI/Pages/Anomalies/PaperTrading.razor new file mode 100644 index 0000000..70071e6 --- /dev/null +++ b/src/Marathon.UI/Pages/Anomalies/PaperTrading.razor @@ -0,0 +1,311 @@ +@* + PaperTrading — the forward-test ledger. + + Read-only view of the paper bets the PaperTradingWorker opens on live directional + signals and settles as results arrive. Settled-only P&L KPIs + a per-bet table. + Same editorial-quant tone as Backtest / Insights. +*@ + +@page "/paper-trading" +@using System.Globalization +@using Marathon.Domain.Enums +@inject IStringLocalizer L +@inject IPaperTradingService Service +@inject NavigationManager Nav + +@L["App.Title"] · @L["Nav.PaperTrading"] + +
+
+ @L["Paper.Kicker"] +

@L["Paper.Title"]

+

@L["Paper.Lede"]

+
+ + @if (_loading) + { +
+ + @L["Common.Loading"] +
+ } + else if (_vm is null || (_vm.OpenCount == 0 && _vm.SettledCount == 0)) + { +
+ + @L["Common.Empty"] + +

+ @L["Paper.Empty"] +

+
+ } + else + { + var vm = _vm; +
+ +
+
+ @L["Paper.Section.Summary"] +
+
+
+ @L["Paper.Stat.NetProfit"] + @FormatSignedDecimal(vm.NetProfit, vm.SettledCount) +
+
+ @L["Paper.Stat.Roi"] + @FormatSignedPercent(vm.RoiPercent) +
+
+ @L["Paper.Stat.HitRate"] + @FormatPercent(vm.HitRatePercent) +
+
+ @L["Paper.Stat.Open"] + @vm.OpenCount +
+
+ +
+ @L["Paper.Stat.Settled"] @vm.SettledCount + + @L["Paper.Stat.Wins"] @vm.Wins + + @L["Paper.Stat.Losses"] @vm.Losses + + @L["Paper.Stat.Staked"] @vm.TotalStaked.ToString("0.00", CultureInfo.InvariantCulture) +
+
+ +
+ +
+
+ @L["Paper.Section.Ledger"] + @vm.Bets.Count +
+ +
+ + + + + + + + + + + + + + + @foreach (var row in vm.Bets) + { + var local = row; + + + + + + + + + + + } + +
@L["Paper.Column.OpenedAt"]@L["Paper.Column.Match"]@L["Paper.Column.Pick"]@L["Paper.Column.Rate"]@L["Paper.Column.Stake"]@L["Paper.Column.Status"]@L["Paper.Column.Payout"]
@local.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)@local.EventTitle@SideLabel(local.PickedSide)@local.Rate.ToString("0.00", CultureInfo.InvariantCulture)@local.Stake.ToString("0.00", CultureInfo.InvariantCulture) + + @OutcomeLabel(local.Outcome) + + + @(local.Payout is { } p ? p.ToString("0.00", CultureInfo.InvariantCulture) : "—") + + + @L["Insights.Action.OpenAnomaly"] + +
+
+
+ } +
+ + + +@code { + private PaperTradingVm? _vm; + private bool _loading = true; + + protected override async Task OnInitializedAsync() + { + try + { + _vm = await Service.GetAsync(CancellationToken.None); + } + catch + { + _vm = null; + } + finally + { + _loading = false; + } + } + + private void OpenAnomaly(MouseEventArgs e, Guid anomalyId) => + Nav.NavigateTo("/anomalies/" + anomalyId.ToString()); + + private string SideLabel(Side side) => side switch + { + Side.Side1 => L["Journal.Side.Side1"], + Side.Side2 => L["Journal.Side.Side2"], + Side.Draw => L["Journal.Side.Draw"], + _ => side.ToString(), + }; + + private string OutcomeLabel(BetOutcome outcome) => outcome switch + { + BetOutcome.Pending => L["Paper.Outcome.Open"], + BetOutcome.Won => L["Paper.Outcome.Won"], + BetOutcome.Lost => L["Paper.Outcome.Lost"], + BetOutcome.Void => L["Paper.Outcome.Void"], + _ => outcome.ToString(), + }; + + private static string OutcomeClass(BetOutcome outcome) => outcome switch + { + BetOutcome.Won => "won", + BetOutcome.Lost => "lost", + _ => "open", + }; + + private static string FormatSignedDecimal(decimal value, int settledCount) + { + if (settledCount == 0) return "—"; + var sign = value > 0m ? "+" : (value < 0m ? "-" : ""); + return sign + Math.Abs(value).ToString("0.00", CultureInfo.InvariantCulture); + } + + private static string FormatSignedPercent(decimal? value) + { + if (value is null) return "—"; + var v = value.Value; + var sign = v > 0m ? "+" : (v < 0m ? "-" : ""); + return sign + Math.Abs(v).ToString("0.0", CultureInfo.InvariantCulture) + "%"; + } + + private static string FormatPercent(decimal? value) => + value is null ? "—" : value.Value.ToString("0.0", CultureInfo.InvariantCulture) + "%"; + + private static string ProfitTone(PaperTradingVm vm) + { + if (vm.SettledCount == 0) return "neutral"; + if (vm.NetProfit > 0m) return "positive"; + if (vm.NetProfit < 0m) return "negative"; + return "neutral"; + } + + private static string RoiTone(decimal? roi) => roi switch + { + null => "neutral", + > 0m => "positive", + < 0m => "negative", + _ => "neutral", + }; +} diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index a7e4fde..d50f6f0 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -461,6 +461,7 @@ Delete this bet permanently? Backtest + Forward-test Simulator Replay the detector against history Run a hypothetical strategy over every anomaly the detector has flagged. Choose a confidence threshold and a staking rule — the simulator settles every bet against the actual event result, compounds bankroll, and reports the headline numbers you need to judge edge. @@ -519,4 +520,29 @@ Strategy saved Strategy deleted Enter a name to save this strategy. + Forward-test + Paper trading + Out-of-sample proof: the worker opens a flat-stake paper bet on every live directional signal and settles it when the result lands — the antidote to backtest overfitting. Enable it under PaperTrading in settings. + No paper bets yet. The forward-test worker is off by default — enable PaperTrading and bets will accrue here as new directional anomalies fire. + Settled P&L + Ledger + Net profit + ROI + Hit rate + Open + Settled + Wins + Losses + Staked + Opened + Match + Pick + Rate + Stake + Status + Payout + Open + Won + Lost + Void diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index b7a4044..bf28969 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -474,6 +474,7 @@ Удалить эту ставку безвозвратно? Бэктест + Форвард-тест Симулятор Прогон детектора по истории Запустите гипотетическую стратегию на всех зафиксированных аномалиях. Выберите порог уверенности и правило стейкинга — симулятор разыграет каждую ставку против реального исхода, нарастит банк и покажет ключевые метрики для оценки преимущества. @@ -532,4 +533,29 @@ Стратегия сохранена Стратегия удалена Введите название, чтобы сохранить стратегию. + Форвард-тест + Бумажная торговля + Проверка вне выборки: воркер открывает ставку фиксированным стейком на каждый живой направленный сигнал и рассчитывает её, когда приходит результат — противоядие от переобучения на бэктесте. Включается в разделе PaperTrading настроек. + Бумажных ставок пока нет. Воркер форвард-теста по умолчанию выключен — включите PaperTrading, и ставки начнут накапливаться здесь по мере появления новых направленных аномалий. + Рассчитанный P&L + Журнал + Чистая прибыль + ROI + Доля попаданий + Открыто + Рассчитано + Победы + Поражения + Поставлено + Открыта + Матч + Выбор + Кэф + Стейк + Статус + Выплата + Открыта + Выигрыш + Проигрыш + Возврат diff --git a/src/Marathon.UI/Services/IPaperTradingService.cs b/src/Marathon.UI/Services/IPaperTradingService.cs new file mode 100644 index 0000000..88d4716 --- /dev/null +++ b/src/Marathon.UI/Services/IPaperTradingService.cs @@ -0,0 +1,11 @@ +namespace Marathon.UI.Services; + +/// +/// Read-facing facade over the paper-trading (forward-test) ledger. Joins event +/// titles and computes the running settled-only P&L summary for the page. +/// +public interface IPaperTradingService +{ + /// The full ledger plus aggregate KPIs, newest bet first. + Task GetAsync(CancellationToken ct); +} diff --git a/src/Marathon.UI/Services/PaperTradingService.cs b/src/Marathon.UI/Services/PaperTradingService.cs new file mode 100644 index 0000000..7d07404 --- /dev/null +++ b/src/Marathon.UI/Services/PaperTradingService.cs @@ -0,0 +1,78 @@ +using Marathon.Application.Abstractions; +using Marathon.Domain.Enums; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.UI.Services; + +/// +/// Page-facing implementation of . Reads the paper-bet +/// ledger, batches event-title lookups (no N+1), and folds the settled bets into a P&L +/// summary. Open bets are shown but excluded from realised P&L. +/// +public sealed class PaperTradingService : IPaperTradingService +{ + private readonly IPaperBetRepository _paperBets; + private readonly IEventRepository _events; + + public PaperTradingService(IPaperBetRepository paperBets, IEventRepository events) + { + _paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets)); + _events = events ?? throw new ArgumentNullException(nameof(events)); + } + + public async Task GetAsync(CancellationToken ct) + { + var bets = await _paperBets.ListAsync(ct).ConfigureAwait(false); + if (bets.Count == 0) + return PaperTradingVm.Empty; + + // Batched event-title join — distinct ids only. Missing events (pruned by + // snapshot retention) fall back to the raw id. + var ids = bets.Select(b => b.EventId).Distinct().ToList(); + var events = await _events.GetManyAsync(ids, ct).ConfigureAwait(false); + + var rows = bets + .Select(b => new PaperBetRowVm( + Id: b.Id, + AnomalyId: b.AnomalyId, + EventTitle: events.TryGetValue(b.EventId, out var ev) ? ev.Title : b.EventId.Value, + PickedSide: b.PickedSide, + Rate: b.Rate, + Stake: b.Stake, + OpenedAt: b.OpenedAt, + Outcome: b.Outcome, + Payout: b.Payout, + SettledAt: b.SettledAt)) + .ToList(); + + var open = bets.Count(b => b.Outcome == BetOutcome.Pending); + var wins = bets.Count(b => b.Outcome == BetOutcome.Won); + var losses = bets.Count(b => b.Outcome == BetOutcome.Lost); + var settled = wins + losses; + + // Settled-only P&L — open bets have no realised return. + var settledBets = bets.Where(b => b.Outcome is BetOutcome.Won or BetOutcome.Lost).ToList(); + var staked = settledBets.Sum(b => b.Stake); + var returned = settledBets.Sum(b => b.Payout ?? 0m); + var net = returned - staked; + + decimal? roi = staked > 0m + ? Math.Round((net / staked) * 100m, 2, MidpointRounding.AwayFromZero) + : null; + decimal? hitRate = settled > 0 + ? Math.Round((decimal)wins / settled * 100m, 1, MidpointRounding.AwayFromZero) + : null; + + return new PaperTradingVm( + OpenCount: open, + SettledCount: settled, + Wins: wins, + Losses: losses, + TotalStaked: Math.Round(staked, 2, MidpointRounding.AwayFromZero), + TotalReturned: Math.Round(returned, 2, MidpointRounding.AwayFromZero), + NetProfit: Math.Round(net, 2, MidpointRounding.AwayFromZero), + RoiPercent: roi, + HitRatePercent: hitRate, + Bets: rows); + } +} diff --git a/src/Marathon.UI/Services/PaperTradingViewModels.cs b/src/Marathon.UI/Services/PaperTradingViewModels.cs new file mode 100644 index 0000000..349a970 --- /dev/null +++ b/src/Marathon.UI/Services/PaperTradingViewModels.cs @@ -0,0 +1,36 @@ +using Marathon.Domain.Enums; + +namespace Marathon.UI.Services; + +/// +/// Aggregated forward-test ledger for the paper-trading page. P&L figures cover +/// SETTLED bets only — open bets have no realised result yet. +/// +public sealed record PaperTradingVm( + int OpenCount, + int SettledCount, + int Wins, + int Losses, + decimal TotalStaked, + decimal TotalReturned, + decimal NetProfit, + decimal? RoiPercent, + decimal? HitRatePercent, + IReadOnlyList Bets) +{ + public static PaperTradingVm Empty { get; } = + new(0, 0, 0, 0, 0m, 0m, 0m, null, null, Array.Empty()); +} + +/// One paper bet with its event title joined for display. +public sealed record PaperBetRowVm( + Guid Id, + Guid AnomalyId, + string EventTitle, + Side PickedSide, + decimal Rate, + decimal Stake, + DateTimeOffset OpenedAt, + BetOutcome Outcome, + decimal? Payout, + DateTimeOffset? SettledAt); diff --git a/src/Marathon.UI/Services/UiServicesExtensions.cs b/src/Marathon.UI/Services/UiServicesExtensions.cs index 7293a6b..85d1828 100644 --- a/src/Marathon.UI/Services/UiServicesExtensions.cs +++ b/src/Marathon.UI/Services/UiServicesExtensions.cs @@ -63,6 +63,7 @@ public static class UiServicesExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Settings writer — file path is host-resolved. services.AddSingleton(_ => new JsonSettingsWriter(settingsLocalPath)); diff --git a/tests/Marathon.UI.Tests/Services/PaperTradingServiceTests.cs b/tests/Marathon.UI.Tests/Services/PaperTradingServiceTests.cs new file mode 100644 index 0000000..d8feaba --- /dev/null +++ b/tests/Marathon.UI.Tests/Services/PaperTradingServiceTests.cs @@ -0,0 +1,74 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Marathon.UI.Services; +using NSubstitute; + +namespace Marathon.UI.Tests.Services; + +public sealed class PaperTradingServiceTests +{ + private static readonly TimeSpan Msk = TimeSpan.FromHours(3); + private static readonly DateTimeOffset T0 = new(2026, 5, 20, 18, 0, 0, Msk); + + private readonly IPaperBetRepository _paperBets = Substitute.For(); + private readonly IEventRepository _events = Substitute.For(); + + private PaperTradingService CreateSut() => new(_paperBets, _events); + + private static PaperBet Bet(string eventCode, decimal rate, BetOutcome outcome) + { + var open = PaperBet.Open(Guid.NewGuid(), new EventId(eventCode), Side.Side1, rate, 10m, T0); + return outcome switch + { + BetOutcome.Won => open.SettleAgainst(Side.Side1, T0.AddHours(2)), + BetOutcome.Lost => open.SettleAgainst(Side.Side2, T0.AddHours(2)), + _ => open, + }; + } + + private static Event Ev(string code, string s1, string s2) => + new(new EventId(code), new SportCode(11), "England", "league", string.Empty, T0.AddDays(1), s1, s2); + + [Fact] + public async Task GetAsync_Empty_When_NoBets() + { + _paperBets.ListAsync(Arg.Any()).Returns(Array.Empty()); + + (await CreateSut().GetAsync(CancellationToken.None)).Should().Be(PaperTradingVm.Empty); + } + + [Fact] + public async Task GetAsync_Aggregates_SettledOnlyPnL_AndJoinsTitles() + { + var bets = new[] + { + Bet("A", 1.90m, BetOutcome.Won), // payout 19 + Bet("A", 2.00m, BetOutcome.Lost), // payout 0 + Bet("B", 1.50m, BetOutcome.Pending), // excluded from P&L + }; + _paperBets.ListAsync(Arg.Any()).Returns(bets); + _events + .GetManyAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary { [new EventId("A")] = Ev("A", "Home", "Away") }); + + var vm = await CreateSut().GetAsync(CancellationToken.None); + + vm.OpenCount.Should().Be(1); + vm.SettledCount.Should().Be(2); + vm.Wins.Should().Be(1); + vm.Losses.Should().Be(1); + vm.TotalStaked.Should().Be(20m); + vm.TotalReturned.Should().Be(19m); + vm.NetProfit.Should().Be(-1m); + vm.RoiPercent.Should().Be(-5m); // -1 / 20 * 100 + vm.HitRatePercent.Should().Be(50m); // 1 / 2 * 100 + vm.Bets.Should().HaveCount(3); + + vm.Bets.Single(b => b.Rate == 1.90m).EventTitle.Should().Be("Home vs Away"); + // Event B isn't in the lookup (e.g. pruned) — falls back to the raw id. + vm.Bets.Single(b => b.Rate == 1.50m).EventTitle.Should().Be("B"); + } +}