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:
2026-05-29 02:33:42 +03:00
parent f622dadf95
commit 39aef449f7
10 changed files with 582 additions and 4 deletions
@@ -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",
};
}