From a3c145c0ed3eb28f48b5c7c024fb13351ec008f6 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 20 Jul 2026 08:54:12 +0200 Subject: [PATCH] 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 --- PolyTrader.App.csproj | 1 + PolyTraderSharp.sln | 15 ++ Program.cs | 6 +- docs/konzepte/KONZEPT-Modul-Accounting.md | 9 + .../AccountingModule.cs | 69 +++++ .../Logic/AccountingClassifier.cs | 108 ++++++++ .../Models/IngestRun.cs | 32 +++ .../Models/LedgerEntry.cs | 59 +++++ .../Models/RawInputs.cs | 44 ++++ .../Models/RawSnapshot.cs | 19 ++ .../Persistence/AccountingDbContext.cs | 83 ++++++ ...260720065029_InitialAccounting.Designer.cs | 214 +++++++++++++++ .../20260720065029_InitialAccounting.cs | 143 ++++++++++ .../AccountingDbContextModelSnapshot.cs | 211 +++++++++++++++ .../Persistence/Repositories.cs | 122 +++++++++ .../PolyTrader.Modules.Accounting.csproj | 30 +++ .../Services/AccountingIngestService.cs | 163 ++++++++++++ .../Services/IngestSources.cs | 70 +++++ .../Ui/AccountingMainForm.Designer.cs | 245 ++++++++++++++++++ .../Ui/AccountingMainForm.cs | 164 ++++++++++++ tests/PolyTrader.Tests/AccountingTests.cs | 189 ++++++++++++++ .../PolyTrader.Tests/PolyTrader.Tests.csproj | 1 + 22 files changed, 1995 insertions(+), 2 deletions(-) create mode 100644 src/PolyTrader.Modules.Accounting/AccountingModule.cs create mode 100644 src/PolyTrader.Modules.Accounting/Logic/AccountingClassifier.cs create mode 100644 src/PolyTrader.Modules.Accounting/Models/IngestRun.cs create mode 100644 src/PolyTrader.Modules.Accounting/Models/LedgerEntry.cs create mode 100644 src/PolyTrader.Modules.Accounting/Models/RawInputs.cs create mode 100644 src/PolyTrader.Modules.Accounting/Models/RawSnapshot.cs create mode 100644 src/PolyTrader.Modules.Accounting/Persistence/AccountingDbContext.cs create mode 100644 src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720065029_InitialAccounting.Designer.cs create mode 100644 src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720065029_InitialAccounting.cs create mode 100644 src/PolyTrader.Modules.Accounting/Persistence/Migrations/AccountingDbContextModelSnapshot.cs create mode 100644 src/PolyTrader.Modules.Accounting/Persistence/Repositories.cs create mode 100644 src/PolyTrader.Modules.Accounting/PolyTrader.Modules.Accounting.csproj create mode 100644 src/PolyTrader.Modules.Accounting/Services/AccountingIngestService.cs create mode 100644 src/PolyTrader.Modules.Accounting/Services/IngestSources.cs create mode 100644 src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs create mode 100644 src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs create mode 100644 tests/PolyTrader.Tests/AccountingTests.cs diff --git a/PolyTrader.App.csproj b/PolyTrader.App.csproj index 4a1cd8e..f7a560e 100644 --- a/PolyTrader.App.csproj +++ b/PolyTrader.App.csproj @@ -90,6 +90,7 @@ + \ No newline at end of file diff --git a/PolyTraderSharp.sln b/PolyTraderSharp.sln index e3a963c..73be81a 100644 --- a/PolyTraderSharp.sln +++ b/PolyTraderSharp.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Modules.Resoluti EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Modules.Supervisor", "src\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj", "{63F8B9B4-6F56-4A6B-BEBE-53DE49F913D1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Modules.Accounting", "src\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj", "{A74EB53F-08E7-43A5-A664-A49E9AB50AEE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -119,6 +121,18 @@ Global {63F8B9B4-6F56-4A6B-BEBE-53DE49F913D1}.Release|x64.Build.0 = Release|Any CPU {63F8B9B4-6F56-4A6B-BEBE-53DE49F913D1}.Release|x86.ActiveCfg = Release|Any CPU {63F8B9B4-6F56-4A6B-BEBE-53DE49F913D1}.Release|x86.Build.0 = Release|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Debug|x64.ActiveCfg = Debug|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Debug|x64.Build.0 = Debug|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Debug|x86.ActiveCfg = Debug|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Debug|x86.Build.0 = Debug|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Release|Any CPU.Build.0 = Release|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Release|x64.ActiveCfg = Release|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Release|x64.Build.0 = Release|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Release|x86.ActiveCfg = Release|Any CPU + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -131,6 +145,7 @@ Global {E361C601-CC50-409F-8298-FD753DB4F6FF} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {63F8B9B4-6F56-4A6B-BEBE-53DE49F913D1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A74EB53F-08E7-43A5-A664-A49E9AB50AEE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {60AA6BCF-B17E-4D52-A290-14154A3E97CF} diff --git a/Program.cs b/Program.cs index ab611c6..c4f5ac6 100644 --- a/Program.cs +++ b/Program.cs @@ -17,6 +17,7 @@ using PolyTrader.Modules.CopyTrading; using PolyTrader.Modules.CopyTrading.Persistence; using PolyTrader.Modules.ResolutionFarming; using PolyTrader.Modules.Supervisor; +using PolyTrader.Modules.Accounting; using PolyTraderSharp.Models; using PolyTraderSharp.Services; @@ -65,7 +66,8 @@ internal static class Program { new CopyTradingModule(), new ResolutionFarmingModule(), - new SupervisorModule() + new SupervisorModule(), + new AccountingModule() }; AppHost = Host.CreateDefaultBuilder() @@ -383,7 +385,7 @@ internal static class Program { ApplicationConfiguration.Initialize(); - var modules = new System.Collections.Generic.List { new CopyTradingModule(), new ResolutionFarmingModule(), new SupervisorModule() }; + var modules = new System.Collections.Generic.List { new CopyTradingModule(), new ResolutionFarmingModule(), new SupervisorModule(), new AccountingModule() }; using var host = Host.CreateDefaultBuilder() .UseContentRoot(AppContext.BaseDirectory) diff --git a/docs/konzepte/KONZEPT-Modul-Accounting.md b/docs/konzepte/KONZEPT-Modul-Accounting.md index 97be8ea..159721f 100644 --- a/docs/konzepte/KONZEPT-Modul-Accounting.md +++ b/docs/konzepte/KONZEPT-Modul-Accounting.md @@ -202,6 +202,15 @@ Sync-/Buchungsfehler unserer Trading-Seite. Bericht in der UI + Export. (ALLE Typen) + On-Chain-USDC-Deposits/Withdrawals + Backfill/Inkrement + Idempotenz + Ingest-Status-UI. **Akzeptanz:** volle Historie eines Live-Accounts vollständig & doppelfrei; Balance-Anker Soll-Ist ≈ 0 (inkl. Ein-/Auszahlungen). + **✅ UMGESETZT (2026-07-20, Commit folgt):** `PolyTrader.Modules.Accounting` (acc_-Präfix). Pure + `AccountingClassifier` (Typ/Vorzeichen/Idempotenz-Key; intern↔extern-Transfer-Trennung via + System-Contract-Whitelist). `AccountingDbContext` (acc_ledger append-only + Unique-Idempotency, + acc_ingest_runs, acc_raw; Migration `InitialAccounting` angewendet). `AccountingIngestService` + (BackgroundService, testbarer `IngestAccountAsync`: idempotenter Upsert + Balance-Anker-Δ + + Backfill/Inkrement mit Lookback). Quellen hinter Interfaces (`IActivitySource`/`ITransferSource`/ + `IBalanceAnchorSource`) mit **Null-Stubs** — offline lauffähig; **Live-Abruf (Polymarket /activity + ALLE Typen, Alchemy-USDC-Transfers, GetUsdcBalanceAsync) + System-Contract-Whitelist ist Zielland- + Arbeit.** UI (designerfähig): Tabs Ledger + Abruf/Status mit manuellem Backfill/Inkrement. 12 Tests. - **A-2 Abrechnung + Übersicht + FX:** `AccountingEngine` + BWA-UI + Periodenabrechnung je Account/alle, Werte in USDC/USD/EUR (Tageskurse in `acc_fx_rates`). **Akzeptanz:** Monatsabrechnung stimmt gegen Balance-Anker; Kennzahlen plausibel; FX nachvollziehbar. diff --git a/src/PolyTrader.Modules.Accounting/AccountingModule.cs b/src/PolyTrader.Modules.Accounting/AccountingModule.cs new file mode 100644 index 0000000..6f9c032 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/AccountingModule.cs @@ -0,0 +1,69 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PolyTrader.Core.Configuration; +using PolyTrader.Core.Modularity; +using PolyTrader.Modules.Accounting.Persistence; +using PolyTrader.Modules.Accounting.Services; + +namespace PolyTrader.Modules.Accounting +{ + /// + /// Modul „Accounting": vollständige, von unserer Trading-DB UNABHÄNGIGE, buchhalterisch korrekte + /// Erfassung aller Transaktionen der Live-Accounts (direkte Polymarket-/On-Chain-Abrufe → append-only + /// Ledger). Reines Ingest-/Reporting-Modul, KEIN Handel. Konzept: docs/konzepte/KONZEPT-Modul-Accounting.md. + /// + /// A-1 (dieser Slice): Modul + acc_-Persistenz + pure Klassifikation + idempotenter Ingest hinter + /// Interfaces (Null-Quellen offline; Live-Abruf im Zielland) + Ledger-/Status-UI. A-2 Abrechnung/BWA/FX, + /// A-3 US-Steuerschicht, A-4 CSV/PDF-Export folgen. + /// + public class AccountingModule : IPolyTraderModule + { + public string Name => "Accounting"; + public string DbPrefix => "acc_"; + + public void RegisterServices(IServiceCollection services, IConfiguration configuration) + { + var conn = configuration["Database:MySqlConnectionString"] ?? string.Empty; + services.AddDbContextFactory(o => o.UseMySql(conn, DatabaseServerVersion.Value)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Ingest-Quellen: offline Null-Stubs (das Modul läuft ohne Live-Anbindung und bucht korrekt + // nichts). Im Zielland werden die echten Quellen (Polymarket /activity, Alchemy-Transfers, + // GetUsdcBalanceAsync) + die System-Contract-Whitelist registriert. Muster wie RF. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(new AccountingSystemContracts()); + + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + } + + public void RegisterUi(IModuleUiHost host, System.IServiceProvider services) + { + host.RegisterView(new ModuleView + { + Id = "accounting.main", + Title = "Accounting", + Group = "Accounting", + Order = 400, + CreateForm = () => + { + var form = new Ui.AccountingMainForm(); + form.Initialize(services); + return form; + } + }); + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/src/PolyTrader.Modules.Accounting/Logic/AccountingClassifier.cs b/src/PolyTrader.Modules.Accounting/Logic/AccountingClassifier.cs new file mode 100644 index 0000000..d4bee3d --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Logic/AccountingClassifier.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PolyTrader.Modules.Accounting.Models; + +namespace PolyTrader.Modules.Accounting.Logic +{ + /// + /// Reine, seiteneffektfreie Klassifikation roher Quell-Ereignisse → normalisierter Buchungssatz + /// (Typ, Vorzeichen, Idempotenz-Schlüssel). Geldkritisch → vollständig unit-getestet. Enthält keine + /// I/O; die Datenbeschaffung liegt in den Ingest-Services hinter Interfaces. + /// + /// Vorzeichenkonvention : Cash-Wirkung aufs Konto (+ Zufluss / − Abfluss). + /// Annahme (im Export dokumentiert, im Adapter justierbar): Activity-Feld UsdcAmount ist der + /// BRUTTO-Betrag, Fee separat — BUY kostet Brutto+Fee, SELL bringt Brutto−Fee. + /// + public static class AccountingClassifier + { + public static LedgerEntry ClassifyActivity(int accountId, RawActivity a, long ingestBatchId) + { + LedgerEventType type = MapActivityType(a); + decimal gross = Math.Abs(a.UsdcAmount); + decimal fee = Math.Abs(a.Fee); + decimal net = type switch + { + LedgerEventType.TradeBuy => -(gross + fee), + LedgerEventType.TradeSell => gross - fee, + LedgerEventType.Redeem => gross, + LedgerEventType.Reward => gross, + LedgerEventType.Fee => -fee, + _ => 0m // Split/Merge/Conversion/Other: geldneutral, aber roh erfasst + }; + + return new LedgerEntry + { + AccountId = accountId, + EventType = type, + Timestamp = a.Timestamp, + TokenId = a.TokenId, + MarketSlug = a.MarketSlug, + Outcome = a.Outcome, + Side = a.Side, + Size = a.Size, + PriceUsdc = a.Price, + GrossUsdc = gross, + FeeUsdc = fee, + NetUsdc = net, + TxHash = a.TxHash, + LogIndex = a.LogIndex, + Source = "polymarket-activity", + IngestBatchId = ingestBatchId, + IdempotencyKey = ActivityKey(type, a) + }; + } + + /// + /// Klassifiziert einen On-Chain-USDC-Transfer. Ist der Gegenpart ein Polymarket-System-Contract + /// (interne Trading-Bewegung, bereits in der Activity erfasst), wird null zurückgegeben. + /// Nur Safe ↔ EXTERNE Adresse = echte Ein-/Auszahlung. + /// + public static LedgerEntry? ClassifyTransfer(int accountId, RawTransfer t, long ingestBatchId, + ISet internalContracts) + { + string counterparty = (t.IsIncoming ? t.FromAddress : t.ToAddress).Trim().ToLowerInvariant(); + if (internalContracts.Contains(counterparty)) + return null; // interne Bewegung – nicht als Deposit/Withdrawal buchen + + LedgerEventType type = t.IsIncoming ? LedgerEventType.Deposit : LedgerEventType.Withdrawal; + decimal gross = Math.Abs(t.UsdcAmount); + + return new LedgerEntry + { + AccountId = accountId, + EventType = type, + Timestamp = t.Timestamp, + GrossUsdc = gross, + NetUsdc = t.IsIncoming ? gross : -gross, + TxHash = t.TxHash, + LogIndex = t.LogIndex, + Source = "onchain-transfer", + IngestBatchId = ingestBatchId, + IdempotencyKey = $"XFER|{type}|{t.TxHash.ToLowerInvariant()}|{t.LogIndex}" + }; + } + + /// Σ NetUsdc – die Buchhaltungs-Sicht des Kontosaldos (für den Balance-Anker-Abgleich). + public static decimal SumNet(IEnumerable entries) => entries.Sum(e => e.NetUsdc); + + // ----- intern ----- + + internal static LedgerEventType MapActivityType(RawActivity a) => + (a.Type ?? string.Empty).Trim().ToUpperInvariant() switch + { + "TRADE" => (a.Side ?? string.Empty).Trim().ToUpperInvariant() == "SELL" + ? LedgerEventType.TradeSell : LedgerEventType.TradeBuy, + "REDEEM" => LedgerEventType.Redeem, + "REWARD" => LedgerEventType.Reward, + "FEE" => LedgerEventType.Fee, + "SPLIT" => LedgerEventType.Split, + "MERGE" => LedgerEventType.Merge, + "CONVERSION" => LedgerEventType.Conversion, + _ => LedgerEventType.Other + }; + + internal static string ActivityKey(LedgerEventType type, RawActivity a) => + $"ACT|{type}|{a.TxHash.Trim().ToLowerInvariant()}|{a.LogIndex}|{a.TokenId}"; + } +} diff --git a/src/PolyTrader.Modules.Accounting/Models/IngestRun.cs b/src/PolyTrader.Modules.Accounting/Models/IngestRun.cs new file mode 100644 index 0000000..db3116a --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Models/IngestRun.cs @@ -0,0 +1,32 @@ +using System; + +namespace PolyTrader.Modules.Accounting.Models +{ + /// + /// Protokoll eines Abruf-/Ingest-Laufs (Tabelle acc_ingest_runs): Zeitfenster, Ergebniszahlen und + /// der Balance-Anker (Soll-Ist als Vollständigkeits-Wächter). BalanceDelta ≈ 0 ⇒ Ledger vollständig. + /// + public class IngestRun + { + public long Id { get; set; } // Autoincrement-PK (= IngestBatchId der Ledger-Sätze) + public int AccountId { get; set; } + public bool Backfill { get; set; } // true = volle Historie, false = inkrementell + + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + public DateTime? FinishedAt { get; set; } + public DateTime? FromTimestamp { get; set; } // abgefragtes Fenster (ab) + + public int NewEntries { get; set; } + public int DuplicateEntries { get; set; } + + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + + /// On-Chain-USDC-Saldo zum Abrufzeitpunkt (Balance-Anker), falls Quelle verfügbar. + public decimal? BalanceAnchorUsdc { get; set; } + /// Σ NetUsdc über alle Ledger-Sätze des Kontos (Soll aus der Buchhaltung). + public decimal? LedgerNetUsdc { get; set; } + /// Anker − LedgerNet: sollte ≈ 0 sein; Abweichung = Vollständigkeits-/Klassifikations-Signal. + public decimal? BalanceDeltaUsdc { get; set; } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Models/LedgerEntry.cs b/src/PolyTrader.Modules.Accounting/Models/LedgerEntry.cs new file mode 100644 index 0000000..a6c844a --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Models/LedgerEntry.cs @@ -0,0 +1,59 @@ +using System; + +namespace PolyTrader.Modules.Accounting.Models +{ + /// + /// Buchungssatz-Typ im neutralen Ledger. Als String persistiert (erweiterbar). Deckt die + /// Polymarket-Activity-Typen und die On-Chain-Ein-/Auszahlungen ab. + /// + public enum LedgerEventType + { + TradeBuy, // Kauf-Fill: Cash raus (Kosten + Fee) + TradeSell, // Verkauf-Fill: Cash rein (Erlös − Fee) + Redeem, // Resolution-Auszahlung (Gewinner-Shares → USDC) + Reward, // Liquidity-Reward / Maker-Rebate (Einnahme) + Fee, // eigenständige Gebühr (Ausgabe) + Split, // CTF-Split (geldneutral, bestandsrelevant) + Merge, // CTF-Merge (geldneutral) + Conversion, // NegRisk-Conversion (geldneutral) + Deposit, // externe USDC-Einzahlung ins Wallet (Cash rein) + Withdrawal, // externe USDC-Auszahlung aus dem Wallet (Cash raus) + Other // unbekannter Typ – roh erfasst, geldneutral bis geklärt + } + + /// + /// Unveränderlicher, normalisierter Buchungssatz (Tabelle acc_ledger). Buchungsgrundlage ist + /// AUSSCHLIESSLICH die unabhängige Polymarket-/On-Chain-Quelle (nie unsere Trading-DB). + /// Jeder Satz führt über TxHash/LogIndex und den auf einen prüfbaren + /// Nachweis zurück; überlappende Abrufe buchen dank des Unique-Keys nicht doppelt. + /// Vorzeichenkonvention: = Cash-Wirkung auf das Konto (+ Zufluss / − Abfluss). + /// + public class LedgerEntry + { + public long Id { get; set; } // Autoincrement-PK + public int AccountId { get; set; } + public LedgerEventType EventType { get; set; } + public DateTime Timestamp { get; set; } // Ereigniszeit (UTC) + + public string TokenId { get; set; } = string.Empty; + public string MarketSlug { get; set; } = string.Empty; + public string Outcome { get; set; } = string.Empty; + public string Side { get; set; } = string.Empty; // BUY/SELL bei Trades + + public decimal Size { get; set; } // Shares + public decimal PriceUsdc { get; set; } // USDC je Share + public decimal GrossUsdc { get; set; } // absolute Bruttobewegung + public decimal FeeUsdc { get; set; } + public decimal NetUsdc { get; set; } // signierte Cash-Wirkung (+/−) + + public string TxHash { get; set; } = string.Empty; + public int LogIndex { get; set; } + public string Source { get; set; } = string.Empty; // "polymarket-activity" / "onchain-transfer" + + public long IngestBatchId { get; set; } // = IngestRun.Id + public DateTime IngestedAt { get; set; } = DateTime.UtcNow; + + /// Stabiler Idempotenz-Schlüssel (unique). Gleiches Ereignis ⇒ gleicher Schlüssel. + public string IdempotencyKey { get; set; } = string.Empty; + } +} diff --git a/src/PolyTrader.Modules.Accounting/Models/RawInputs.cs b/src/PolyTrader.Modules.Accounting/Models/RawInputs.cs new file mode 100644 index 0000000..a57398b --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Models/RawInputs.cs @@ -0,0 +1,44 @@ +using System; + +namespace PolyTrader.Modules.Accounting.Models +{ + /// + /// Normalisierte Polymarket-Activity-Position (Eingabe des Klassifikators). Die Live-Quelle + /// (IActivitySource) mappt die tatsächliche API-JSON auf dieses Modell — so bleibt die + /// pure Buchungslogik von der (im Zielland zu verifizierenden) API-Feldbenennung entkoppelt. + /// + public sealed class RawActivity + { + public string Type { get; init; } = string.Empty; // TRADE/REDEEM/REWARD/FEE/SPLIT/MERGE/CONVERSION + public string Side { get; init; } = string.Empty; // BUY/SELL (bei TRADE) + public DateTime Timestamp { get; init; } + public string TxHash { get; init; } = string.Empty; + public int LogIndex { get; init; } + public string TokenId { get; init; } = string.Empty; + public string MarketSlug { get; init; } = string.Empty; + public string Outcome { get; init; } = string.Empty; + public decimal Size { get; init; } // Shares + public decimal Price { get; init; } // USDC je Share + public decimal UsdcAmount { get; init; } // Brutto-USDC des Ereignisses + public decimal Fee { get; init; } + public string RawJson { get; init; } = string.Empty; + } + + /// + /// Normalisierter On-Chain-USDC-Transfer des Safe-Wallets (Eingabe des Klassifikators). Die + /// Live-Quelle liefert ERC-20-Transfer-Logs; die Klassifikation trennt interne Bewegungen + /// (Safe ↔ Polymarket-System-Contracts, bereits in der Activity) von echten externen + /// Ein-/Auszahlungen. + /// + public sealed class RawTransfer + { + public DateTime Timestamp { get; init; } + public string TxHash { get; init; } = string.Empty; + public int LogIndex { get; init; } + public string FromAddress { get; init; } = string.Empty; + public string ToAddress { get; init; } = string.Empty; + public decimal UsdcAmount { get; init; } // positiv + public bool IsIncoming { get; init; } // Ziel == unser Wallet + public string RawJson { get; init; } = string.Empty; + } +} diff --git a/src/PolyTrader.Modules.Accounting/Models/RawSnapshot.cs b/src/PolyTrader.Modules.Accounting/Models/RawSnapshot.cs new file mode 100644 index 0000000..c2fff0d --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Models/RawSnapshot.cs @@ -0,0 +1,19 @@ +using System; + +namespace PolyTrader.Modules.Accounting.Models +{ + /// + /// Rohdaten-Snapshot je Abruf-Batch (Tabelle acc_raw): die unveränderte Quell-Antwort als Nachweis + /// und für die Reproduzierbarkeit — falls sich API-Felder ändern oder die Normalisierung revidiert + /// werden muss, bleibt die Urquelle erhalten. + /// + public class RawSnapshot + { + public long Id { get; set; } + public long IngestRunId { get; set; } + public int AccountId { get; set; } + public DateTime CapturedAt { get; set; } = DateTime.UtcNow; + public string SourceKind { get; set; } = string.Empty; // "activity" / "transfers" + public string Json { get; set; } = string.Empty; + } +} diff --git a/src/PolyTrader.Modules.Accounting/Persistence/AccountingDbContext.cs b/src/PolyTrader.Modules.Accounting/Persistence/AccountingDbContext.cs new file mode 100644 index 0000000..8c2d479 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Persistence/AccountingDbContext.cs @@ -0,0 +1,83 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using PolyTrader.Core.Configuration; +using PolyTrader.Modules.Accounting.Models; + +namespace PolyTrader.Modules.Accounting.Persistence +{ + /// + /// EF-Kontext des Accounting-Moduls (gleiche MySQL-DB, Tabellen mit Präfix acc_). Append-only + /// Ledger mit Autoincrement-PKs und Unique-Index auf dem Idempotenz-Schlüssel (kein Doppel-Buchen). + /// + public class AccountingDbContext : DbContext + { + public AccountingDbContext(DbContextOptions options) : base(options) { } + + public DbSet Ledger => Set(); + public DbSet IngestRuns => Set(); + public DbSet RawSnapshots => Set(); + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("acc_ledger"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedOnAdd(); + e.Property(x => x.EventType).HasConversion().HasMaxLength(20); + e.Property(x => x.TokenId).HasMaxLength(120); + e.Property(x => x.MarketSlug).HasMaxLength(300); + e.Property(x => x.Outcome).HasMaxLength(200); + e.Property(x => x.Side).HasMaxLength(10); + e.Property(x => x.Source).HasMaxLength(40); + e.Property(x => x.TxHash).HasMaxLength(80); + e.Property(x => x.IdempotencyKey).HasMaxLength(200); + e.Property(x => x.Size).HasPrecision(28, 8); + e.Property(x => x.PriceUsdc).HasPrecision(18, 6); + e.Property(x => x.GrossUsdc).HasPrecision(28, 8); + e.Property(x => x.FeeUsdc).HasPrecision(28, 8); + e.Property(x => x.NetUsdc).HasPrecision(28, 8); + e.HasIndex(x => x.IdempotencyKey).IsUnique(); // Idempotenz: kein Doppel-Buchen + e.HasIndex(x => new { x.AccountId, x.Timestamp }); + e.HasIndex(x => x.EventType); + }); + + b.Entity(e => + { + e.ToTable("acc_ingest_runs"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedOnAdd(); + e.Property(x => x.Message).HasMaxLength(1000); + e.Property(x => x.BalanceAnchorUsdc).HasPrecision(28, 8); + e.Property(x => x.LedgerNetUsdc).HasPrecision(28, 8); + e.Property(x => x.BalanceDeltaUsdc).HasPrecision(28, 8); + e.HasIndex(x => new { x.AccountId, x.StartedAt }); + }); + + b.Entity(e => + { + e.ToTable("acc_raw"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedOnAdd(); + e.Property(x => x.SourceKind).HasMaxLength(20); + e.Property(x => x.Json).HasColumnType("longtext"); + e.HasIndex(x => x.IngestRunId); + }); + } + } + + /// Design-Time-Factory (fixe Server-Version → Migrations-Scaffolding OHNE DB-Verbindung). + public class AccountingDbContextFactory : IDesignTimeDbContextFactory + { + public AccountingDbContext CreateDbContext(string[] args) + { + var conn = Environment.GetEnvironmentVariable("POLYTRADER_MYSQL") + ?? "Server=localhost;Port=3306;Database=polytrader;User ID=root;Password=;"; + var options = new DbContextOptionsBuilder() + .UseMySql(conn, DatabaseServerVersion.Value) + .Options; + return new AccountingDbContext(options); + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720065029_InitialAccounting.Designer.cs b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720065029_InitialAccounting.Designer.cs new file mode 100644 index 0000000..e201cce --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720065029_InitialAccounting.Designer.cs @@ -0,0 +1,214 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PolyTrader.Modules.Accounting.Persistence; + +#nullable disable + +namespace PolyTrader.Modules.Accounting.Persistence.Migrations +{ + [DbContext(typeof(AccountingDbContext))] + [Migration("20260720065029_InitialAccounting")] + partial class InitialAccounting + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("PolyTrader.Modules.Accounting.Models.IngestRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Backfill") + .HasColumnType("tinyint(1)"); + + b.Property("BalanceAnchorUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("BalanceDeltaUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("DuplicateEntries") + .HasColumnType("int"); + + b.Property("FinishedAt") + .HasColumnType("datetime(6)"); + + b.Property("FromTimestamp") + .HasColumnType("datetime(6)"); + + b.Property("LedgerNetUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("NewEntries") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "StartedAt"); + + b.ToTable("acc_ingest_runs", (string)null); + }); + + modelBuilder.Entity("PolyTrader.Modules.Accounting.Models.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("FeeUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("GrossUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IngestBatchId") + .HasColumnType("bigint"); + + b.Property("IngestedAt") + .HasColumnType("datetime(6)"); + + b.Property("LogIndex") + .HasColumnType("int"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("NetUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PriceUsdc") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Size") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("TxHash") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("IdempotencyKey") + .IsUnique(); + + b.HasIndex("AccountId", "Timestamp"); + + b.ToTable("acc_ledger", (string)null); + }); + + modelBuilder.Entity("PolyTrader.Modules.Accounting.Models.RawSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CapturedAt") + .HasColumnType("datetime(6)"); + + b.Property("IngestRunId") + .HasColumnType("bigint"); + + b.Property("Json") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("IngestRunId"); + + b.ToTable("acc_raw", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720065029_InitialAccounting.cs b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720065029_InitialAccounting.cs new file mode 100644 index 0000000..af82eb9 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720065029_InitialAccounting.cs @@ -0,0 +1,143 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PolyTrader.Modules.Accounting.Persistence.Migrations +{ + /// + public partial class InitialAccounting : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "acc_ingest_runs", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + AccountId = table.Column(type: "int", nullable: false), + Backfill = table.Column(type: "tinyint(1)", nullable: false), + StartedAt = table.Column(type: "datetime(6)", nullable: false), + FinishedAt = table.Column(type: "datetime(6)", nullable: true), + FromTimestamp = table.Column(type: "datetime(6)", nullable: true), + NewEntries = table.Column(type: "int", nullable: false), + DuplicateEntries = table.Column(type: "int", nullable: false), + Success = table.Column(type: "tinyint(1)", nullable: false), + Message = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + BalanceAnchorUsdc = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: true), + LedgerNetUsdc = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: true), + BalanceDeltaUsdc = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_acc_ingest_runs", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "acc_ledger", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + AccountId = table.Column(type: "int", nullable: false), + EventType = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Timestamp = table.Column(type: "datetime(6)", nullable: false), + TokenId = table.Column(type: "varchar(120)", maxLength: 120, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + MarketSlug = table.Column(type: "varchar(300)", maxLength: 300, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Outcome = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Side = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Size = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false), + PriceUsdc = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false), + GrossUsdc = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false), + FeeUsdc = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false), + NetUsdc = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false), + TxHash = table.Column(type: "varchar(80)", maxLength: 80, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + LogIndex = table.Column(type: "int", nullable: false), + Source = table.Column(type: "varchar(40)", maxLength: 40, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + IngestBatchId = table.Column(type: "bigint", nullable: false), + IngestedAt = table.Column(type: "datetime(6)", nullable: false), + IdempotencyKey = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_acc_ledger", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "acc_raw", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + IngestRunId = table.Column(type: "bigint", nullable: false), + AccountId = table.Column(type: "int", nullable: false), + CapturedAt = table.Column(type: "datetime(6)", nullable: false), + SourceKind = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Json = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_acc_raw", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_acc_ingest_runs_AccountId_StartedAt", + table: "acc_ingest_runs", + columns: new[] { "AccountId", "StartedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_acc_ledger_AccountId_Timestamp", + table: "acc_ledger", + columns: new[] { "AccountId", "Timestamp" }); + + migrationBuilder.CreateIndex( + name: "IX_acc_ledger_EventType", + table: "acc_ledger", + column: "EventType"); + + migrationBuilder.CreateIndex( + name: "IX_acc_ledger_IdempotencyKey", + table: "acc_ledger", + column: "IdempotencyKey", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_acc_raw_IngestRunId", + table: "acc_raw", + column: "IngestRunId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "acc_ingest_runs"); + + migrationBuilder.DropTable( + name: "acc_ledger"); + + migrationBuilder.DropTable( + name: "acc_raw"); + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Persistence/Migrations/AccountingDbContextModelSnapshot.cs b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/AccountingDbContextModelSnapshot.cs new file mode 100644 index 0000000..e08b9a2 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/AccountingDbContextModelSnapshot.cs @@ -0,0 +1,211 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PolyTrader.Modules.Accounting.Persistence; + +#nullable disable + +namespace PolyTrader.Modules.Accounting.Persistence.Migrations +{ + [DbContext(typeof(AccountingDbContext))] + partial class AccountingDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("PolyTrader.Modules.Accounting.Models.IngestRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Backfill") + .HasColumnType("tinyint(1)"); + + b.Property("BalanceAnchorUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("BalanceDeltaUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("DuplicateEntries") + .HasColumnType("int"); + + b.Property("FinishedAt") + .HasColumnType("datetime(6)"); + + b.Property("FromTimestamp") + .HasColumnType("datetime(6)"); + + b.Property("LedgerNetUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("NewEntries") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "StartedAt"); + + b.ToTable("acc_ingest_runs", (string)null); + }); + + modelBuilder.Entity("PolyTrader.Modules.Accounting.Models.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("FeeUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("GrossUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IngestBatchId") + .HasColumnType("bigint"); + + b.Property("IngestedAt") + .HasColumnType("datetime(6)"); + + b.Property("LogIndex") + .HasColumnType("int"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("NetUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PriceUsdc") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Size") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("TxHash") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("IdempotencyKey") + .IsUnique(); + + b.HasIndex("AccountId", "Timestamp"); + + b.ToTable("acc_ledger", (string)null); + }); + + modelBuilder.Entity("PolyTrader.Modules.Accounting.Models.RawSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CapturedAt") + .HasColumnType("datetime(6)"); + + b.Property("IngestRunId") + .HasColumnType("bigint"); + + b.Property("Json") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("IngestRunId"); + + b.ToTable("acc_raw", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Persistence/Repositories.cs b/src/PolyTrader.Modules.Accounting/Persistence/Repositories.cs new file mode 100644 index 0000000..9dd6547 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Persistence/Repositories.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using PolyTrader.Modules.Accounting.Models; + +namespace PolyTrader.Modules.Accounting.Persistence +{ + /// Append-only Ledger-Zugriff mit idempotentem Upsert (Doppel-Buchen ausgeschlossen). + public interface ILedgerRepository + { + /// Fügt den Satz ein, falls sein IdempotencyKey neu ist. true = neu gebucht, false = Duplikat. + bool Upsert(LedgerEntry entry); + DateTime? LatestTimestamp(int accountId); + decimal SumNet(int accountId); + int Count(int accountId); + List Query(int? accountId, DateTime? from, DateTime? to, int limit); + } + + public interface IIngestRunRepository + { + void Insert(IngestRun run); // setzt Id + void Update(IngestRun run); + List GetRecent(int? accountId, int limit); + } + + public interface IRawSnapshotRepository + { + void Insert(RawSnapshot snapshot); + } + + // ---------------- EF-Implementierungen ---------------- + + public class EfLedgerRepository : ILedgerRepository + { + private readonly IDbContextFactory _factory; + public EfLedgerRepository(IDbContextFactory factory) => _factory = factory; + + public bool Upsert(LedgerEntry entry) + { + using var ctx = _factory.CreateDbContext(); + bool exists = ctx.Ledger.AsNoTracking().Any(x => x.IdempotencyKey == entry.IdempotencyKey); + if (exists) return false; + ctx.Ledger.Add(entry); + ctx.SaveChanges(); + return true; + } + + public DateTime? LatestTimestamp(int accountId) + { + using var ctx = _factory.CreateDbContext(); + return ctx.Ledger.AsNoTracking() + .Where(x => x.AccountId == accountId) + .OrderByDescending(x => x.Timestamp) + .Select(x => (DateTime?)x.Timestamp) + .FirstOrDefault(); + } + + public decimal SumNet(int accountId) + { + using var ctx = _factory.CreateDbContext(); + return ctx.Ledger.AsNoTracking().Where(x => x.AccountId == accountId).Sum(x => (decimal?)x.NetUsdc) ?? 0m; + } + + public int Count(int accountId) + { + using var ctx = _factory.CreateDbContext(); + return ctx.Ledger.AsNoTracking().Count(x => x.AccountId == accountId); + } + + public List Query(int? accountId, DateTime? from, DateTime? to, int limit) + { + using var ctx = _factory.CreateDbContext(); + var q = ctx.Ledger.AsNoTracking().AsQueryable(); + if (accountId.HasValue) q = q.Where(x => x.AccountId == accountId.Value); + if (from.HasValue) q = q.Where(x => x.Timestamp >= from.Value); + if (to.HasValue) q = q.Where(x => x.Timestamp <= to.Value); + return q.OrderByDescending(x => x.Timestamp).Take(limit).ToList(); + } + } + + public class EfIngestRunRepository : IIngestRunRepository + { + private readonly IDbContextFactory _factory; + public EfIngestRunRepository(IDbContextFactory factory) => _factory = factory; + + public void Insert(IngestRun run) + { + using var ctx = _factory.CreateDbContext(); + ctx.IngestRuns.Add(run); + ctx.SaveChanges(); // füllt run.Id (Autoincrement) + } + + public void Update(IngestRun run) + { + using var ctx = _factory.CreateDbContext(); + ctx.IngestRuns.Update(run); + ctx.SaveChanges(); + } + + public List GetRecent(int? accountId, int limit) + { + using var ctx = _factory.CreateDbContext(); + var q = ctx.IngestRuns.AsNoTracking().AsQueryable(); + if (accountId.HasValue) q = q.Where(x => x.AccountId == accountId.Value); + return q.OrderByDescending(x => x.StartedAt).Take(limit).ToList(); + } + } + + public class EfRawSnapshotRepository : IRawSnapshotRepository + { + private readonly IDbContextFactory _factory; + public EfRawSnapshotRepository(IDbContextFactory factory) => _factory = factory; + + public void Insert(RawSnapshot snapshot) + { + using var ctx = _factory.CreateDbContext(); + ctx.RawSnapshots.Add(snapshot); + ctx.SaveChanges(); + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/PolyTrader.Modules.Accounting.csproj b/src/PolyTrader.Modules.Accounting/PolyTrader.Modules.Accounting.csproj new file mode 100644 index 0000000..08ba0f0 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/PolyTrader.Modules.Accounting.csproj @@ -0,0 +1,30 @@ + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + <_Parameter1>PolyTrader.Tests + + + + + net8.0-windows + enable + enable + + true + + + diff --git a/src/PolyTrader.Modules.Accounting/Services/AccountingIngestService.cs b/src/PolyTrader.Modules.Accounting/Services/AccountingIngestService.cs new file mode 100644 index 0000000..0156c98 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Services/AccountingIngestService.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using PolyTrader.Modules.Accounting.Logic; +using PolyTrader.Modules.Accounting.Models; +using PolyTrader.Modules.Accounting.Persistence; +using PolyTraderSharp; +using PolyTraderSharp.Models; +using PolyTraderSharp.Services; + +namespace PolyTrader.Modules.Accounting.Services +{ + /// + /// Ingest-Orchestrierung (A-1): erhebt je Live-Account die unabhängige Buchungsgrundlage von + /// Polymarket/On-Chain, klassifiziert sie pur () und bucht sie + /// idempotent in den append-only Ledger. Protokolliert jeden Lauf (acc_ingest_runs) inkl. + /// Balance-Anker (Soll-Ist). Rein LESEND — keine Orders, keine On-Chain-Writes. + /// + /// Der eigentliche Abruf liegt hinter Interfaces (IActivitySource/ITransferSource/IBalanceAnchorSource); + /// mit den Null-Quellen läuft das Modul offline (bucht korrekt nichts). Der testbare Kern ist + /// . + /// + public sealed class AccountingIngestService : BackgroundService + { + /// Sicherheits-Überlappung gegen API-Lag beim inkrementellen Abruf. + internal const int IncrementalLookbackHours = 6; + + private static readonly TimeSpan Interval = TimeSpan.FromHours(1); + + private readonly TradingState _state; + private readonly ILedgerRepository _ledger; + private readonly IIngestRunRepository _runs; + private readonly IRawSnapshotRepository _raw; + private readonly IActivitySource _activity; + private readonly ITransferSource _transfers; + private readonly IBalanceAnchorSource _balance; + private readonly AccountingSystemContracts _contracts; + private readonly TerminalLogger _logger; + + public AccountingIngestService( + TradingState state, ILedgerRepository ledger, IIngestRunRepository runs, IRawSnapshotRepository raw, + IActivitySource activity, ITransferSource transfers, IBalanceAnchorSource balance, + AccountingSystemContracts contracts, TerminalLogger logger) + { + _state = state; + _ledger = ledger; + _runs = runs; + _raw = raw; + _activity = activity; + _transfers = transfers; + _balance = balance; + _contracts = contracts; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(TimeSpan.FromMinutes(3), stoppingToken); // nach Hydration/Trading-Services + _logger.Info("Accounting-Ingest gestartet (unabhängiger Polymarket-/On-Chain-Abruf, read-only)."); + + while (!stoppingToken.IsCancellationRequested) + { + try { await IngestAllAsync(backfill: false, stoppingToken); } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _logger.Error($"Accounting-Ingest Fehler: {ex.Message}"); } + + try { await Task.Delay(Interval, stoppingToken); } + catch (OperationCanceledException) { break; } + } + } + + /// Ein Durchlauf über alle Live-Accounts (mit gesetzter WalletAddress). + public async Task IngestAllAsync(bool backfill, CancellationToken ct) + { + var liveAccounts = _state.Accounts.Values + .Where(a => !a.IsDemo && !string.IsNullOrWhiteSpace(a.WalletAddress)) + .OrderBy(a => a.AccountId) + .ToList(); + + foreach (var acc in liveAccounts) + { + if (ct.IsCancellationRequested) break; + var run = await IngestAccountAsync(acc, backfill, ct); + if (run.NewEntries > 0 || !run.Success) + _logger.Info($"📒 [Accounting] {acc.Name}: {run.Message}" + + (run.BalanceDeltaUsdc.HasValue ? $" (Balance-Δ {run.BalanceDeltaUsdc:F2} USDC)" : "")); + } + } + + /// + /// Testbarer Kern: erhebt + bucht einen Account, protokolliert den Lauf inkl. Balance-Anker. + /// Fehler brechen den Gesamt-Ingest nicht (im Run vermerkt). + /// + public async Task IngestAccountAsync(AccountState account, bool backfill, CancellationToken ct) + { + var run = new IngestRun { AccountId = account.AccountId, Backfill = backfill, StartedAt = DateTime.UtcNow }; + _runs.Insert(run); // Id vergeben → dient als IngestBatchId + long batchId = run.Id; + int newCount = 0, dupCount = 0; + + try + { + DateTime? since = backfill + ? null + : _ledger.LatestTimestamp(account.AccountId)?.AddHours(-IncrementalLookbackHours); + run.FromTimestamp = since; + + // 1) Polymarket-Activity (Trades/Redeems/Rewards/Splits …) + var activities = await _activity.GetActivityAsync(account.WalletAddress, since, ct); + if (activities.Count > 0) + _raw.Insert(new RawSnapshot { IngestRunId = batchId, AccountId = account.AccountId, SourceKind = "activity", Json = SnapshotJson(activities.Select(a => a.RawJson)) }); + foreach (var a in activities) + { + var entry = AccountingClassifier.ClassifyActivity(account.AccountId, a, batchId); + if (_ledger.Upsert(entry)) newCount++; else dupCount++; + } + + // 2) On-Chain-USDC-Transfers → nur EXTERNE als Deposit/Withdrawal + var transfers = await _transfers.GetTransfersAsync(account.WalletAddress, since, ct); + if (transfers.Count > 0) + _raw.Insert(new RawSnapshot { IngestRunId = batchId, AccountId = account.AccountId, SourceKind = "transfers", Json = SnapshotJson(transfers.Select(t => t.RawJson)) }); + foreach (var t in transfers) + { + var entry = AccountingClassifier.ClassifyTransfer(account.AccountId, t, batchId, _contracts.Addresses); + if (entry == null) continue; // interne Bewegung + if (_ledger.Upsert(entry)) newCount++; else dupCount++; + } + + // 3) Balance-Anker (Vollständigkeits-Wächter) + decimal? anchor = await _balance.GetBalanceAsync(account.WalletAddress, ct); + decimal ledgerNet = _ledger.SumNet(account.AccountId); + run.BalanceAnchorUsdc = anchor; + run.LedgerNetUsdc = ledgerNet; + run.BalanceDeltaUsdc = anchor.HasValue ? anchor.Value - ledgerNet : null; + + run.NewEntries = newCount; + run.DuplicateEntries = dupCount; + run.Success = true; + run.Message = $"{newCount} neu, {dupCount} Duplikate ({(backfill ? "Backfill" : "inkrementell")})."; + } + catch (Exception ex) + { + run.Success = false; + run.NewEntries = newCount; + run.DuplicateEntries = dupCount; + run.Message = $"Fehler: {ex.Message}"; + } + + run.FinishedAt = DateTime.UtcNow; + _runs.Update(run); + return run; + } + + private static string SnapshotJson(IEnumerable rawItems) + { + var items = rawItems.Where(s => !string.IsNullOrEmpty(s)).ToList(); + return items.Count == 0 ? "[]" : "[" + string.Join(",", items) + "]"; + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Services/IngestSources.cs b/src/PolyTrader.Modules.Accounting/Services/IngestSources.cs new file mode 100644 index 0000000..29c2ec4 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Services/IngestSources.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PolyTrader.Modules.Accounting.Models; + +namespace PolyTrader.Modules.Accounting.Services +{ + /// + /// Unabhängige Polymarket-Activity-Quelle (Data-API /activity, ALLE Typen). Interface, damit die + /// Buchungslogik ohne Live-API testbar/offline lauffähig ist; die Live-Implementierung (Zielland) + /// mappt die reale API-JSON auf und speichert den Rohschnappschuss. + /// + public interface IActivitySource + { + /// Activity ab (null = volle Historie/Backfill), paginiert. + Task> GetActivityAsync(string wallet, DateTime? since, CancellationToken ct); + } + + /// On-Chain-USDC-Transfers des Safe-Wallets (ERC-20-Logs). Live-Impl über Alchemy (Zielland). + public interface ITransferSource + { + Task> GetTransfersAsync(string wallet, DateTime? since, CancellationToken ct); + } + + /// On-Chain-USDC-Saldo als Balance-Anker (Soll-Ist). Live-Impl über GetUsdcBalanceAsync. + public interface IBalanceAnchorSource + { + Task GetBalanceAsync(string wallet, CancellationToken ct); + } + + /// + /// Offline-Stubs: das Modul läuft ohne Live-Anbindung vollständig (Ingest bucht dann korrekt nichts). + /// Im Zielland werden die echten Quellen registriert (Muster wie NullFarmingMarketSource). + /// + public sealed class NullActivitySource : IActivitySource + { + public Task> GetActivityAsync(string wallet, DateTime? since, CancellationToken ct) + => Task.FromResult((IReadOnlyList)Array.Empty()); + } + + public sealed class NullTransferSource : ITransferSource + { + public Task> GetTransfersAsync(string wallet, DateTime? since, CancellationToken ct) + => Task.FromResult((IReadOnlyList)Array.Empty()); + } + + public sealed class NullBalanceAnchorSource : IBalanceAnchorSource + { + public Task GetBalanceAsync(string wallet, CancellationToken ct) => Task.FromResult((decimal?)null); + } + + /// + /// Whitelist der Polymarket-System-Contract-Adressen (lowercased). Transfers zu/von diesen Adressen + /// sind interne Trading-Bewegungen (bereits in der Activity), KEINE Ein-/Auszahlungen. Wird im + /// Zielland aus Config/Docs befüllt/verifiziert (CTF-Adresse liegt bereits im Core; Exchange/ + /// NegRisk-Adapter/USDC ergänzen). Default leer → ohne Live-Transfers unkritisch. + /// + public sealed class AccountingSystemContracts + { + public HashSet Addresses { get; } = new(StringComparer.OrdinalIgnoreCase); + + public AccountingSystemContracts(IEnumerable? addresses = null) + { + if (addresses == null) return; + foreach (var a in addresses) + if (!string.IsNullOrWhiteSpace(a)) Addresses.Add(a.Trim().ToLowerInvariant()); + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs new file mode 100644 index 0000000..ec5658e --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs @@ -0,0 +1,245 @@ +namespace PolyTrader.Modules.Accounting.Ui +{ + partial class AccountingMainForm + { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Vom Komponenten-Designer generierter Code + + private void InitializeComponent() + { + this.tabControlAcc = new System.Windows.Forms.TabControl(); + this.tabLedger = new System.Windows.Forms.TabPage(); + this.dgvLedger = new System.Windows.Forms.DataGridView(); + this.toolStripLedger = new System.Windows.Forms.ToolStrip(); + this.lblLedgerKonto = new System.Windows.Forms.ToolStripLabel(); + this.cbLedgerAccount = new System.Windows.Forms.ToolStripComboBox(); + this.sepLedger = new System.Windows.Forms.ToolStripSeparator(); + this.btnLedgerRefresh = new System.Windows.Forms.ToolStripButton(); + this.tabStatus = new System.Windows.Forms.TabPage(); + this.dgvRuns = new System.Windows.Forms.DataGridView(); + this.toolStripStatus = new System.Windows.Forms.ToolStrip(); + this.lblStatusKonto = new System.Windows.Forms.ToolStripLabel(); + this.cbStatusAccount = new System.Windows.Forms.ToolStripComboBox(); + this.sepStatus = new System.Windows.Forms.ToolStripSeparator(); + this.btnIncremental = new System.Windows.Forms.ToolStripButton(); + this.btnBackfill = new System.Windows.Forms.ToolStripButton(); + this.sepStatus2 = new System.Windows.Forms.ToolStripSeparator(); + this.btnStatusRefresh = new System.Windows.Forms.ToolStripButton(); + this.lblAccStatus = new System.Windows.Forms.Label(); + this.tabControlAcc.SuspendLayout(); + this.tabLedger.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dgvLedger)).BeginInit(); + this.toolStripLedger.SuspendLayout(); + this.tabStatus.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dgvRuns)).BeginInit(); + this.toolStripStatus.SuspendLayout(); + this.SuspendLayout(); + // + // tabControlAcc + // + this.tabControlAcc.Controls.Add(this.tabLedger); + this.tabControlAcc.Controls.Add(this.tabStatus); + this.tabControlAcc.Dock = System.Windows.Forms.DockStyle.Fill; + this.tabControlAcc.Location = new System.Drawing.Point(0, 0); + this.tabControlAcc.Name = "tabControlAcc"; + this.tabControlAcc.SelectedIndex = 0; + this.tabControlAcc.Size = new System.Drawing.Size(1100, 618); + this.tabControlAcc.TabIndex = 0; + // + // tabLedger + // + this.tabLedger.Controls.Add(this.dgvLedger); + this.tabLedger.Controls.Add(this.toolStripLedger); + this.tabLedger.Location = new System.Drawing.Point(4, 24); + this.tabLedger.Name = "tabLedger"; + this.tabLedger.Padding = new System.Windows.Forms.Padding(3); + this.tabLedger.Size = new System.Drawing.Size(1092, 590); + this.tabLedger.TabIndex = 0; + this.tabLedger.Text = "Ledger"; + this.tabLedger.UseVisualStyleBackColor = true; + // + // dgvLedger + // + this.dgvLedger.AllowUserToAddRows = false; + this.dgvLedger.AllowUserToDeleteRows = false; + this.dgvLedger.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dgvLedger.Dock = System.Windows.Forms.DockStyle.Fill; + this.dgvLedger.Location = new System.Drawing.Point(3, 28); + this.dgvLedger.Name = "dgvLedger"; + this.dgvLedger.ReadOnly = true; + this.dgvLedger.RowHeadersVisible = false; + this.dgvLedger.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect; + this.dgvLedger.Size = new System.Drawing.Size(1086, 559); + this.dgvLedger.TabIndex = 1; + // + // toolStripLedger + // + this.toolStripLedger.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.lblLedgerKonto, this.cbLedgerAccount, this.sepLedger, this.btnLedgerRefresh}); + this.toolStripLedger.Location = new System.Drawing.Point(3, 3); + this.toolStripLedger.Name = "toolStripLedger"; + this.toolStripLedger.Size = new System.Drawing.Size(1086, 25); + this.toolStripLedger.TabIndex = 0; + // + // lblLedgerKonto + // + this.lblLedgerKonto.Name = "lblLedgerKonto"; + this.lblLedgerKonto.Text = "Konto:"; + // + // cbLedgerAccount + // + this.cbLedgerAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cbLedgerAccount.Name = "cbLedgerAccount"; + this.cbLedgerAccount.Size = new System.Drawing.Size(240, 25); + // + // sepLedger + // + this.sepLedger.Name = "sepLedger"; + // + // btnLedgerRefresh + // + this.btnLedgerRefresh.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnLedgerRefresh.Name = "btnLedgerRefresh"; + this.btnLedgerRefresh.Text = "Aktualisieren"; + // + // tabStatus + // + this.tabStatus.Controls.Add(this.dgvRuns); + this.tabStatus.Controls.Add(this.toolStripStatus); + this.tabStatus.Location = new System.Drawing.Point(4, 24); + this.tabStatus.Name = "tabStatus"; + this.tabStatus.Padding = new System.Windows.Forms.Padding(3); + this.tabStatus.Size = new System.Drawing.Size(1092, 590); + this.tabStatus.TabIndex = 1; + this.tabStatus.Text = "Abruf / Status"; + this.tabStatus.UseVisualStyleBackColor = true; + // + // dgvRuns + // + this.dgvRuns.AllowUserToAddRows = false; + this.dgvRuns.AllowUserToDeleteRows = false; + this.dgvRuns.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dgvRuns.Dock = System.Windows.Forms.DockStyle.Fill; + this.dgvRuns.Location = new System.Drawing.Point(3, 28); + this.dgvRuns.Name = "dgvRuns"; + this.dgvRuns.ReadOnly = true; + this.dgvRuns.RowHeadersVisible = false; + this.dgvRuns.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect; + this.dgvRuns.Size = new System.Drawing.Size(1086, 559); + this.dgvRuns.TabIndex = 1; + // + // toolStripStatus + // + this.toolStripStatus.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.lblStatusKonto, this.cbStatusAccount, this.sepStatus, this.btnIncremental, this.btnBackfill, this.sepStatus2, this.btnStatusRefresh}); + this.toolStripStatus.Location = new System.Drawing.Point(3, 3); + this.toolStripStatus.Name = "toolStripStatus"; + this.toolStripStatus.Size = new System.Drawing.Size(1086, 25); + this.toolStripStatus.TabIndex = 0; + // + // lblStatusKonto + // + this.lblStatusKonto.Name = "lblStatusKonto"; + this.lblStatusKonto.Text = "Konto:"; + // + // cbStatusAccount + // + this.cbStatusAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cbStatusAccount.Name = "cbStatusAccount"; + this.cbStatusAccount.Size = new System.Drawing.Size(240, 25); + // + // sepStatus + // + this.sepStatus.Name = "sepStatus"; + // + // btnIncremental + // + this.btnIncremental.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnIncremental.Name = "btnIncremental"; + this.btnIncremental.Text = "Inkrement jetzt"; + this.btnIncremental.ToolTipText = "Nur neue Ereignisse seit dem letzten Stand abrufen (mit Lookback-Überlappung)."; + // + // btnBackfill + // + this.btnBackfill.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnBackfill.Name = "btnBackfill"; + this.btnBackfill.Text = "Backfill (volle Historie)"; + this.btnBackfill.ToolTipText = "Komplette Historie neu abrufen (idempotent – bucht nichts doppelt)."; + // + // sepStatus2 + // + this.sepStatus2.Name = "sepStatus2"; + // + // btnStatusRefresh + // + this.btnStatusRefresh.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnStatusRefresh.Name = "btnStatusRefresh"; + this.btnStatusRefresh.Text = "Aktualisieren"; + // + // lblAccStatus + // + this.lblAccStatus.Dock = System.Windows.Forms.DockStyle.Bottom; + this.lblAccStatus.Location = new System.Drawing.Point(0, 618); + this.lblAccStatus.Name = "lblAccStatus"; + this.lblAccStatus.Padding = new System.Windows.Forms.Padding(6, 2, 6, 2); + this.lblAccStatus.Size = new System.Drawing.Size(1100, 22); + this.lblAccStatus.TabIndex = 1; + this.lblAccStatus.Text = ""; + // + // AccountingMainForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(1100, 640); + this.Controls.Add(this.tabControlAcc); + this.Controls.Add(this.lblAccStatus); + this.Name = "AccountingMainForm"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "Accounting"; + this.tabControlAcc.ResumeLayout(false); + this.tabLedger.ResumeLayout(false); + this.tabLedger.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dgvLedger)).EndInit(); + this.toolStripLedger.ResumeLayout(false); + this.toolStripLedger.PerformLayout(); + this.tabStatus.ResumeLayout(false); + this.tabStatus.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dgvRuns)).EndInit(); + this.toolStripStatus.ResumeLayout(false); + this.toolStripStatus.PerformLayout(); + this.ResumeLayout(false); + } + + #endregion + + private System.Windows.Forms.TabControl tabControlAcc; + private System.Windows.Forms.TabPage tabLedger; + private System.Windows.Forms.ToolStrip toolStripLedger; + private System.Windows.Forms.ToolStripLabel lblLedgerKonto; + private System.Windows.Forms.ToolStripComboBox cbLedgerAccount; + private System.Windows.Forms.ToolStripSeparator sepLedger; + private System.Windows.Forms.ToolStripButton btnLedgerRefresh; + private System.Windows.Forms.DataGridView dgvLedger; + private System.Windows.Forms.TabPage tabStatus; + private System.Windows.Forms.ToolStrip toolStripStatus; + private System.Windows.Forms.ToolStripLabel lblStatusKonto; + private System.Windows.Forms.ToolStripComboBox cbStatusAccount; + private System.Windows.Forms.ToolStripSeparator sepStatus; + private System.Windows.Forms.ToolStripButton btnIncremental; + private System.Windows.Forms.ToolStripButton btnBackfill; + private System.Windows.Forms.ToolStripSeparator sepStatus2; + private System.Windows.Forms.ToolStripButton btnStatusRefresh; + private System.Windows.Forms.DataGridView dgvRuns; + private System.Windows.Forms.Label lblAccStatus; + } +} diff --git a/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs new file mode 100644 index 0000000..1823995 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Windows.Forms; +using Microsoft.Extensions.DependencyInjection; +using PolyTrader.Modules.Accounting.Persistence; +using PolyTrader.Modules.Accounting.Services; +using PolyTraderSharp; + +namespace PolyTrader.Modules.Accounting.Ui +{ + /// + /// Hauptfenster des Accounting-Moduls (A-1): Tab „Ledger" (unabhängig erhobene Buchungssätze, + /// filterbar) und Tab „Abruf / Status" (Ingest-Läufe + Balance-Anker, manueller Backfill/Inkrement). + /// BWA-Übersicht, US-Steuerschicht und CSV/PDF-Export folgen A-2..A-4. Layout im Designer. + /// + public partial class AccountingMainForm : Form + { + private ILedgerRepository? _ledger; + private IIngestRunRepository? _runs; + private AccountingIngestService? _ingest; + private TradingState? _state; + + public AccountingMainForm() + { + InitializeComponent(); + + btnLedgerRefresh.Click += (_, _) => LoadLedger(); + cbLedgerAccount.SelectedIndexChanged += (_, _) => LoadLedger(); + btnStatusRefresh.Click += (_, _) => LoadRuns(); + btnIncremental.Click += async (_, _) => await RunIngestAsync(backfill: false); + btnBackfill.Click += async (_, _) => await RunIngestAsync(backfill: true); + } + + public void Initialize(IServiceProvider services) + { + _ledger = services.GetRequiredService(); + _runs = services.GetRequiredService(); + _ingest = services.GetRequiredService(); + _state = services.GetRequiredService(); + + PopulateAccounts(); + LoadLedger(); + LoadRuns(); + } + + // ---------------- Konten-Filter ---------------- + + private void PopulateAccounts() + { + var items = new List { new(null, "Alle Live-Konten") }; + if (_state != null) + items.AddRange(_state.Accounts.Values + .Where(a => !a.IsDemo && !string.IsNullOrWhiteSpace(a.WalletAddress)) + .OrderBy(a => a.AccountId) + .Select(a => new AccountItem(a.AccountId, string.IsNullOrEmpty(a.Name) ? $"#{a.AccountId}" : $"{a.Name} (#{a.AccountId})"))); + + foreach (var combo in new[] { cbLedgerAccount, cbStatusAccount }) + { + combo.ComboBox.DisplayMember = nameof(AccountItem.Label); + combo.ComboBox.DataSource = new List(items); + } + } + + private int? SelectedAccountId(ToolStripComboBox combo) => + combo.SelectedItem is AccountItem it ? it.Id : null; + + // ---------------- Ledger ---------------- + + private void LoadLedger() + { + if (_ledger == null) return; + try + { + var rows = _ledger.Query(SelectedAccountId(cbLedgerAccount), null, null, 1000); + dgvLedger.DataSource = rows.Select(e => new LedgerRow(e)).ToList(); + lblAccStatus.Text = rows.Count == 0 + ? "Noch keine Buchungen. (Ingest-Quellen sind offline bis zur Live-Anbindung im Zielland.)" + : $"{rows.Count} Buchungssätze angezeigt."; + } + catch (Exception ex) + { + lblAccStatus.Text = $"Ledger nicht lesbar (acc_-Migration angewendet?): {ex.Message}"; + } + } + + // ---------------- Abruf / Status ---------------- + + private void LoadRuns() + { + if (_runs == null) return; + try + { + dgvRuns.DataSource = _runs.GetRecent(SelectedAccountId(cbStatusAccount), 100); + } + catch (Exception ex) + { + lblAccStatus.Text = $"Ingest-Läufe nicht lesbar: {ex.Message}"; + } + } + + private async System.Threading.Tasks.Task RunIngestAsync(bool backfill) + { + if (_ingest == null) return; + btnBackfill.Enabled = false; + btnIncremental.Enabled = false; + lblAccStatus.Text = backfill ? "Backfill läuft …" : "Inkrementeller Abruf läuft …"; + try + { + await System.Threading.Tasks.Task.Run(() => _ingest.IngestAllAsync(backfill, CancellationToken.None)); + lblAccStatus.Text = "Abruf abgeschlossen."; + LoadRuns(); + LoadLedger(); + } + catch (Exception ex) + { + lblAccStatus.Text = $"Abruf-Fehler: {ex.Message}"; + } + finally + { + btnBackfill.Enabled = true; + btnIncremental.Enabled = true; + } + } + + private sealed record AccountItem(int? Id, string Label); + + /// Anzeige-Zeile fürs Ledger-Grid (kompakte, lesbare Spalten). + private sealed class LedgerRow + { + public LedgerRow(Models.LedgerEntry e) + { + Zeit = e.Timestamp; + Konto = e.AccountId; + Typ = e.EventType.ToString(); + Markt = e.MarketSlug; + Outcome = e.Outcome; + Side = e.Side; + Size = e.Size; + Preis = e.PriceUsdc; + Brutto = e.GrossUsdc; + Fee = e.FeeUsdc; + Netto = e.NetUsdc; + TxHash = e.TxHash; + Quelle = e.Source; + } + + public DateTime Zeit { get; } + public int Konto { get; } + public string Typ { get; } + public string Markt { get; } + public string Outcome { get; } + public string Side { get; } + public decimal Size { get; } + public decimal Preis { get; } + public decimal Brutto { get; } + public decimal Fee { get; } + public decimal Netto { get; } + public string TxHash { get; } + public string Quelle { get; } + } + } +} diff --git a/tests/PolyTrader.Tests/AccountingTests.cs b/tests/PolyTrader.Tests/AccountingTests.cs new file mode 100644 index 0000000..6748258 --- /dev/null +++ b/tests/PolyTrader.Tests/AccountingTests.cs @@ -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 +{ + /// + /// 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). + /// + 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(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 Items { get; } = new(); + public int Calls { get; private set; } + public Task> GetActivityAsync(string wallet, DateTime? since, CancellationToken ct) + { Calls++; return Task.FromResult((IReadOnlyList)Items.ToList()); } + } + private sealed class FakeBalance : IBalanceAnchorSource + { + public decimal? Value; + public Task 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(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); + } + } +} diff --git a/tests/PolyTrader.Tests/PolyTrader.Tests.csproj b/tests/PolyTrader.Tests/PolyTrader.Tests.csproj index 4291379..0a15feb 100644 --- a/tests/PolyTrader.Tests/PolyTrader.Tests.csproj +++ b/tests/PolyTrader.Tests/PolyTrader.Tests.csproj @@ -21,6 +21,7 @@ +