feat(paper-trading): forward-test results page + worker hardening
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.
This commit is contained in:
@@ -59,11 +59,22 @@ internal sealed class PaperTradingWorker : BackgroundService
|
||||
|
||||
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.
|
||||
// Advance only after a successful open pass, so an open failure replays the window.
|
||||
_since = until;
|
||||
|
||||
var settle = scope.ServiceProvider.GetRequiredService<SettlePaperBetsUseCase>();
|
||||
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<SettlePaperBetsUseCase>();
|
||||
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);
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
<MudIcon Icon="@Icons.Material.Outlined.QueryStats" Size="Size.Small" />
|
||||
<span>@L["Nav.Backtest"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="paper-trading">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Science" Size="Size.Small" />
|
||||
<span>@L["Nav.PaperTrading"]</span>
|
||||
</NavLink>
|
||||
|
||||
<div class="m-nav__group" style="margin-top: var(--m-space-5);">@L["Nav.Section.System"]</div>
|
||||
<NavLink class="m-nav__link" href="export">
|
||||
|
||||
@@ -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<SharedResource> L
|
||||
@inject IPaperTradingService Service
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.PaperTrading"]</PageTitle>
|
||||
|
||||
<section class="m-shell">
|
||||
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
|
||||
<span class="m-kicker">@L["Paper.Kicker"]</span>
|
||||
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Paper.Title"]</h1>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Paper.Lede"]</p>
|
||||
</header>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div class="m-list-empty">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||
<span class="m-mono">@L["Common.Loading"]</span>
|
||||
</div>
|
||||
}
|
||||
else if (_vm is null || (_vm.OpenCount == 0 && _vm.SettledCount == 0))
|
||||
{
|
||||
<div class="m-list-empty m-rise m-rise-2" data-test="paper-empty">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
|
||||
@L["Common.Empty"]
|
||||
</span>
|
||||
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
|
||||
@L["Paper.Empty"]
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var vm = _vm;
|
||||
<hr class="m-rule" />
|
||||
|
||||
<section class="m-paper__section m-rise m-rise-2" data-test="paper-kpis">
|
||||
<header class="m-paper__section-head">
|
||||
<span class="m-kicker">@L["Paper.Section.Summary"]</span>
|
||||
</header>
|
||||
<div class="m-paper__kpis">
|
||||
<article class="m-paper__kpi m-paper__kpi--@ProfitTone(vm)" data-test="paper-kpi-net">
|
||||
<span class="m-paper__kpi-label">@L["Paper.Stat.NetProfit"]</span>
|
||||
<span class="m-paper__kpi-value">@FormatSignedDecimal(vm.NetProfit, vm.SettledCount)</span>
|
||||
</article>
|
||||
<article class="m-paper__kpi m-paper__kpi--@RoiTone(vm.RoiPercent)" data-test="paper-kpi-roi">
|
||||
<span class="m-paper__kpi-label">@L["Paper.Stat.Roi"]</span>
|
||||
<span class="m-paper__kpi-value">@FormatSignedPercent(vm.RoiPercent)</span>
|
||||
</article>
|
||||
<article class="m-paper__kpi m-paper__kpi--neutral" data-test="paper-kpi-hit">
|
||||
<span class="m-paper__kpi-label">@L["Paper.Stat.HitRate"]</span>
|
||||
<span class="m-paper__kpi-value">@FormatPercent(vm.HitRatePercent)</span>
|
||||
</article>
|
||||
<article class="m-paper__kpi m-paper__kpi--neutral" data-test="paper-kpi-open">
|
||||
<span class="m-paper__kpi-label">@L["Paper.Stat.Open"]</span>
|
||||
<span class="m-paper__kpi-value">@vm.OpenCount</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="m-paper__counts m-mono" data-test="paper-counts">
|
||||
<span><span class="m-paper__counts-label">@L["Paper.Stat.Settled"]</span> <strong>@vm.SettledCount</strong></span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span><span class="m-paper__counts-label">@L["Paper.Stat.Wins"]</span> <strong style="color: var(--m-c-positive);">@vm.Wins</strong></span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span><span class="m-paper__counts-label">@L["Paper.Stat.Losses"]</span> <strong style="color: var(--m-c-anomaly);">@vm.Losses</strong></span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span><span class="m-paper__counts-label">@L["Paper.Stat.Staked"]</span> <strong>@vm.TotalStaked.ToString("0.00", CultureInfo.InvariantCulture)</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
<section class="m-paper__section m-rise m-rise-3" data-test="paper-table-section">
|
||||
<header class="m-paper__section-head">
|
||||
<span class="m-kicker">@L["Paper.Section.Ledger"]</span>
|
||||
<span class="m-paper__section-count m-mono">@vm.Bets.Count</span>
|
||||
</header>
|
||||
|
||||
<div class="m-paper__table-wrap">
|
||||
<table class="m-paper__table" data-test="paper-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@L["Paper.Column.OpenedAt"]</th>
|
||||
<th scope="col">@L["Paper.Column.Match"]</th>
|
||||
<th scope="col">@L["Paper.Column.Pick"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Paper.Column.Rate"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Paper.Column.Stake"]</th>
|
||||
<th scope="col">@L["Paper.Column.Status"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Paper.Column.Payout"]</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in vm.Bets)
|
||||
{
|
||||
var local = row;
|
||||
<tr class="m-paper__row m-paper__row--@OutcomeClass(local.Outcome)"
|
||||
data-test="paper-row"
|
||||
data-anomaly-id="@local.AnomalyId">
|
||||
<td class="m-mono">@local.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
|
||||
<td style="font-weight: 500;">@local.EventTitle</td>
|
||||
<td style="font-weight: 600;">@SideLabel(local.PickedSide)</td>
|
||||
<td class="m-mono" style="text-align: right;">@local.Rate.ToString("0.00", CultureInfo.InvariantCulture)</td>
|
||||
<td class="m-mono" style="text-align: right;">@local.Stake.ToString("0.00", CultureInfo.InvariantCulture)</td>
|
||||
<td>
|
||||
<span class="m-paper__status m-paper__status--@OutcomeClass(local.Outcome)">
|
||||
@OutcomeLabel(local.Outcome)
|
||||
</span>
|
||||
</td>
|
||||
<td class="m-mono" style="text-align: right;">
|
||||
@(local.Payout is { } p ? p.ToString("0.00", CultureInfo.InvariantCulture) : "—")
|
||||
</td>
|
||||
<td>
|
||||
<a href="@($"/anomalies/{local.AnomalyId}")"
|
||||
class="m-paper__open"
|
||||
data-test="paper-open"
|
||||
@onclick="@(e => OpenAnomaly(e, local.AnomalyId))"
|
||||
@onclick:preventDefault>
|
||||
@L["Insights.Action.OpenAnomaly"] <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.m-paper__section { display: grid; gap: var(--m-space-4); }
|
||||
.m-paper__section-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: var(--m-space-3);
|
||||
}
|
||||
.m-paper__section-count {
|
||||
font-size: 0.6875rem; letter-spacing: 0.16em; text-transform: uppercase; color: var(--m-c-ink-soft);
|
||||
}
|
||||
|
||||
.m-paper__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--m-space-4);
|
||||
}
|
||||
.m-paper__kpi {
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
border-left: 3px solid var(--m-c-rule);
|
||||
padding: var(--m-space-4) var(--m-space-5);
|
||||
display: flex; flex-direction: column; gap: var(--m-space-2);
|
||||
}
|
||||
.m-paper__kpi--positive { border-left-color: var(--m-c-positive); }
|
||||
.m-paper__kpi--negative { border-left-color: var(--m-c-anomaly); }
|
||||
.m-paper__kpi--neutral { border-left-color: var(--m-c-accent); }
|
||||
.m-paper__kpi-label {
|
||||
font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-paper__kpi-value {
|
||||
font-family: var(--m-font-mono); font-feature-settings: var(--m-num-feature);
|
||||
font-size: clamp(1.6rem, 3vw, 2.2rem); font-weight: 500; line-height: 1;
|
||||
letter-spacing: -0.02em; color: var(--m-c-ink);
|
||||
}
|
||||
.m-paper__kpi--positive .m-paper__kpi-value { color: var(--m-c-positive); }
|
||||
.m-paper__kpi--negative .m-paper__kpi-value { color: var(--m-c-anomaly); }
|
||||
|
||||
.m-paper__counts {
|
||||
display: flex; gap: var(--m-space-3); flex-wrap: wrap; align-items: baseline;
|
||||
padding: var(--m-space-2) 0; font-size: 0.8125rem; color: var(--m-c-ink-soft);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
}
|
||||
.m-paper__counts strong { color: var(--m-c-ink); font-weight: 600; }
|
||||
.m-paper__counts-label { text-transform: uppercase; letter-spacing: 0.12em; font-size: 0.6875rem; }
|
||||
|
||||
.m-paper__table-wrap {
|
||||
background: var(--m-c-paper); border: 1px solid var(--m-c-rule); overflow-x: auto;
|
||||
}
|
||||
.m-paper__table { width: 100%; border-collapse: collapse; font-family: var(--m-font-body); }
|
||||
.m-paper__table thead th {
|
||||
font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; text-align: left; padding: var(--m-space-3);
|
||||
border-bottom: 1px solid var(--m-c-rule); color: var(--m-c-ink-soft);
|
||||
background: var(--m-c-paper-2); white-space: nowrap;
|
||||
}
|
||||
.m-paper__table tbody td {
|
||||
padding: var(--m-space-3); border-bottom: 1px solid var(--m-c-rule);
|
||||
vertical-align: middle; font-size: 0.9375rem;
|
||||
}
|
||||
.m-paper__table tbody tr:last-child td { border-bottom: 0; }
|
||||
.m-paper__row { transition: background 120ms ease; }
|
||||
.m-paper__row:hover { background: var(--m-c-paper-2); }
|
||||
.m-paper__row--won { box-shadow: inset 2px 0 0 0 var(--m-c-positive); }
|
||||
.m-paper__row--lost { box-shadow: inset 2px 0 0 0 var(--m-c-anomaly); }
|
||||
.m-paper__row--open { box-shadow: inset 2px 0 0 0 var(--m-c-ink-soft); }
|
||||
@@media (prefers-reduced-motion: reduce) { .m-paper__row { transition: none; } }
|
||||
|
||||
.m-paper__status {
|
||||
display: inline-flex; align-items: center; padding: 2px 8px;
|
||||
font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; border: 1px solid currentColor; border-radius: var(--m-radius-xs);
|
||||
}
|
||||
.m-paper__status--won { color: var(--m-c-positive); background: rgba(21, 128, 61, 0.10); }
|
||||
.m-paper__status--lost { color: var(--m-c-anomaly); background: rgba(220, 38, 38, 0.10); }
|
||||
.m-paper__status--open { color: var(--m-c-ink-soft); }
|
||||
|
||||
.m-paper__open {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem; letter-spacing: 0.12em; text-transform: uppercase; text-decoration: none;
|
||||
color: var(--m-c-ink); border-bottom: 1px solid var(--m-c-accent); padding-bottom: 1px;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.m-paper__open:hover { color: var(--m-c-accent); border-bottom-color: var(--m-c-ink); }
|
||||
|
||||
.m-list-empty {
|
||||
display: grid; place-content: center; gap: var(--m-space-3); padding: var(--m-space-7);
|
||||
text-align: center; background: var(--m-c-paper); border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
</style>
|
||||
|
||||
@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",
|
||||
};
|
||||
}
|
||||
@@ -461,6 +461,7 @@
|
||||
<data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data>
|
||||
|
||||
<data name="Nav.Backtest"><value>Backtest</value></data>
|
||||
<data name="Nav.PaperTrading"><value>Forward-test</value></data>
|
||||
<data name="Backtest.Kicker"><value>Simulator</value></data>
|
||||
<data name="Backtest.Title"><value>Replay the detector against history</value></data>
|
||||
<data name="Backtest.Lede"><value>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.</value></data>
|
||||
@@ -519,4 +520,29 @@
|
||||
<data name="Backtest.Presets.Saved"><value>Strategy saved</value></data>
|
||||
<data name="Backtest.Presets.Deleted"><value>Strategy deleted</value></data>
|
||||
<data name="Backtest.Presets.NameRequired"><value>Enter a name to save this strategy.</value></data>
|
||||
<data name="Paper.Kicker"><value>Forward-test</value></data>
|
||||
<data name="Paper.Title"><value>Paper trading</value></data>
|
||||
<data name="Paper.Lede"><value>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.</value></data>
|
||||
<data name="Paper.Empty"><value>No paper bets yet. The forward-test worker is off by default — enable PaperTrading and bets will accrue here as new directional anomalies fire.</value></data>
|
||||
<data name="Paper.Section.Summary"><value>Settled P&L</value></data>
|
||||
<data name="Paper.Section.Ledger"><value>Ledger</value></data>
|
||||
<data name="Paper.Stat.NetProfit"><value>Net profit</value></data>
|
||||
<data name="Paper.Stat.Roi"><value>ROI</value></data>
|
||||
<data name="Paper.Stat.HitRate"><value>Hit rate</value></data>
|
||||
<data name="Paper.Stat.Open"><value>Open</value></data>
|
||||
<data name="Paper.Stat.Settled"><value>Settled</value></data>
|
||||
<data name="Paper.Stat.Wins"><value>Wins</value></data>
|
||||
<data name="Paper.Stat.Losses"><value>Losses</value></data>
|
||||
<data name="Paper.Stat.Staked"><value>Staked</value></data>
|
||||
<data name="Paper.Column.OpenedAt"><value>Opened</value></data>
|
||||
<data name="Paper.Column.Match"><value>Match</value></data>
|
||||
<data name="Paper.Column.Pick"><value>Pick</value></data>
|
||||
<data name="Paper.Column.Rate"><value>Rate</value></data>
|
||||
<data name="Paper.Column.Stake"><value>Stake</value></data>
|
||||
<data name="Paper.Column.Status"><value>Status</value></data>
|
||||
<data name="Paper.Column.Payout"><value>Payout</value></data>
|
||||
<data name="Paper.Outcome.Open"><value>Open</value></data>
|
||||
<data name="Paper.Outcome.Won"><value>Won</value></data>
|
||||
<data name="Paper.Outcome.Lost"><value>Lost</value></data>
|
||||
<data name="Paper.Outcome.Void"><value>Void</value></data>
|
||||
</root>
|
||||
|
||||
@@ -474,6 +474,7 @@
|
||||
<data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data>
|
||||
|
||||
<data name="Nav.Backtest"><value>Бэктест</value></data>
|
||||
<data name="Nav.PaperTrading"><value>Форвард-тест</value></data>
|
||||
<data name="Backtest.Kicker"><value>Симулятор</value></data>
|
||||
<data name="Backtest.Title"><value>Прогон детектора по истории</value></data>
|
||||
<data name="Backtest.Lede"><value>Запустите гипотетическую стратегию на всех зафиксированных аномалиях. Выберите порог уверенности и правило стейкинга — симулятор разыграет каждую ставку против реального исхода, нарастит банк и покажет ключевые метрики для оценки преимущества.</value></data>
|
||||
@@ -532,4 +533,29 @@
|
||||
<data name="Backtest.Presets.Saved"><value>Стратегия сохранена</value></data>
|
||||
<data name="Backtest.Presets.Deleted"><value>Стратегия удалена</value></data>
|
||||
<data name="Backtest.Presets.NameRequired"><value>Введите название, чтобы сохранить стратегию.</value></data>
|
||||
<data name="Paper.Kicker"><value>Форвард-тест</value></data>
|
||||
<data name="Paper.Title"><value>Бумажная торговля</value></data>
|
||||
<data name="Paper.Lede"><value>Проверка вне выборки: воркер открывает ставку фиксированным стейком на каждый живой направленный сигнал и рассчитывает её, когда приходит результат — противоядие от переобучения на бэктесте. Включается в разделе PaperTrading настроек.</value></data>
|
||||
<data name="Paper.Empty"><value>Бумажных ставок пока нет. Воркер форвард-теста по умолчанию выключен — включите PaperTrading, и ставки начнут накапливаться здесь по мере появления новых направленных аномалий.</value></data>
|
||||
<data name="Paper.Section.Summary"><value>Рассчитанный P&L</value></data>
|
||||
<data name="Paper.Section.Ledger"><value>Журнал</value></data>
|
||||
<data name="Paper.Stat.NetProfit"><value>Чистая прибыль</value></data>
|
||||
<data name="Paper.Stat.Roi"><value>ROI</value></data>
|
||||
<data name="Paper.Stat.HitRate"><value>Доля попаданий</value></data>
|
||||
<data name="Paper.Stat.Open"><value>Открыто</value></data>
|
||||
<data name="Paper.Stat.Settled"><value>Рассчитано</value></data>
|
||||
<data name="Paper.Stat.Wins"><value>Победы</value></data>
|
||||
<data name="Paper.Stat.Losses"><value>Поражения</value></data>
|
||||
<data name="Paper.Stat.Staked"><value>Поставлено</value></data>
|
||||
<data name="Paper.Column.OpenedAt"><value>Открыта</value></data>
|
||||
<data name="Paper.Column.Match"><value>Матч</value></data>
|
||||
<data name="Paper.Column.Pick"><value>Выбор</value></data>
|
||||
<data name="Paper.Column.Rate"><value>Кэф</value></data>
|
||||
<data name="Paper.Column.Stake"><value>Стейк</value></data>
|
||||
<data name="Paper.Column.Status"><value>Статус</value></data>
|
||||
<data name="Paper.Column.Payout"><value>Выплата</value></data>
|
||||
<data name="Paper.Outcome.Open"><value>Открыта</value></data>
|
||||
<data name="Paper.Outcome.Won"><value>Выигрыш</value></data>
|
||||
<data name="Paper.Outcome.Lost"><value>Проигрыш</value></data>
|
||||
<data name="Paper.Outcome.Void"><value>Возврат</value></data>
|
||||
</root>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IPaperTradingService
|
||||
{
|
||||
/// <summary>The full ledger plus aggregate KPIs, newest bet first.</summary>
|
||||
Task<PaperTradingVm> GetAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Domain.Enums;
|
||||
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Page-facing implementation of <see cref="IPaperTradingService"/>. 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.
|
||||
/// </summary>
|
||||
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<PaperTradingVm> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Marathon.Domain.Enums;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated forward-test ledger for the paper-trading page. P&L figures cover
|
||||
/// SETTLED bets only — open bets have no realised result yet.
|
||||
/// </summary>
|
||||
public sealed record PaperTradingVm(
|
||||
int OpenCount,
|
||||
int SettledCount,
|
||||
int Wins,
|
||||
int Losses,
|
||||
decimal TotalStaked,
|
||||
decimal TotalReturned,
|
||||
decimal NetProfit,
|
||||
decimal? RoiPercent,
|
||||
decimal? HitRatePercent,
|
||||
IReadOnlyList<PaperBetRowVm> Bets)
|
||||
{
|
||||
public static PaperTradingVm Empty { get; } =
|
||||
new(0, 0, 0, 0, 0m, 0m, 0m, null, null, Array.Empty<PaperBetRowVm>());
|
||||
}
|
||||
|
||||
/// <summary>One paper bet with its event title joined for display.</summary>
|
||||
public sealed record PaperBetRowVm(
|
||||
Guid Id,
|
||||
Guid AnomalyId,
|
||||
string EventTitle,
|
||||
Side PickedSide,
|
||||
decimal Rate,
|
||||
decimal Stake,
|
||||
DateTimeOffset OpenedAt,
|
||||
BetOutcome Outcome,
|
||||
decimal? Payout,
|
||||
DateTimeOffset? SettledAt);
|
||||
@@ -63,6 +63,7 @@ public static class UiServicesExtensions
|
||||
services.AddScoped<IBacktestService, BacktestService>();
|
||||
services.AddScoped<IDashboardSummaryService, DashboardSummaryService>();
|
||||
services.AddScoped<IPipelineHealthService, PipelineHealthService>();
|
||||
services.AddScoped<IPaperTradingService, PaperTradingService>();
|
||||
|
||||
// Settings writer — file path is host-resolved.
|
||||
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
|
||||
|
||||
@@ -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<IPaperBetRepository>();
|
||||
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||
|
||||
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<CancellationToken>()).Returns(Array.Empty<PaperBet>());
|
||||
|
||||
(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<CancellationToken>()).Returns(bets);
|
||||
_events
|
||||
.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<EventId, Event> { [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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user