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,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");
}
}