Files
PolyTraderSharp/tests/PolyTrader.Tests/AccountingReportTests.cs
T
RichardandClaude Opus 4.8 42a599a3a3 Accounting A-2: neutrale Periodenabrechnung + BWA-Uebersicht + FX + CSV-Export
Alles laenderneutral, ohne steuerliche Einordnung (A-3 US-Steuerschicht bleibt bewusst offen,
haengt an den CPA-Fragebogen-Antworten). Konzept: docs/konzepte/KONZEPT-Modul-Accounting.md, A-2.

Pure Logik (unit-getestet):
- AccountingEngine.BuildStatement: aggregiert Ledger-Saetze eines Zeitraums (x Account/alle) zu
  Anfangs-/Endsaldo, Ein-/Auszahlungen, Handelsvolumen, Redeems, Rewards, Fees und
  Netto-Handelsergebnis (Cash-Basis, EXKL. Ein-/Auszahlungen); Invariante Endsaldo-Anfang =
  Ergebnis + Einz. - Ausz. BuildMonthlyBreakdown: Monatsvergleich mit verketteten Anfangssalden.
- FxConverter: USDC->USD (dokumentierte 1:1-Annahme) + USD->EUR ueber amtliche Tageskurse
  (acc_fx_rates, Nearest-on-or-before fuer Wochenend-/Feiertage).
- CsvExporter: Ledger + Statement als RFC-4180-CSV, kulturinvariant (Punkt-Dezimal, ISO-Datum).

Infrastruktur:
- acc_fx_rates (FxRate, PK Datum) + EfFxRateRepository (Upsert je Datum). Migration AddFxRates angewendet.
- ILedgerRepository.GetUpTo (alle Saetze <= to fuer die Abrechnung inkl. Anfangssaldo).
- AccountingReportService: baut Abrechnungen + Waehrungs-View (USDC/USD immer, EUR wenn Kurse geladen).

UI (designerfaehig, partial + .Designer.cs): neuer erster Tab 'Uebersicht / BWA' mit KPI-Kacheln
(Netto-Handelsergebnis gruen/rot, Endsaldo, Ein-/Auszahlungen, Fees, Rewards, Volumen, #Trades),
Monatsvergleich-Grid, Zeitraum-Picker (Standard laufender Monat), Konto- und Waehrungswahl, CSV-Export
via SaveFileDialog.

Tests: +6 (Periodenaggregation+Invariante, Monatsverkettung, FX-Umrechnung/Nearest-Kurs, CSV-Quoting).
Build 0 Fehler, 385 Tests gruen, --smoke-ui alle 6 Views gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:06:13 +02:00

132 lines
5.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using PolyTrader.Modules.Accounting.Logic;
using PolyTrader.Modules.Accounting.Models;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>Sicherheitsnetz für die neutrale Abrechnung (A-2): Periodenaggregation, FX, CSV.</summary>
public class AccountingReportTests
{
private static readonly DateTime Jun = new(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Jul = new(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc);
private static LedgerEntry E(LedgerEventType type, decimal net, DateTime ts,
decimal gross = 0m, decimal fee = 0m) => new()
{
AccountId = 1, EventType = type, Timestamp = ts, NetUsdc = net,
GrossUsdc = gross == 0m ? Math.Abs(net) : gross, FeeUsdc = fee
};
// ---------------- AccountingEngine ----------------
[Fact]
public void Statement_aggregates_period_and_carries_opening_balance()
{
var entries = new List<LedgerEntry>
{
// VOR dem Zeitraum → nur Anfangssaldo
E(LedgerEventType.Deposit, 1000m, new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), gross: 1000m),
// im Zeitraum (Juli)
E(LedgerEventType.TradeBuy, -100m, Jul, gross: 100m, fee: 1m),
E(LedgerEventType.TradeSell, 130m, Jul, gross: 130m, fee: 1m),
E(LedgerEventType.Reward, 5m, Jul, gross: 5m),
E(LedgerEventType.Deposit, 200m, Jul, gross: 200m),
E(LedgerEventType.Withdrawal, -50m, Jul, gross: 50m),
};
var from = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc);
var to = new DateTime(2026, 7, 31, 23, 59, 59, DateTimeKind.Utc);
var s = AccountingEngine.BuildStatement(entries, from, to, 1);
Assert.Equal(1000m, s.OpeningBalanceUsdc);
Assert.Equal(200m, s.Deposits);
Assert.Equal(50m, s.Withdrawals);
Assert.Equal(230m, s.TradeVolume); // 100 + 130
Assert.Equal(5m, s.Rewards);
Assert.Equal(2m, s.Fees);
Assert.Equal(35m, s.NetTradingResultUsdc); // -100 +130 +5 (exkl. Ein-/Auszahlungen)
Assert.Equal(2, s.TradeCount);
// Invariante: Endsaldo Anfangssaldo = Handelsergebnis + Einzahlungen Auszahlungen
Assert.Equal(1185m, s.ClosingBalanceUsdc); // 1000 + 35 + 200 - 50
Assert.Equal(s.NetTradingResultUsdc + s.Deposits - s.Withdrawals, s.BalanceChange);
}
[Fact]
public void Monthly_breakdown_chains_opening_balances()
{
var entries = new List<LedgerEntry>
{
E(LedgerEventType.Deposit, 100m, Jun, gross: 100m), // Juni
E(LedgerEventType.TradeSell, 40m, Jul, gross: 40m), // Juli
};
var from = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
var to = new DateTime(2026, 7, 31, 23, 59, 59, DateTimeKind.Utc);
var months = AccountingEngine.BuildMonthlyBreakdown(entries, from, to, 1);
Assert.Equal(2, months.Count);
Assert.Equal(0m, months[0].OpeningBalanceUsdc); // Juni startet bei 0
Assert.Equal(100m, months[0].ClosingBalanceUsdc);
Assert.Equal(100m, months[1].OpeningBalanceUsdc); // Juli erbt Juni-Endsaldo
Assert.Equal(140m, months[1].ClosingBalanceUsdc);
}
// ---------------- FxConverter ----------------
[Fact]
public void Fx_conversions_usdc_usd_eur()
{
Assert.Equal(100m, FxConverter.UsdcToUsd(100m, 1.0m));
Assert.Equal(92m, FxConverter.UsdToEur(100m, 0.92m));
Assert.Equal(92m, FxConverter.UsdcToEur(100m, 0.92m, 1.0m));
}
[Fact]
public void Fx_nearest_on_or_before_picks_latest_valid_rate()
{
var rates = new List<FxRate>
{
new() { Date = new DateTime(2026, 7, 3), UsdToEur = 0.90m },
new() { Date = new DateTime(2026, 7, 5), UsdToEur = 0.92m }, // Freitag
};
// Sonntag 2026-07-05 gibt es keinen Kurs → letzter gültiger (Freitag).
var pick = FxConverter.NearestOnOrBefore(rates, new DateTime(2026, 7, 6));
Assert.NotNull(pick);
Assert.Equal(0.92m, pick!.UsdToEur);
Assert.Null(FxConverter.NearestOnOrBefore(rates, new DateTime(2026, 7, 1))); // vor allen Kursen
}
// ---------------- CsvExporter ----------------
[Fact]
public void Csv_ledger_has_header_and_escapes_commas()
{
var entries = new List<LedgerEntry>
{
new() { AccountId = 1, EventType = LedgerEventType.TradeBuy, Timestamp = Jul,
MarketSlug = "will-x-win, really?", Side = "BUY", Size = 10m, PriceUsdc = 0.5m,
GrossUsdc = 5m, FeeUsdc = 0.1m, NetUsdc = -5.1m, TxHash = "0xabc", Source = "polymarket-activity" }
};
string csv = CsvExporter.Ledger(entries);
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.StartsWith("Timestamp,AccountId,EventType", lines[0]);
Assert.Contains("\"will-x-win, really?\"", lines[1]); // Komma-Feld gequotet
Assert.Contains("-5.1", lines[1]); // kulturinvariant (Punkt)
}
[Fact]
public void Csv_statement_lists_key_figures()
{
var s = new PeriodStatement(1, Jul, Jul, 100m, 135m, 0m, 0m, 230m, 0m, 5m, 2m, 35m, 2, 3);
string csv = CsvExporter.Statement(s);
Assert.Contains("Endsaldo,135", csv);
Assert.Contains("Netto-Handelsergebnis (Cash),35", csv);
}
}
}