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:
@@ -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