Accounting A-1: Ingest-Fundament (unabhaengiger Ledger, idempotent, read-only)
Neues Modul PolyTrader.Modules.Accounting (IPolyTraderModule, acc_-Praefix, nur Core-Referenz, KEIN Handel). Konzept: docs/konzepte/KONZEPT-Modul-Accounting.md, Phase A-1. Buchungsgrundlage ausschliesslich aus unabhaengigen Polymarket-/On-Chain-Abrufen (nie unsere Trading-DB), append-only, prueffaehig: - Modelle: LedgerEntry (+ LedgerEventType), IngestRun (mit Balance-Anker), RawSnapshot, RawActivity/RawTransfer (normalisierte Eingaben, entkoppeln pure Logik von der API-Feldbenennung). - AccountingClassifier (Logic/, pur+getestet): Activity->Buchungssatz (Typ/Vorzeichen: BUY=Cash raus inkl. Fee, SELL=Cash rein minus Fee, Redeem/Reward +, Split/Merge/Conversion geldneutral), stabiler Idempotency-Key; Transfer-Klassifikation trennt intern (System-Contract-Whitelist) von externen Deposits/Withdrawals. SumNet fuer den Balance-Anker-Abgleich. - AccountingDbContext (acc_ledger append-only + Unique-Index Idempotency, acc_ingest_runs, acc_raw; Autoincrement-PKs). Migration InitialAccounting generiert UND angewendet. Repos mit idempotentem Upsert (true=neu/false=Duplikat). - AccountingIngestService (BackgroundService): testbarer IngestAccountAsync - Activity + On-Chain- Transfers klassifizieren + idempotent buchen, Rohschnappschuss ablegen, Lauf inkl. Balance-Anker- Delta protokollieren; Backfill vs. inkrementell (Lookback-Ueberlappung gegen API-Lag). - Quellen hinter Interfaces (IActivitySource/ITransferSource/IBalanceAnchorSource) mit Null-Stubs: Modul laeuft offline und bucht korrekt nichts. Live-Abruf + System-Contract-Whitelist = Zielland. - UI designerfaehig (partial + .Designer.cs): Tabs Ledger (filterbar) + Abruf/Status (Ingest-Laeufe, Balance-Anker, manueller Backfill/Inkrement). - Program.cs (beide Modul-Listen) + sln + App/Tests-Referenzen. A-2 (Abrechnung/BWA/FX), A-3 (US-Steuerschicht FIFO/Form-8949), A-4 (CSV/PDF via PDFsharp/MigraDoc) folgen. Tests: +12 (Klassifikation, intern/extern-Transfer, Ingest-Idempotenz, Balance-Anker, Inkrement-Fenster). Build 0 Fehler, 379 Tests gruen, --smoke-ui alle 6 Views gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c7668170a4
commit
a3c145c0ed
@@ -90,6 +90,7 @@
|
|||||||
<ProjectReference Include="src\PolyTrader.Modules.CopyTrading\PolyTrader.Modules.CopyTrading.csproj" />
|
<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.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj" />
|
||||||
<ProjectReference Include="src\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj" />
|
<ProjectReference Include="src\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj" />
|
||||||
|
<ProjectReference Include="src\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Modules.Resoluti
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Modules.Supervisor", "src\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj", "{63F8B9B4-6F56-4A6B-BEBE-53DE49F913D1}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Modules.Supervisor", "src\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj", "{63F8B9B4-6F56-4A6B-BEBE-53DE49F913D1}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{63F8B9B4-6F56-4A6B-BEBE-53DE49F913D1}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -131,6 +145,7 @@ Global
|
|||||||
{E361C601-CC50-409F-8298-FD753DB4F6FF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{E361C601-CC50-409F-8298-FD753DB4F6FF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{63F8B9B4-6F56-4A6B-BEBE-53DE49F913D1} = {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
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {60AA6BCF-B17E-4D52-A290-14154A3E97CF}
|
SolutionGuid = {60AA6BCF-B17E-4D52-A290-14154A3E97CF}
|
||||||
|
|||||||
+4
-2
@@ -17,6 +17,7 @@ using PolyTrader.Modules.CopyTrading;
|
|||||||
using PolyTrader.Modules.CopyTrading.Persistence;
|
using PolyTrader.Modules.CopyTrading.Persistence;
|
||||||
using PolyTrader.Modules.ResolutionFarming;
|
using PolyTrader.Modules.ResolutionFarming;
|
||||||
using PolyTrader.Modules.Supervisor;
|
using PolyTrader.Modules.Supervisor;
|
||||||
|
using PolyTrader.Modules.Accounting;
|
||||||
using PolyTraderSharp.Models;
|
using PolyTraderSharp.Models;
|
||||||
using PolyTraderSharp.Services;
|
using PolyTraderSharp.Services;
|
||||||
|
|
||||||
@@ -65,7 +66,8 @@ internal static class Program
|
|||||||
{
|
{
|
||||||
new CopyTradingModule(),
|
new CopyTradingModule(),
|
||||||
new ResolutionFarmingModule(),
|
new ResolutionFarmingModule(),
|
||||||
new SupervisorModule()
|
new SupervisorModule(),
|
||||||
|
new AccountingModule()
|
||||||
};
|
};
|
||||||
|
|
||||||
AppHost = Host.CreateDefaultBuilder()
|
AppHost = Host.CreateDefaultBuilder()
|
||||||
@@ -383,7 +385,7 @@ internal static class Program
|
|||||||
{
|
{
|
||||||
ApplicationConfiguration.Initialize();
|
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()
|
using var host = Host.CreateDefaultBuilder()
|
||||||
.UseContentRoot(AppContext.BaseDirectory)
|
.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.
|
(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
|
**Akzeptanz:** volle Historie eines Live-Accounts vollständig & doppelfrei; Balance-Anker Soll-Ist ≈ 0
|
||||||
(inkl. Ein-/Auszahlungen).
|
(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,
|
- **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
|
Werte in USDC/USD/EUR (Tageskurse in `acc_fx_rates`). **Akzeptanz:** Monatsabrechnung stimmt gegen
|
||||||
Balance-Anker; Kennzahlen plausibel; FX nachvollziehbar.
|
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 Brutto−Fee.
|
||||||
|
/// </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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+211
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.CopyTrading\PolyTrader.Modules.CopyTrading.csproj" />
|
||||||
<ProjectReference Include="..\..\src\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.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.Supervisor\PolyTrader.Modules.Supervisor.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user