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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-20 08:54:12 +02:00
co-authored by Claude Opus 4.8
parent c7668170a4
commit a3c145c0ed
22 changed files with 1995 additions and 2 deletions
+1
View File
@@ -90,6 +90,7 @@
<ProjectReference Include="src\PolyTrader.Modules.CopyTrading\PolyTrader.Modules.CopyTrading.csproj" />
<ProjectReference Include="src\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj" />
<ProjectReference Include="src\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj" />
<ProjectReference Include="src\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj" />
</ItemGroup>
</Project>
+15
View File
@@ -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}
+4 -2
View File
@@ -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<IPolyTraderModule> { new CopyTradingModule(), new ResolutionFarmingModule(), new SupervisorModule() };
var modules = new System.Collections.Generic.List<IPolyTraderModule> { new CopyTradingModule(), new ResolutionFarmingModule(), new SupervisorModule(), new AccountingModule() };
using var host = Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
@@ -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.
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<AccountingDbContext>(o => o.UseMySql(conn, DatabaseServerVersion.Value));
services.AddSingleton<ILedgerRepository, EfLedgerRepository>();
services.AddSingleton<IIngestRunRepository, EfIngestRunRepository>();
services.AddSingleton<IRawSnapshotRepository, EfRawSnapshotRepository>();
// 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<IActivitySource, NullActivitySource>();
services.AddSingleton<ITransferSource, NullTransferSource>();
services.AddSingleton<IBalanceAnchorSource, NullBalanceAnchorSource>();
services.AddSingleton(new AccountingSystemContracts());
services.AddSingleton<AccountingIngestService>();
services.AddHostedService(sp => sp.GetRequiredService<AccountingIngestService>());
}
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;
}
}
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Linq;
using PolyTrader.Modules.Accounting.Models;
namespace PolyTrader.Modules.Accounting.Logic
{
/// <summary>
/// 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 <see cref="LedgerEntry.NetUsdc"/>: Cash-Wirkung aufs Konto (+ Zufluss / Abfluss).
/// Annahme (im Export dokumentiert, im Adapter justierbar): Activity-Feld <c>UsdcAmount</c> ist der
/// BRUTTO-Betrag, <c>Fee</c> separat — BUY kostet Brutto+Fee, SELL bringt BruttoFee.
/// </summary>
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)
};
}
/// <summary>
/// Klassifiziert einen On-Chain-USDC-Transfer. Ist der Gegenpart ein Polymarket-System-Contract
/// (interne Trading-Bewegung, bereits in der Activity erfasst), wird <c>null</c> zurückgegeben.
/// Nur Safe ↔ EXTERNE Adresse = echte Ein-/Auszahlung.
/// </summary>
public static LedgerEntry? ClassifyTransfer(int accountId, RawTransfer t, long ingestBatchId,
ISet<string> 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}"
};
}
/// <summary>Σ NetUsdc die Buchhaltungs-Sicht des Kontosaldos (für den Balance-Anker-Abgleich).</summary>
public static decimal SumNet(IEnumerable<LedgerEntry> 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}";
}
}
@@ -0,0 +1,32 @@
using System;
namespace PolyTrader.Modules.Accounting.Models
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>On-Chain-USDC-Saldo zum Abrufzeitpunkt (Balance-Anker), falls Quelle verfügbar.</summary>
public decimal? BalanceAnchorUsdc { get; set; }
/// <summary>Σ NetUsdc über alle Ledger-Sätze des Kontos (Soll aus der Buchhaltung).</summary>
public decimal? LedgerNetUsdc { get; set; }
/// <summary>Anker LedgerNet: sollte ≈ 0 sein; Abweichung = Vollständigkeits-/Klassifikations-Signal.</summary>
public decimal? BalanceDeltaUsdc { get; set; }
}
}
@@ -0,0 +1,59 @@
using System;
namespace PolyTrader.Modules.Accounting.Models
{
/// <summary>
/// Buchungssatz-Typ im neutralen Ledger. Als String persistiert (erweiterbar). Deckt die
/// Polymarket-Activity-Typen und die On-Chain-Ein-/Auszahlungen ab.
/// </summary>
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
}
/// <summary>
/// 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 <see cref="IdempotencyKey"/> auf einen prüfbaren
/// Nachweis zurück; überlappende Abrufe buchen dank des Unique-Keys nicht doppelt.
/// Vorzeichenkonvention: <see cref="NetUsdc"/> = Cash-Wirkung auf das Konto (+ Zufluss / Abfluss).
/// </summary>
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;
/// <summary>Stabiler Idempotenz-Schlüssel (unique). Gleiches Ereignis ⇒ gleicher Schlüssel.</summary>
public string IdempotencyKey { get; set; } = string.Empty;
}
}
@@ -0,0 +1,44 @@
using System;
namespace PolyTrader.Modules.Accounting.Models
{
/// <summary>
/// Normalisierte Polymarket-Activity-Position (Eingabe des Klassifikators). Die Live-Quelle
/// (<c>IActivitySource</c>) mappt die tatsächliche API-JSON auf dieses Modell — so bleibt die
/// pure Buchungslogik von der (im Zielland zu verifizierenden) API-Feldbenennung entkoppelt.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -0,0 +1,19 @@
using System;
namespace PolyTrader.Modules.Accounting.Models
{
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -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
{
/// <summary>
/// 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).
/// </summary>
public class AccountingDbContext : DbContext
{
public AccountingDbContext(DbContextOptions<AccountingDbContext> options) : base(options) { }
public DbSet<LedgerEntry> Ledger => Set<LedgerEntry>();
public DbSet<IngestRun> IngestRuns => Set<IngestRun>();
public DbSet<RawSnapshot> RawSnapshots => Set<RawSnapshot>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<LedgerEntry>(e =>
{
e.ToTable("acc_ledger");
e.HasKey(x => x.Id);
e.Property(x => x.Id).ValueGeneratedOnAdd();
e.Property(x => x.EventType).HasConversion<string>().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<IngestRun>(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<RawSnapshot>(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);
});
}
}
/// <summary>Design-Time-Factory (fixe Server-Version → Migrations-Scaffolding OHNE DB-Verbindung).</summary>
public class AccountingDbContextFactory : IDesignTimeDbContextFactory<AccountingDbContext>
{
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<AccountingDbContext>()
.UseMySql(conn, DatabaseServerVersion.Value)
.Options;
return new AccountingDbContext(options);
}
}
}
@@ -0,0 +1,214 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<bool>("Backfill")
.HasColumnType("tinyint(1)");
b.Property<decimal?>("BalanceAnchorUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<decimal?>("BalanceDeltaUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<int>("DuplicateEntries")
.HasColumnType("int");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("FromTimestamp")
.HasColumnType("datetime(6)");
b.Property<decimal?>("LedgerNetUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<int>("NewEntries")
.HasColumnType("int");
b.Property<DateTime>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("FeeUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<decimal>("GrossUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<long>("IngestBatchId")
.HasColumnType("bigint");
b.Property<DateTime>("IngestedAt")
.HasColumnType("datetime(6)");
b.Property<int>("LogIndex")
.HasColumnType("int");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<decimal>("NetUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<decimal>("PriceUsdc")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<decimal>("Size")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<DateTime>("CapturedAt")
.HasColumnType("datetime(6)");
b.Property<long>("IngestRunId")
.HasColumnType("bigint");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("SourceKind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.HasKey("Id");
b.HasIndex("IngestRunId");
b.ToTable("acc_raw", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,143 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PolyTrader.Modules.Accounting.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialAccounting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "acc_ingest_runs",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
AccountId = table.Column<int>(type: "int", nullable: false),
Backfill = table.Column<bool>(type: "tinyint(1)", nullable: false),
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
FinishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
FromTimestamp = table.Column<DateTime>(type: "datetime(6)", nullable: true),
NewEntries = table.Column<int>(type: "int", nullable: false),
DuplicateEntries = table.Column<int>(type: "int", nullable: false),
Success = table.Column<bool>(type: "tinyint(1)", nullable: false),
Message = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
BalanceAnchorUsdc = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: true),
LedgerNetUsdc = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: true),
BalanceDeltaUsdc = table.Column<decimal>(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<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
AccountId = table.Column<int>(type: "int", nullable: false),
EventType = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Timestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false),
TokenId = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
MarketSlug = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Outcome = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Side = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Size = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false),
PriceUsdc = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
GrossUsdc = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false),
FeeUsdc = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false),
NetUsdc = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false),
TxHash = table.Column<string>(type: "varchar(80)", maxLength: 80, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
LogIndex = table.Column<int>(type: "int", nullable: false),
Source = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IngestBatchId = table.Column<long>(type: "bigint", nullable: false),
IngestedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IdempotencyKey = table.Column<string>(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<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
IngestRunId = table.Column<long>(type: "bigint", nullable: false),
AccountId = table.Column<int>(type: "int", nullable: false),
CapturedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
SourceKind = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Json = table.Column<string>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "acc_ingest_runs");
migrationBuilder.DropTable(
name: "acc_ledger");
migrationBuilder.DropTable(
name: "acc_raw");
}
}
}
@@ -0,0 +1,211 @@
// <auto-generated />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<bool>("Backfill")
.HasColumnType("tinyint(1)");
b.Property<decimal?>("BalanceAnchorUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<decimal?>("BalanceDeltaUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<int>("DuplicateEntries")
.HasColumnType("int");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("FromTimestamp")
.HasColumnType("datetime(6)");
b.Property<decimal?>("LedgerNetUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<int>("NewEntries")
.HasColumnType("int");
b.Property<DateTime>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("FeeUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<decimal>("GrossUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<long>("IngestBatchId")
.HasColumnType("bigint");
b.Property<DateTime>("IngestedAt")
.HasColumnType("datetime(6)");
b.Property<int>("LogIndex")
.HasColumnType("int");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<decimal>("NetUsdc")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<decimal>("PriceUsdc")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<decimal>("Size")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<DateTime>("CapturedAt")
.HasColumnType("datetime(6)");
b.Property<long>("IngestRunId")
.HasColumnType("bigint");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("SourceKind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.HasKey("Id");
b.HasIndex("IngestRunId");
b.ToTable("acc_raw", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -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
{
/// <summary>Append-only Ledger-Zugriff mit idempotentem Upsert (Doppel-Buchen ausgeschlossen).</summary>
public interface ILedgerRepository
{
/// <summary>Fügt den Satz ein, falls sein IdempotencyKey neu ist. true = neu gebucht, false = Duplikat.</summary>
bool Upsert(LedgerEntry entry);
DateTime? LatestTimestamp(int accountId);
decimal SumNet(int accountId);
int Count(int accountId);
List<LedgerEntry> Query(int? accountId, DateTime? from, DateTime? to, int limit);
}
public interface IIngestRunRepository
{
void Insert(IngestRun run); // setzt Id
void Update(IngestRun run);
List<IngestRun> GetRecent(int? accountId, int limit);
}
public interface IRawSnapshotRepository
{
void Insert(RawSnapshot snapshot);
}
// ---------------- EF-Implementierungen ----------------
public class EfLedgerRepository : ILedgerRepository
{
private readonly IDbContextFactory<AccountingDbContext> _factory;
public EfLedgerRepository(IDbContextFactory<AccountingDbContext> 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<LedgerEntry> 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<AccountingDbContext> _factory;
public EfIngestRunRepository(IDbContextFactory<AccountingDbContext> 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<IngestRun> 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<AccountingDbContext> _factory;
public EfRawSnapshotRepository(IDbContextFactory<AccountingDbContext> factory) => _factory = factory;
public void Insert(RawSnapshot snapshot)
{
using var ctx = _factory.CreateDbContext();
ctx.RawSnapshots.Add(snapshot);
ctx.SaveChanges();
}
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\PolyTrader.Core\PolyTrader.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<!-- Erlaubt dem Testprojekt, interne Helfer zu testen. -->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>PolyTrader.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Modul trägt eigene WinForms-UI bei (Ledger/Status; BWA/Steuer/Export folgen A-2..A-4). -->
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
</Project>
@@ -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
{
/// <summary>
/// Ingest-Orchestrierung (A-1): erhebt je Live-Account die unabhängige Buchungsgrundlage von
/// Polymarket/On-Chain, klassifiziert sie pur (<see cref="AccountingClassifier"/>) 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
/// <see cref="IngestAccountAsync"/>.
/// </summary>
public sealed class AccountingIngestService : BackgroundService
{
/// <summary>Sicherheits-Überlappung gegen API-Lag beim inkrementellen Abruf.</summary>
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; }
}
}
/// <summary>Ein Durchlauf über alle Live-Accounts (mit gesetzter WalletAddress).</summary>
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)" : ""));
}
}
/// <summary>
/// Testbarer Kern: erhebt + bucht einen Account, protokolliert den Lauf inkl. Balance-Anker.
/// Fehler brechen den Gesamt-Ingest nicht (im Run vermerkt).
/// </summary>
public async Task<IngestRun> 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<string> rawItems)
{
var items = rawItems.Where(s => !string.IsNullOrEmpty(s)).ToList();
return items.Count == 0 ? "[]" : "[" + string.Join(",", items) + "]";
}
}
}
@@ -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
{
/// <summary>
/// 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 <see cref="RawActivity"/> und speichert den Rohschnappschuss.
/// </summary>
public interface IActivitySource
{
/// <summary>Activity ab <paramref name="since"/> (null = volle Historie/Backfill), paginiert.</summary>
Task<IReadOnlyList<RawActivity>> GetActivityAsync(string wallet, DateTime? since, CancellationToken ct);
}
/// <summary>On-Chain-USDC-Transfers des Safe-Wallets (ERC-20-Logs). Live-Impl über Alchemy (Zielland).</summary>
public interface ITransferSource
{
Task<IReadOnlyList<RawTransfer>> GetTransfersAsync(string wallet, DateTime? since, CancellationToken ct);
}
/// <summary>On-Chain-USDC-Saldo als Balance-Anker (Soll-Ist). Live-Impl über GetUsdcBalanceAsync.</summary>
public interface IBalanceAnchorSource
{
Task<decimal?> GetBalanceAsync(string wallet, CancellationToken ct);
}
/// <summary>
/// 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).
/// </summary>
public sealed class NullActivitySource : IActivitySource
{
public Task<IReadOnlyList<RawActivity>> GetActivityAsync(string wallet, DateTime? since, CancellationToken ct)
=> Task.FromResult((IReadOnlyList<RawActivity>)Array.Empty<RawActivity>());
}
public sealed class NullTransferSource : ITransferSource
{
public Task<IReadOnlyList<RawTransfer>> GetTransfersAsync(string wallet, DateTime? since, CancellationToken ct)
=> Task.FromResult((IReadOnlyList<RawTransfer>)Array.Empty<RawTransfer>());
}
public sealed class NullBalanceAnchorSource : IBalanceAnchorSource
{
public Task<decimal?> GetBalanceAsync(string wallet, CancellationToken ct) => Task.FromResult((decimal?)null);
}
/// <summary>
/// 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.
/// </summary>
public sealed class AccountingSystemContracts
{
public HashSet<string> Addresses { get; } = new(StringComparer.OrdinalIgnoreCase);
public AccountingSystemContracts(IEnumerable<string>? addresses = null)
{
if (addresses == null) return;
foreach (var a in addresses)
if (!string.IsNullOrWhiteSpace(a)) Addresses.Add(a.Trim().ToLowerInvariant());
}
}
}
@@ -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;
}
}
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<ILedgerRepository>();
_runs = services.GetRequiredService<IIngestRunRepository>();
_ingest = services.GetRequiredService<AccountingIngestService>();
_state = services.GetRequiredService<TradingState>();
PopulateAccounts();
LoadLedger();
LoadRuns();
}
// ---------------- Konten-Filter ----------------
private void PopulateAccounts()
{
var items = new List<AccountItem> { 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<AccountItem>(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);
/// <summary>Anzeige-Zeile fürs Ledger-Grid (kompakte, lesbare Spalten).</summary>
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; }
}
}
}
+189
View File
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PolyTrader.Modules.Accounting.Logic;
using PolyTrader.Modules.Accounting.Models;
using PolyTrader.Modules.Accounting.Persistence;
using PolyTrader.Modules.Accounting.Services;
using PolyTrader.Tests.TestSupport;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Sicherheitsnetz für das Accounting-Fundament (A-1): pure Klassifikation (Typ/Vorzeichen/
/// Idempotenz), Deposit/Withdrawal-Trennung intern↔extern, und die Ingest-Orchestrierung
/// (idempotenter Upsert, Balance-Anker, Backfill vs. inkrementell).
/// </summary>
public class AccountingTests
{
// ---------------- Pure Klassifikation ----------------
private static RawActivity Act(string type, string side, decimal usdc, decimal fee = 0m,
string tx = "0xabc", int logIndex = 0, string token = "tok") => new()
{
Type = type, Side = side, Timestamp = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc),
TxHash = tx, LogIndex = logIndex, TokenId = token, UsdcAmount = usdc, Fee = fee, Size = 10m, Price = 0.5m
};
[Fact]
public void Buy_is_cash_out_including_fee()
{
var e = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "BUY", 100m, 1.5m), 7);
Assert.Equal(LedgerEventType.TradeBuy, e.EventType);
Assert.Equal(-101.5m, e.NetUsdc); // (brutto + fee)
Assert.Equal(100m, e.GrossUsdc);
Assert.Equal(1.5m, e.FeeUsdc);
Assert.Equal(7, e.IngestBatchId);
}
[Fact]
public void Sell_is_cash_in_minus_fee()
{
var e = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "SELL", 100m, 1.5m), 7);
Assert.Equal(LedgerEventType.TradeSell, e.EventType);
Assert.Equal(98.5m, e.NetUsdc); // brutto fee
}
[Theory]
[InlineData("REDEEM", 50.0, 50.0)]
[InlineData("REWARD", 3.0, 3.0)]
[InlineData("SPLIT", 20.0, 0.0)] // geldneutral
[InlineData("MERGE", 20.0, 0.0)]
[InlineData("CONVERSION", 20.0, 0.0)]
public void Other_types_have_expected_net(string type, double usdc, double expectedNet)
{
var e = AccountingClassifier.ClassifyActivity(1, Act(type, "", (decimal)usdc), 7);
Assert.Equal((decimal)expectedNet, e.NetUsdc);
}
[Fact]
public void Idempotency_key_is_stable_and_type_specific()
{
var buy = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "BUY", 100m, tx: "0xAbC", logIndex: 2), 7);
var buyAgain = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "BUY", 100m, tx: "0xabc", logIndex: 2), 99);
Assert.Equal(buy.IdempotencyKey, buyAgain.IdempotencyKey); // gleicher Key trotz anderem Batch/Casing
var sell = AccountingClassifier.ClassifyActivity(1, Act("TRADE", "SELL", 100m, tx: "0xabc", logIndex: 2), 7);
Assert.NotEqual(buy.IdempotencyKey, sell.IdempotencyKey); // Typ unterscheidet
}
[Fact]
public void Transfer_external_is_deposit_or_withdrawal_internal_is_skipped()
{
var contracts = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "0xctf" };
var deposit = AccountingClassifier.ClassifyTransfer(1, new RawTransfer
{
IsIncoming = true, FromAddress = "0xExternalWhale", ToAddress = "0xSafe",
UsdcAmount = 500m, TxHash = "0xd", LogIndex = 1
}, 7, contracts);
Assert.NotNull(deposit);
Assert.Equal(LedgerEventType.Deposit, deposit!.EventType);
Assert.Equal(500m, deposit.NetUsdc);
var withdrawal = AccountingClassifier.ClassifyTransfer(1, new RawTransfer
{
IsIncoming = false, FromAddress = "0xSafe", ToAddress = "0xExternalBank",
UsdcAmount = 200m, TxHash = "0xw", LogIndex = 0
}, 7, contracts);
Assert.Equal(LedgerEventType.Withdrawal, withdrawal!.EventType);
Assert.Equal(-200m, withdrawal.NetUsdc);
// interne Bewegung (Gegenpart = System-Contract) → NICHT als Ein-/Auszahlung buchen
var internalMove = AccountingClassifier.ClassifyTransfer(1, new RawTransfer
{
IsIncoming = false, FromAddress = "0xSafe", ToAddress = "0xCTF", UsdcAmount = 100m, TxHash = "0xi"
}, 7, contracts);
Assert.Null(internalMove);
}
// ---------------- Ingest-Orchestrierung ----------------
private sealed class FakeActivitySource : IActivitySource
{
public List<RawActivity> Items { get; } = new();
public int Calls { get; private set; }
public Task<IReadOnlyList<RawActivity>> GetActivityAsync(string wallet, DateTime? since, CancellationToken ct)
{ Calls++; return Task.FromResult((IReadOnlyList<RawActivity>)Items.ToList()); }
}
private sealed class FakeBalance : IBalanceAnchorSource
{
public decimal? Value;
public Task<decimal?> GetBalanceAsync(string wallet, CancellationToken ct) => Task.FromResult(Value);
}
private static (AccountingIngestService svc, EfLedgerRepository ledger, EfIngestRunRepository runs, FakeActivitySource act, FakeBalance bal)
BuildIngest()
{
var factory = new InMemoryContextFactory<AccountingDbContext>(o => new AccountingDbContext(o));
var ledger = new EfLedgerRepository(factory);
var runs = new EfIngestRunRepository(factory);
var raw = new EfRawSnapshotRepository(factory);
var act = new FakeActivitySource();
var bal = new FakeBalance();
var state = new TradingState();
var svc = new AccountingIngestService(state, ledger, runs, raw, act, new NullTransferSource(), bal,
new AccountingSystemContracts(), new TerminalLogger());
return (svc, ledger, runs, act, bal);
}
private static AccountState LiveAccount(int id) =>
new() { AccountId = id, Name = "Live", IsDemo = false, WalletAddress = "0xSafe" };
[Fact]
public async Task Ingest_books_entries_and_is_idempotent_on_reingest()
{
var (svc, ledger, _, act, bal) = BuildIngest();
act.Items.Add(Act("TRADE", "BUY", 100m, 1m, tx: "0x1", logIndex: 0));
act.Items.Add(Act("TRADE", "SELL", 120m, 1m, tx: "0x2", logIndex: 0));
bal.Value = 18m; // Anker
var run1 = await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None);
Assert.True(run1.Success);
Assert.Equal(2, run1.NewEntries);
Assert.Equal(0, run1.DuplicateEntries);
Assert.Equal(2, ledger.Count(1));
// Zweiter Lauf mit denselben Ereignissen → alles Duplikate, keine Doppelbuchung.
var run2 = await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None);
Assert.Equal(0, run2.NewEntries);
Assert.Equal(2, run2.DuplicateEntries);
Assert.Equal(2, ledger.Count(1));
}
[Fact]
public async Task Ingest_records_balance_anchor_delta()
{
var (svc, ledger, _, act, bal) = BuildIngest();
act.Items.Add(Act("TRADE", "BUY", 100m, 0m, tx: "0x1")); // Netto 100
act.Items.Add(Act("REDEEM", "", 150m, tx: "0x2")); // Netto +150 → Σ = 50
bal.Value = 50m; // Anker == Ledger-Netto → Δ 0
var run = await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None);
Assert.Equal(50m, run.LedgerNetUsdc);
Assert.Equal(50m, run.BalanceAnchorUsdc);
Assert.Equal(0m, run.BalanceDeltaUsdc); // Vollständigkeit: Soll-Ist ≈ 0
}
[Fact]
public async Task Incremental_run_queries_from_last_timestamp_with_lookback()
{
var (svc, _, _, act, _) = BuildIngest();
act.Items.Add(Act("TRADE", "BUY", 100m, tx: "0x1"));
await svc.IngestAccountAsync(LiveAccount(1), backfill: true, CancellationToken.None);
var run = await svc.IngestAccountAsync(LiveAccount(1), backfill: false, CancellationToken.None);
// Fenster beginnt beim jüngsten Ledger-Zeitpunkt minus Lookback-Überlappung.
Assert.NotNull(run.FromTimestamp);
var expected = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc)
.AddHours(-AccountingIngestService.IncrementalLookbackHours);
Assert.Equal(expected, run.FromTimestamp);
}
}
}
@@ -21,6 +21,7 @@
<ProjectReference Include="..\..\src\PolyTrader.Modules.CopyTrading\PolyTrader.Modules.CopyTrading.csproj" />
<ProjectReference Include="..\..\src\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj" />
<ProjectReference Include="..\..\src\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj" />
<ProjectReference Include="..\..\src\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj" />
</ItemGroup>
</Project>