Accounting A-1: Ingest-Fundament (unabhaengiger Ledger, idempotent, read-only)

Neues Modul PolyTrader.Modules.Accounting (IPolyTraderModule, acc_-Praefix, nur Core-Referenz,
KEIN Handel). Konzept: docs/konzepte/KONZEPT-Modul-Accounting.md, Phase A-1.

Buchungsgrundlage ausschliesslich aus unabhaengigen Polymarket-/On-Chain-Abrufen (nie unsere
Trading-DB), append-only, prueffaehig:
- Modelle: LedgerEntry (+ LedgerEventType), IngestRun (mit Balance-Anker), RawSnapshot,
  RawActivity/RawTransfer (normalisierte Eingaben, entkoppeln pure Logik von der API-Feldbenennung).
- AccountingClassifier (Logic/, pur+getestet): Activity->Buchungssatz (Typ/Vorzeichen: BUY=Cash raus
  inkl. Fee, SELL=Cash rein minus Fee, Redeem/Reward +, Split/Merge/Conversion geldneutral),
  stabiler Idempotency-Key; Transfer-Klassifikation trennt intern (System-Contract-Whitelist) von
  externen Deposits/Withdrawals. SumNet fuer den Balance-Anker-Abgleich.
- AccountingDbContext (acc_ledger append-only + Unique-Index Idempotency, acc_ingest_runs, acc_raw;
  Autoincrement-PKs). Migration InitialAccounting generiert UND angewendet. Repos mit idempotentem
  Upsert (true=neu/false=Duplikat).
- AccountingIngestService (BackgroundService): testbarer IngestAccountAsync - Activity + On-Chain-
  Transfers klassifizieren + idempotent buchen, Rohschnappschuss ablegen, Lauf inkl. Balance-Anker-
  Delta protokollieren; Backfill vs. inkrementell (Lookback-Ueberlappung gegen API-Lag).
- Quellen hinter Interfaces (IActivitySource/ITransferSource/IBalanceAnchorSource) mit Null-Stubs:
  Modul laeuft offline und bucht korrekt nichts. Live-Abruf + System-Contract-Whitelist = Zielland.
- UI designerfaehig (partial + .Designer.cs): Tabs Ledger (filterbar) + Abruf/Status (Ingest-Laeufe,
  Balance-Anker, manueller Backfill/Inkrement).
- Program.cs (beide Modul-Listen) + sln + App/Tests-Referenzen.

A-2 (Abrechnung/BWA/FX), A-3 (US-Steuerschicht FIFO/Form-8949), A-4 (CSV/PDF via PDFsharp/MigraDoc)
folgen. Tests: +12 (Klassifikation, intern/extern-Transfer, Ingest-Idempotenz, Balance-Anker,
Inkrement-Fenster). Build 0 Fehler, 379 Tests gruen, --smoke-ui alle 6 Views gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-20 08:54:12 +02:00
co-authored by Claude Opus 4.8
parent c7668170a4
commit a3c145c0ed
22 changed files with 1995 additions and 2 deletions
+189
View File
@@ -0,0 +1,189 @@
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();
var svc = new AccountingIngestService(state, ledger, runs, raw, act, new NullTransferSource(), bal,
new AccountingSystemContracts(), new TerminalLogger());
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);
}
}
}