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:
co-authored by
Claude Opus 4.8
parent
c7668170a4
commit
a3c145c0ed
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
<ProjectReference Include="..\..\src\PolyTrader.Modules.CopyTrading\PolyTrader.Modules.CopyTrading.csproj" />
|
||||
<ProjectReference Include="..\..\src\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj" />
|
||||
<ProjectReference Include="..\..\src\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj" />
|
||||
<ProjectReference Include="..\..\src\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user