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>
132 lines
5.8 KiB
C#
132 lines
5.8 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|