Files
PolyTraderSharp/tests/PolyTrader.Tests/AccountingTests.cs
T
RichardandClaude Opus 5 72ad38518b
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
EZB-Tageskurse selbst abrufen, Kopfleiste umbrechen lassen
Behebt #532 und #533.

#532 — Kopfleiste bricht um statt zu scrollen

Mit elf Ansichten brauchte die Leiste 1623 px. Darunter scrollte sie seitlich
weg, ohne dass ein Hinweis darauf sichtbar war: Wer einen rechten Tab anklickte,
verlor "Dashboard" aus dem Blick und fand den Weg zurueck nicht mehr. Statt
overflow-x nun flex-wrap auf Leiste und Kopfzeile. Geprueft von 1920 bis 768 px
— nichts wird mehr abgeschnitten, der Koerper scrollt nie waagerecht.

#533 — Kurse holen, statt EUR still auf null zu setzen

Die Tabelle acc_fx_rates gab es seit A-2, befuellt hat sie nie jemand. Deshalb
war EUR nicht verfuegbar, und der PDF-Export machte daraus wortlos einen Beleg
mit lauter Nullbetraegen.

Neu holt EcbFxRateSource die amtlichen Referenzkurse ueber die EZB-Daten-API
(Reihe EXR/D.USD.EUR.SP00.A, CSV). FxRateSyncService stoesst das beim ersten
Ingest-Lauf des Tages an: erstmalig 400 Tage rueckwirkend, danach nur noch der
Rand. Die Kurse werden gespeichert, nicht bei Bedarf geholt — eine Abrechnung
fuer einen vergangenen Monat muss den Kurs von damals nehmen und beim naechsten
Aufruf dasselbe Ergebnis liefern.

Zwei Fallen, die dabei zu umgehen waren:

- Die Reihe liefert USD je EUR (1 EUR = 1,1652 USD), FxRate.UsdToEur meint die
  Gegenrichtung. Wer das verwechselt, bekommt eine Abrechnung, die um rund 35 %
  danebenliegt und trotzdem plausibel aussieht. Gespeichert wird der Kehrwert.
- Die CSV-Antwort enthaelt in TITLE_COMPL ein Komma innerhalb von
  Anfuehrungszeichen; naives Splitten verschoebe die Spalten.

Die Warnung erscheint, sobald der verwendete Kurs mehr als drei Tage vor dem
Stichtag liegt. Drei Tage, weil genau so gross die planmaessige Wochenendluecke
ist: Der Freitagskurs gilt bis Montag und ist dabei kein Mangel.

Der Beleg bleibt trotzdem erzeugbar — die Warnung steht sichtbar darauf und im
WebUI ueber der Abrechnung. Nur ohne jeden Kurs lehnt der Export jetzt mit 400
ab, statt Nullbetraege auszugeben. PDF und CSV laufen deshalb nicht mehr ueber
window.open: Eine nackte JSON-Fehlermeldung in einem neuen Reiter ist keine
Antwort. downloadExport() holt die Datei, meldet Fehler im Klartext und stoesst
sonst den Download an.

Nebenbei im Testlauf gefunden und behoben: Der manuelle Anstoss aus dem WebUI
und die stuendliche Schleife trafen sich in derselben Sekunde — im Log standen
"280 Kurse uebernommen" und "53 Kurse uebernommen" untereinander, beide hatten
die EZB gefragt. SyncDailyAsync serialisiert jetzt und prueft den Tag ein
zweites Mal innerhalb der Sperre.

Geprueft auf der Linux-Test-VM gegen die echte EZB-API: 280 Kurse vom
06.08.2025 bis 09.09.2026 uebernommen, Umrechnung stimmt (1/1,1652 = 0,858222),
eine Maerz-Abrechnung nimmt den Kurs vom 31.03. und nicht den heutigen, das
Warnbanner erscheint ab dem vierten Tag, der Export verweigert ohne Kurs und
liefert sonst ein gueltiges PDF. 551 von 551 Tests bestanden (18 neue).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:25:47 +02:00

193 lines
9.1 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 System.Threading;
using System.Threading.Tasks;
using PolyTrader.Modules.Accounting.Logic;
using PolyTrader.Modules.Accounting.Models;
using PolyTrader.Modules.Accounting.Persistence;
using PolyTrader.Modules.Accounting.Services;
using PolyTrader.Tests.TestSupport;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Sicherheitsnetz für das Accounting-Fundament (A-1): pure Klassifikation (Typ/Vorzeichen/
/// Idempotenz), Deposit/Withdrawal-Trennung intern↔extern, und die Ingest-Orchestrierung
/// (idempotenter Upsert, Balance-Anker, Backfill vs. inkrementell).
/// </summary>
public class AccountingTests
{
// ---------------- Pure Klassifikation ----------------
private static RawActivity Act(string type, string side, decimal usdc, decimal fee = 0m,
string tx = "0xabc", int logIndex = 0, string token = "tok") => new()
{
Type = type, Side = side, Timestamp = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc),
TxHash = tx, LogIndex = logIndex, TokenId = token, UsdcAmount = usdc, Fee = fee, Size = 10m, Price = 0.5m
};
[Fact]
public void Buy_is_cash_out_including_fee()
{
var e = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "BUY", 100m, 1.5m), 7);
Assert.Equal(LedgerEventType.TradeBuy, e.EventType);
Assert.Equal(-101.5m, e.NetUsdc); // −(brutto + fee)
Assert.Equal(100m, e.GrossUsdc);
Assert.Equal(1.5m, e.FeeUsdc);
Assert.Equal(7, e.IngestBatchId);
}
[Fact]
public void Sell_is_cash_in_minus_fee()
{
var e = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "SELL", 100m, 1.5m), 7);
Assert.Equal(LedgerEventType.TradeSell, e.EventType);
Assert.Equal(98.5m, e.NetUsdc); // brutto − fee
}
[Theory]
[InlineData("REDEEM", 50.0, 50.0)]
[InlineData("REWARD", 3.0, 3.0)]
[InlineData("SPLIT", 20.0, 0.0)] // geldneutral
[InlineData("MERGE", 20.0, 0.0)]
[InlineData("CONVERSION", 20.0, 0.0)]
public void Other_types_have_expected_net(string type, double usdc, double expectedNet)
{
var e = AccountingClassifier.ClassifyActivity(1, Act(type, "", (decimal)usdc), 7);
Assert.Equal((decimal)expectedNet, e.NetUsdc);
}
[Fact]
public void Idempotency_key_is_stable_and_type_specific()
{
var buy = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "BUY", 100m, tx: "0xAbC", logIndex: 2), 7);
var buyAgain = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "BUY", 100m, tx: "0xabc", logIndex: 2), 99);
Assert.Equal(buy.IdempotencyKey, buyAgain.IdempotencyKey); // gleicher Key trotz anderem Batch/Casing
var sell = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "SELL", 100m, tx: "0xabc", logIndex: 2), 7);
Assert.NotEqual(buy.IdempotencyKey, sell.IdempotencyKey); // Typ unterscheidet
}
[Fact]
public void Transfer_external_is_deposit_or_withdrawal_internal_is_skipped()
{
var contracts = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "0xctf" };
var deposit = AccountingClassifier.ClassifyTransfer(1, new RawTransfer
{
IsIncoming = true, FromAddress = "0xExternalWhale", ToAddress = "0xSafe",
UsdcAmount = 500m, TxHash = "0xd", LogIndex = 1
}, 7, contracts);
Assert.NotNull(deposit);
Assert.Equal(LedgerEventType.Deposit, deposit!.EventType);
Assert.Equal(500m, deposit.NetUsdc);
var withdrawal = AccountingClassifier.ClassifyTransfer(1, new RawTransfer
{
IsIncoming = false, FromAddress = "0xSafe", ToAddress = "0xExternalBank",
UsdcAmount = 200m, TxHash = "0xw", LogIndex = 0
}, 7, contracts);
Assert.Equal(LedgerEventType.Withdrawal, withdrawal!.EventType);
Assert.Equal(-200m, withdrawal.NetUsdc);
// interne Bewegung (Gegenpart = System-Contract) → NICHT als Ein-/Auszahlung buchen
var internalMove = AccountingClassifier.ClassifyTransfer(1, new RawTransfer
{
IsIncoming = false, FromAddress = "0xSafe", ToAddress = "0xCTF", UsdcAmount = 100m, TxHash = "0xi"
}, 7, contracts);
Assert.Null(internalMove);
}
// ---------------- Ingest-Orchestrierung ----------------
private sealed class FakeActivitySource : IActivitySource
{
public List<RawActivity> Items { get; } = new();
public int Calls { get; private set; }
public Task<IReadOnlyList<RawActivity>> GetActivityAsync(string wallet, DateTime? since, CancellationToken ct)
{ Calls++; return Task.FromResult((IReadOnlyList<RawActivity>)Items.ToList()); }
}
private sealed class FakeBalance : IBalanceAnchorSource
{
public decimal? Value;
public Task<decimal?> GetBalanceAsync(string wallet, CancellationToken ct) => Task.FromResult(Value);
}
private static (AccountingIngestService svc, EfLedgerRepository ledger, EfIngestRunRepository runs, FakeActivitySource act, FakeBalance bal)
BuildIngest()
{
var factory = new InMemoryContextFactory<AccountingDbContext>(o => new AccountingDbContext(o));
var ledger = new EfLedgerRepository(factory);
var runs = new EfIngestRunRepository(factory);
var raw = new EfRawSnapshotRepository(factory);
var act = new FakeActivitySource();
var bal = new FakeBalance();
var state = new TradingState();
// Kursabgleich mit der Null-Quelle: Der Ingest-Test prueft das Buchen, nicht die EZB (#533).
var logger = new TerminalLogger();
var fxSync = new FxRateSyncService(new NullFxRateSource(), new EfFxRateRepository(factory), logger);
var svc = new AccountingIngestService(state, ledger, runs, raw, act, new NullTransferSource(), bal,
new AccountingSystemContracts(), logger, fxSync);
return (svc, ledger, runs, act, bal);
}
private static AccountState LiveAccount(int id) =>
new() { AccountId = id, Name = "Live", IsDemo = false, WalletAddress = "0xSafe" };
[Fact]
public async Task Ingest_books_entries_and_is_idempotent_on_reingest()
{
var (svc, ledger, _, act, bal) = BuildIngest();
act.Items.Add(Act("TRADE", "BUY", 100m, 1m, tx: "0x1", logIndex: 0));
act.Items.Add(Act("TRADE", "SELL", 120m, 1m, tx: "0x2", logIndex: 0));
bal.Value = 18m; // Anker
var run1 = await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None);
Assert.True(run1.Success);
Assert.Equal(2, run1.NewEntries);
Assert.Equal(0, run1.DuplicateEntries);
Assert.Equal(2, ledger.Count(1));
// Zweiter Lauf mit denselben Ereignissen → alles Duplikate, keine Doppelbuchung.
var run2 = await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None);
Assert.Equal(0, run2.NewEntries);
Assert.Equal(2, run2.DuplicateEntries);
Assert.Equal(2, ledger.Count(1));
}
[Fact]
public async Task Ingest_records_balance_anchor_delta()
{
var (svc, ledger, _, act, bal) = BuildIngest();
act.Items.Add(Act("TRADE", "BUY", 100m, 0m, tx: "0x1")); // Netto −100
act.Items.Add(Act("REDEEM", "", 150m, tx: "0x2")); // Netto +150 → Σ = 50
bal.Value = 50m; // Anker == Ledger-Netto → Δ 0
var run = await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None);
Assert.Equal(50m, run.LedgerNetUsdc);
Assert.Equal(50m, run.BalanceAnchorUsdc);
Assert.Equal(0m, run.BalanceDeltaUsdc); // Vollständigkeit: Soll-Ist ≈ 0
}
[Fact]
public async Task Incremental_run_queries_from_last_timestamp_with_lookback()
{
var (svc, _, _, act, _) = BuildIngest();
act.Items.Add(Act("TRADE", "BUY", 100m, tx: "0x1"));
await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None);
var run = await svc.IngestAccountAsync(LiveAccount(1), backfill: false, CancellationToken.None);
// Fenster beginnt beim jüngsten Ledger-Zeitpunkt minus Lookback-Überlappung.
Assert.NotNull(run.FromTimestamp);
var expected = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc)
.AddHours(-AccountingIngestService.IncrementalLookbackHours);
Assert.Equal(expected, run.FromTimestamp);
}
}
}