Files
RichardandClaude Opus 4.8 a3c145c0ed 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>
2026-07-20 08:54:12 +02:00

190 lines
8.8 KiB
C#
Raw Permalink 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();
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);
}
}
}