39aef449f7
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.
75 lines
2.9 KiB
C#
75 lines
2.9 KiB
C#
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");
|
|
}
|
|
}
|