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 @@
+