feat(export): CSV export for the bet journal + forward-test ledger

Adds CSV export alongside the Excel snapshot export: two buttons on the Export hub
write the bet journal and the paper-trading ledger to UTF-8 (BOM) .csv files in the
configured export directory and toast the path — mirroring the Excel use case's
write-and-return-path contract. CSV needs no third-party library, so it lives in the
Application layer behind a pure RFC-4180 formatter.

- Csv formatter (RFC 4180 escaping) + ExportToCsvUseCase (journal + ledger, batched
  title join, returns null when there's nothing to export); registered; Export hub
  buttons + en/ru resx.
- 11 tests: formatter escaping/Document + use-case empty-state + real-file write to a temp dir.
This commit is contained in:
2026-05-29 13:44:35 +03:00
parent 1092e2a2c5
commit 88615a95e9
8 changed files with 378 additions and 0 deletions
@@ -29,6 +29,7 @@ public static class ApplicationModule
services.AddScoped<PullLiveOddsUseCase>(); services.AddScoped<PullLiveOddsUseCase>();
services.AddScoped<PullResultsUseCase>(); services.AddScoped<PullResultsUseCase>();
services.AddScoped<ExportToExcelUseCase>(); services.AddScoped<ExportToExcelUseCase>();
services.AddScoped<ExportToCsvUseCase>();
services.AddScoped<DetectAnomaliesUseCase>(); services.AddScoped<DetectAnomaliesUseCase>();
services.AddScoped<EvaluateAnomalyOutcomesUseCase>(); services.AddScoped<EvaluateAnomalyOutcomesUseCase>();
services.AddScoped<GetPendingAnomalyNotificationsUseCase>(); services.AddScoped<GetPendingAnomalyNotificationsUseCase>();
+47
View File
@@ -0,0 +1,47 @@
using System.Text;
namespace Marathon.Application.Export;
/// <summary>
/// Minimal RFC 4180 CSV writer — escapes fields and joins rows with CRLF. Pure and
/// allocation-light; used by <see cref="UseCases.ExportToCsvUseCase"/>.
/// </summary>
public static class Csv
{
private static readonly char[] MustQuote = { ',', '"', '\r', '\n' };
/// <summary>Builds a CSV document from a header row plus data rows (CRLF endings).</summary>
public static string Document(IReadOnlyList<string> header, IEnumerable<IReadOnlyList<string>> rows)
{
ArgumentNullException.ThrowIfNull(header);
ArgumentNullException.ThrowIfNull(rows);
var sb = new StringBuilder();
AppendLine(sb, header);
foreach (var row in rows)
AppendLine(sb, row);
return sb.ToString();
}
private static void AppendLine(StringBuilder sb, IReadOnlyList<string> fields)
{
for (var i = 0; i < fields.Count; i++)
{
if (i > 0) sb.Append(',');
sb.Append(Escape(fields[i]));
}
sb.Append("\r\n");
}
/// <summary>
/// Quotes a field when it contains a comma, double-quote, CR or LF; inner quotes are
/// doubled. Null is treated as empty.
/// </summary>
public static string Escape(string? field)
{
var value = field ?? string.Empty;
if (value.IndexOfAny(MustQuote) < 0)
return value;
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
}
@@ -0,0 +1,132 @@
using System.Globalization;
using System.Text;
using Marathon.Application.Abstractions;
using Marathon.Application.Export;
using Marathon.Application.Storage;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Exports the bet journal and the paper-trading (forward-test) ledger to CSV files in
/// the configured export directory, returning each file's path (or null when there is
/// nothing to export). Mirrors <see cref="ExportToExcelUseCase"/>'s write-and-return-path
/// contract; CSV needs no third-party library so it stays in the Application layer.
/// </summary>
public sealed class ExportToCsvUseCase
{
// BOM so Excel opens UTF-8 (Cyrillic team names) correctly.
private static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
private readonly IPlacedBetRepository _bets;
private readonly IPaperBetRepository _paperBets;
private readonly IEventRepository _events;
private readonly IOptions<StorageOptions> _storage;
private readonly ILogger<ExportToCsvUseCase> _logger;
public ExportToCsvUseCase(
IPlacedBetRepository bets,
IPaperBetRepository paperBets,
IEventRepository events,
IOptions<StorageOptions> storage,
ILogger<ExportToCsvUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
_events = events ?? throw new ArgumentNullException(nameof(events));
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Writes the bet journal to CSV; returns the path, or null when empty.</summary>
public async Task<string?> ExportJournalAsync(CancellationToken ct = default)
{
var bets = await _bets.ListAsync(ct).ConfigureAwait(false);
if (bets.Count == 0)
return null;
var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false);
var header = new[]
{
"PlacedAt", "Event", "EventId", "Type", "Side", "Value", "Rate", "Stake", "Outcome", "Profit", "Notes",
};
var rows = bets
.OrderByDescending(b => b.PlacedAt)
.Select(b => (IReadOnlyList<string>)new[]
{
b.PlacedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
Title(titles, b.EventId),
b.EventId.Value,
b.Selection.Type.ToString(),
b.Selection.Side.ToString(),
b.Selection.Value?.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
b.Selection.Rate.Value.ToString(CultureInfo.InvariantCulture),
b.Stake.ToString(CultureInfo.InvariantCulture),
b.Outcome.ToString(),
b.NetProfit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
b.Notes ?? string.Empty,
});
return await WriteAsync("journal", Csv.Document(header, rows), ct).ConfigureAwait(false);
}
/// <summary>Writes the paper-trading ledger to CSV; returns the path, or null when empty.</summary>
public async Task<string?> ExportPaperLedgerAsync(CancellationToken ct = default)
{
var bets = await _paperBets.ListAsync(ct).ConfigureAwait(false);
if (bets.Count == 0)
return null;
var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false);
var header = new[]
{
"OpenedAt", "Event", "EventId", "PickedSide", "Rate", "Stake", "Outcome", "Payout", "SettledAt",
};
var rows = bets
.OrderByDescending(b => b.OpenedAt)
.Select(b => (IReadOnlyList<string>)new[]
{
b.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
Title(titles, b.EventId),
b.EventId.Value,
b.PickedSide.ToString(),
b.Rate.ToString(CultureInfo.InvariantCulture),
b.Stake.ToString(CultureInfo.InvariantCulture),
b.Outcome.ToString(),
b.Payout?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
b.SettledAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty,
});
return await WriteAsync("forward-test", Csv.Document(header, rows), ct).ConfigureAwait(false);
}
private async Task<string> WriteAsync(string label, string content, CancellationToken ct)
{
var dir = _storage.Value.ExportDirectory;
Directory.CreateDirectory(dir);
var fileName = $"Marathon_{label}_{MoscowTime.Now:yyyy-MM-dd_HHmmss}.csv";
var path = Path.Combine(dir, fileName);
await File.WriteAllTextAsync(path, content, Utf8Bom, ct).ConfigureAwait(false);
_logger.LogInformation("ExportToCsvUseCase: wrote {Label} CSV → {Path}", label, path);
return path;
}
private async Task<IReadOnlyDictionary<DomainEventId, string>> TitlesAsync(
IEnumerable<DomainEventId> ids, CancellationToken ct)
{
var distinct = ids.Distinct().ToList();
var events = await _events.GetManyAsync(distinct, ct).ConfigureAwait(false);
var titles = new Dictionary<DomainEventId, string>(events.Count);
foreach (var (id, ev) in events)
titles[id] = ev.Title;
return titles;
}
private static string Title(IReadOnlyDictionary<DomainEventId, string> titles, DomainEventId id) =>
titles.TryGetValue(id, out var t) ? t : id.Value;
}
@@ -8,6 +8,8 @@
@inject IStringLocalizer<SharedResource> L @inject IStringLocalizer<SharedResource> L
@inject IDialogService Dialog @inject IDialogService Dialog
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject Marathon.Application.UseCases.ExportToCsvUseCase CsvExport
@inject ILogger<ExportHub> Logger
<PageTitle>@L["App.Title"] · @L["Export.Title"]</PageTitle> <PageTitle>@L["App.Title"] · @L["Export.Title"]</PageTitle>
@@ -30,6 +32,20 @@
@L["Export.Hub.FilenameHint"] @L["Export.Hub.FilenameHint"]
</span> </span>
</div> </div>
<hr class="m-rule" />
<div class="m-rise m-rise-3" style="display: grid; gap: var(--m-space-3);">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">@L["Export.Csv.Section"]</span>
<div style="display: flex; gap: var(--m-space-3); flex-wrap: wrap;">
<button type="button" class="m-chip" @onclick="ExportJournalCsvAsync" disabled="@_busy" data-test="export-journal-csv">
@L["Export.Csv.Journal"]
</button>
<button type="button" class="m-chip" @onclick="ExportLedgerCsvAsync" disabled="@_busy" data-test="export-ledger-csv">
@L["Export.Csv.Ledger"]
</button>
</div>
</div>
</section> </section>
@code { @code {
@@ -55,4 +71,35 @@
Snackbar.Add(string.Format(CultureInfo.CurrentCulture, L["Export.Success"].Value, path), Severity.Success); Snackbar.Add(string.Format(CultureInfo.CurrentCulture, L["Export.Success"].Value, path), Severity.Success);
} }
} }
private bool _busy;
private Task ExportJournalCsvAsync() => RunCsvAsync(CsvExport.ExportJournalAsync);
private Task ExportLedgerCsvAsync() => RunCsvAsync(CsvExport.ExportPaperLedgerAsync);
private async Task RunCsvAsync(Func<CancellationToken, Task<string?>> export)
{
if (_busy) return;
_busy = true;
StateHasChanged();
try
{
var path = await export(CancellationToken.None);
if (path is null)
Snackbar.Add(L["Export.Csv.Empty"].Value, Severity.Info);
else
Snackbar.Add(string.Format(CultureInfo.CurrentCulture, L["Export.Success"].Value, path), Severity.Success);
}
catch (Exception ex)
{
Logger.LogError(ex, "ExportHub: CSV export failed.");
Snackbar.Add(L["Export.Csv.Error"].Value, Severity.Error);
}
finally
{
_busy = false;
StateHasChanged();
}
}
} }
@@ -290,6 +290,11 @@
<data name="Export.Hub.Lede"><value>Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first.</value></data> <data name="Export.Hub.Lede"><value>Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first.</value></data>
<data name="Export.Hub.Action"><value>Configure export</value></data> <data name="Export.Hub.Action"><value>Configure export</value></data>
<data name="Export.Hub.FilenameHint"><value>Saved as Marathon_&lt;from&gt;_to_&lt;to&gt;.xlsx in the configured export directory.</value></data> <data name="Export.Hub.FilenameHint"><value>Saved as Marathon_&lt;from&gt;_to_&lt;to&gt;.xlsx in the configured export directory.</value></data>
<data name="Export.Csv.Section"><value>Journal &amp; ledger (CSV)</value></data>
<data name="Export.Csv.Journal"><value>Export bet journal</value></data>
<data name="Export.Csv.Ledger"><value>Export forward-test ledger</value></data>
<data name="Export.Csv.Empty"><value>Nothing to export yet.</value></data>
<data name="Export.Csv.Error"><value>CSV export failed — see the log for details.</value></data>
<data name="Health.Kicker"><value>Operations</value></data> <data name="Health.Kicker"><value>Operations</value></data>
<data name="Health.Title"><value>Pipeline health</value></data> <data name="Health.Title"><value>Pipeline health</value></data>
<data name="Health.Lede"><value>Capture freshness, recent volumes, and worker status at a glance.</value></data> <data name="Health.Lede"><value>Capture freshness, recent volumes, and worker status at a glance.</value></data>
@@ -303,6 +303,11 @@
<data name="Export.Hub.Lede"><value>Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие.</value></data> <data name="Export.Hub.Lede"><value>Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие.</value></data>
<data name="Export.Hub.Action"><value>Настроить экспорт</value></data> <data name="Export.Hub.Action"><value>Настроить экспорт</value></data>
<data name="Export.Hub.FilenameHint"><value>Сохраняется как Marathon_&lt;от&gt;_to_&lt;до&gt;.xlsx в указанной папке экспорта.</value></data> <data name="Export.Hub.FilenameHint"><value>Сохраняется как Marathon_&lt;от&gt;_to_&lt;до&gt;.xlsx в указанной папке экспорта.</value></data>
<data name="Export.Csv.Section"><value>Журнал и реестр (CSV)</value></data>
<data name="Export.Csv.Journal"><value>Экспорт журнала ставок</value></data>
<data name="Export.Csv.Ledger"><value>Экспорт реестра форвард-теста</value></data>
<data name="Export.Csv.Empty"><value>Пока нечего экспортировать.</value></data>
<data name="Export.Csv.Error"><value>Экспорт CSV не удался — подробности в логе.</value></data>
<data name="Health.Kicker"><value>Операции</value></data> <data name="Health.Kicker"><value>Операции</value></data>
<data name="Health.Title"><value>Состояние конвейера</value></data> <data name="Health.Title"><value>Состояние конвейера</value></data>
<data name="Health.Lede"><value>Свежесть сбора, недавние объёмы и статус воркеров на одном экране.</value></data> <data name="Health.Lede"><value>Свежесть сбора, недавние объёмы и статус воркеров на одном экране.</value></data>
@@ -0,0 +1,41 @@
using FluentAssertions;
using Marathon.Application.Export;
namespace Marathon.Application.Tests.Export;
public sealed class CsvTests
{
[Theory]
[InlineData("plain", "plain")]
[InlineData("", "")]
[InlineData("a,b", "\"a,b\"")]
[InlineData("he said \"hi\"", "\"he said \"\"hi\"\"\"")]
[InlineData("line1\nline2", "\"line1\nline2\"")]
public void Escape_QuotesOnlyWhenNeeded(string input, string expected)
{
Csv.Escape(input).Should().Be(expected);
}
[Fact]
public void Escape_Null_IsEmpty()
{
Csv.Escape(null).Should().BeEmpty();
}
[Fact]
public void Document_JoinsHeaderAndRows_WithCrlf_AndEscapes()
{
var csv = Csv.Document(
new[] { "Name", "Note" },
new[]
{
(IReadOnlyList<string>)new[] { "Kelly", "ok" },
new[] { "Flat, fixed", "say \"hi\"" },
});
csv.Should().Be(
"Name,Note\r\n" +
"Kelly,ok\r\n" +
"\"Flat, fixed\",\"say \"\"hi\"\"\"\r\n");
}
}
@@ -0,0 +1,100 @@
using FluentAssertions;
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Application.UseCases;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
namespace Marathon.Application.Tests.UseCases;
public sealed class ExportToCsvUseCaseTests : IDisposable
{
private static readonly TimeSpan Msk = TimeSpan.FromHours(3);
private static readonly DateTimeOffset T0 = new(2026, 5, 16, 12, 0, 0, Msk);
private readonly string _tempDir =
Path.Combine(Path.GetTempPath(), "marathon_csv_" + Guid.NewGuid().ToString("N"));
private readonly IPlacedBetRepository _bets = Substitute.For<IPlacedBetRepository>();
private readonly IPaperBetRepository _paperBets = Substitute.For<IPaperBetRepository>();
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
public void Dispose()
{
if (Directory.Exists(_tempDir))
Directory.Delete(_tempDir, recursive: true);
}
private ExportToCsvUseCase CreateSut()
{
var opts = Options.Create(new StorageOptions
{
DatabasePath = "x.db",
ExportDirectory = _tempDir,
SnapshotRetentionDays = 90,
});
return new ExportToCsvUseCase(_bets, _paperBets, _events, opts, NullLogger<ExportToCsvUseCase>.Instance);
}
[Fact]
public async Task ExportJournalAsync_ReturnsNull_When_NoBets()
{
_bets.ListAsync(Arg.Any<CancellationToken>()).Returns(Array.Empty<PlacedBet>());
(await CreateSut().ExportJournalAsync()).Should().BeNull();
}
[Fact]
public async Task ExportJournalAsync_WritesFile_WithHeaderAndJoinedTitle()
{
var bet = new PlacedBet(
Guid.NewGuid(),
new EventId("evt-1"),
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.0m)),
100m, T0, BetOutcome.Pending, "note");
_bets.ListAsync(Arg.Any<CancellationToken>()).Returns(new[] { bet });
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<EventId, Event>
{
[new EventId("evt-1")] =
new Event(new EventId("evt-1"), new SportCode(11), "GB", "L", "C", T0.AddDays(1), "Home", "Away"),
});
var path = await CreateSut().ExportJournalAsync();
path.Should().NotBeNull();
File.Exists(path!).Should().BeTrue();
var content = await File.ReadAllTextAsync(path!);
content.Should().StartWith("PlacedAt,Event,EventId");
content.Should().Contain("Home vs Away");
content.Should().Contain("evt-1");
}
[Fact]
public async Task ExportPaperLedgerAsync_ReturnsNull_When_NoBets()
{
_paperBets.ListAsync(Arg.Any<CancellationToken>()).Returns(Array.Empty<PaperBet>());
(await CreateSut().ExportPaperLedgerAsync()).Should().BeNull();
}
[Fact]
public async Task ExportPaperLedgerAsync_WritesFile_WithHeader()
{
var paper = PaperBet.Open(Guid.NewGuid(), new EventId("evt-2"), Side.Side2, 1.9m, 10m, T0);
_paperBets.ListAsync(Arg.Any<CancellationToken>()).Returns(new[] { paper });
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<EventId, Event>());
var path = await CreateSut().ExportPaperLedgerAsync();
path.Should().NotBeNull();
var content = await File.ReadAllTextAsync(path!);
content.Should().StartWith("OpenedAt,Event,EventId,PickedSide");
content.Should().Contain("evt-2"); // title falls back to the raw id
}
}