Accounting A-2: neutrale Periodenabrechnung + BWA-Uebersicht + FX + CSV-Export

Alles laenderneutral, ohne steuerliche Einordnung (A-3 US-Steuerschicht bleibt bewusst offen,
haengt an den CPA-Fragebogen-Antworten). Konzept: docs/konzepte/KONZEPT-Modul-Accounting.md, A-2.

Pure Logik (unit-getestet):
- AccountingEngine.BuildStatement: aggregiert Ledger-Saetze eines Zeitraums (x Account/alle) zu
  Anfangs-/Endsaldo, Ein-/Auszahlungen, Handelsvolumen, Redeems, Rewards, Fees und
  Netto-Handelsergebnis (Cash-Basis, EXKL. Ein-/Auszahlungen); Invariante Endsaldo-Anfang =
  Ergebnis + Einz. - Ausz. BuildMonthlyBreakdown: Monatsvergleich mit verketteten Anfangssalden.
- FxConverter: USDC->USD (dokumentierte 1:1-Annahme) + USD->EUR ueber amtliche Tageskurse
  (acc_fx_rates, Nearest-on-or-before fuer Wochenend-/Feiertage).
- CsvExporter: Ledger + Statement als RFC-4180-CSV, kulturinvariant (Punkt-Dezimal, ISO-Datum).

Infrastruktur:
- acc_fx_rates (FxRate, PK Datum) + EfFxRateRepository (Upsert je Datum). Migration AddFxRates angewendet.
- ILedgerRepository.GetUpTo (alle Saetze <= to fuer die Abrechnung inkl. Anfangssaldo).
- AccountingReportService: baut Abrechnungen + Waehrungs-View (USDC/USD immer, EUR wenn Kurse geladen).

UI (designerfaehig, partial + .Designer.cs): neuer erster Tab 'Uebersicht / BWA' mit KPI-Kacheln
(Netto-Handelsergebnis gruen/rot, Endsaldo, Ein-/Auszahlungen, Fees, Rewards, Volumen, #Trades),
Monatsvergleich-Grid, Zeitraum-Picker (Standard laufender Monat), Konto- und Waehrungswahl, CSV-Export
via SaveFileDialog.

Tests: +6 (Periodenaggregation+Invariante, Monatsverkettung, FX-Umrechnung/Nearest-Kurs, CSV-Quoting).
Build 0 Fehler, 385 Tests gruen, --smoke-ui alle 6 Views gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-20 15:06:13 +02:00
co-authored by Claude Opus 4.8
parent a3c145c0ed
commit 42a599a3a3
15 changed files with 1205 additions and 10 deletions
@@ -32,6 +32,10 @@ namespace PolyTrader.Modules.Accounting
services.AddSingleton<ILedgerRepository, EfLedgerRepository>();
services.AddSingleton<IIngestRunRepository, EfIngestRunRepository>();
services.AddSingleton<IRawSnapshotRepository, EfRawSnapshotRepository>();
services.AddSingleton<IFxRateRepository, EfFxRateRepository>();
// A-2: neutrale Periodenabrechnung/BWA + Währungs-View (USDC/USD/EUR).
services.AddSingleton<Services.AccountingReportService>();
// 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,
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using PolyTrader.Modules.Accounting.Models;
namespace PolyTrader.Modules.Accounting.Logic
{
/// <summary>
/// Neutrale Periodenabrechnung (A-2): aggregiert die Ledger-Sätze eines Zeitraums (× Account oder
/// alle) zu einer prüfbaren Aufstellung — länderneutral, ohne steuerliche Einordnung (die kommt
/// getrennt in der US-Steuerschicht A-3). Alle Beträge in USDC (native Buchungswährung); die
/// FX-Umrechnung nach USD/EUR liegt separat im <see cref="FxConverter"/>.
/// </summary>
public sealed record PeriodStatement(
int? AccountId,
DateTime From,
DateTime To,
decimal OpeningBalanceUsdc,
decimal ClosingBalanceUsdc,
decimal Deposits,
decimal Withdrawals,
decimal TradeVolume,
decimal Redeems,
decimal Rewards,
decimal Fees,
decimal NetTradingResultUsdc, // operatives Ergebnis (Cash-Basis, EXKL. Ein-/Auszahlungen)
int TradeCount,
int EntryCount)
{
/// <summary>Invariante: Endsaldo Anfangssaldo = Handelsergebnis + Einzahlungen Auszahlungen.</summary>
public decimal BalanceChange => ClosingBalanceUsdc - OpeningBalanceUsdc;
}
public static class AccountingEngine
{
private static bool IsCashflowType(LedgerEventType t) =>
t is LedgerEventType.Deposit or LedgerEventType.Withdrawal;
/// <summary>
/// Baut die Abrechnung für [<paramref name="from"/>, <paramref name="to"/>]. <paramref name="allUpToTo"/>
/// enthält ALLE Ledger-Sätze des Scopes bis <paramref name="to"/> (für den Anfangssaldo werden die
/// Sätze vor <paramref name="from"/> kumuliert). Grenzen inklusive.
/// </summary>
public static PeriodStatement BuildStatement(
IEnumerable<LedgerEntry> allUpToTo, DateTime from, DateTime to, int? accountId)
{
var list = allUpToTo.Where(e => e.Timestamp <= to).ToList();
decimal opening = list.Where(e => e.Timestamp < from).Sum(e => e.NetUsdc);
var period = list.Where(e => e.Timestamp >= from && e.Timestamp <= to).ToList();
decimal Sum(Func<LedgerEntry, bool> pred, Func<LedgerEntry, decimal> sel) =>
period.Where(pred).Sum(sel);
decimal deposits = Sum(e => e.EventType == LedgerEventType.Deposit, e => e.GrossUsdc);
decimal withdrawals = Sum(e => e.EventType == LedgerEventType.Withdrawal, e => e.GrossUsdc);
decimal tradeVolume = Sum(e => e.EventType is LedgerEventType.TradeBuy or LedgerEventType.TradeSell, e => e.GrossUsdc);
decimal redeems = Sum(e => e.EventType == LedgerEventType.Redeem, e => e.GrossUsdc);
decimal rewards = Sum(e => e.EventType == LedgerEventType.Reward, e => e.GrossUsdc);
decimal fees = period.Sum(e => e.FeeUsdc);
decimal netTrading = period.Where(e => !IsCashflowType(e.EventType)).Sum(e => e.NetUsdc);
decimal closing = opening + period.Sum(e => e.NetUsdc);
int tradeCount = period.Count(e => e.EventType is LedgerEventType.TradeBuy or LedgerEventType.TradeSell);
return new PeriodStatement(accountId, from, to, opening, closing, deposits, withdrawals,
tradeVolume, redeems, rewards, fees, netTrading, tradeCount, period.Count);
}
/// <summary>
/// Zerlegt den Zeitraum in Kalendermonate und liefert je Monat eine Abrechnung (für den
/// BWA-Perioden-/Monatsvergleich). Anfangssaldo jedes Monats = Endsaldo des Vormonats.
/// </summary>
public static List<PeriodStatement> BuildMonthlyBreakdown(
IEnumerable<LedgerEntry> allUpToTo, DateTime from, DateTime to, int? accountId)
{
var list = allUpToTo.ToList();
var result = new List<PeriodStatement>();
var monthStart = new DateTime(from.Year, from.Month, 1, 0, 0, 0, DateTimeKind.Utc);
while (monthStart <= to)
{
DateTime monthEnd = monthStart.AddMonths(1).AddTicks(-1);
DateTime windowFrom = monthStart < from ? from : monthStart;
DateTime windowTo = monthEnd > to ? to : monthEnd;
result.Add(BuildStatement(list, windowFrom, windowTo, accountId));
monthStart = monthStart.AddMonths(1);
}
return result;
}
}
}
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using PolyTrader.Modules.Accounting.Models;
namespace PolyTrader.Modules.Accounting.Logic
{
/// <summary>
/// Reiner CSV-Export (A-2/A-4-neutral): erzeugt maschinen-/prüfbare CSV-Strings aus Ledger und
/// Abrechnung. Kulturinvariant (Punkt-Dezimal, ISO-Datum), RFC-4180-Quoting. Der Dateizugriff
/// liegt in der UI; die Formatierung ist hier pur + testbar.
/// </summary>
public static class CsvExporter
{
private static string F(decimal d) => d.ToString("0.######", CultureInfo.InvariantCulture);
private static string T(DateTime d) => d.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
private static string Q(string s)
{
s ??= string.Empty;
bool needsQuote = s.Contains(',') || s.Contains('"') || s.Contains('\n') || s.Contains('\r');
if (needsQuote) s = "\"" + s.Replace("\"", "\"\"") + "\"";
return s;
}
/// <summary>Vollständiger Ledger-Export (eine Zeile je Buchungssatz, inkl. Nachweisspalten).</summary>
public static string Ledger(IEnumerable<LedgerEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine("Timestamp,AccountId,EventType,Side,MarketSlug,Outcome,Size,PriceUsdc,GrossUsdc,FeeUsdc,NetUsdc,TxHash,LogIndex,Source,IdempotencyKey");
foreach (var e in entries.OrderBy(e => e.Timestamp))
{
sb.Append(T(e.Timestamp)).Append(',')
.Append(e.AccountId).Append(',')
.Append(e.EventType).Append(',')
.Append(Q(e.Side)).Append(',')
.Append(Q(e.MarketSlug)).Append(',')
.Append(Q(e.Outcome)).Append(',')
.Append(F(e.Size)).Append(',')
.Append(F(e.PriceUsdc)).Append(',')
.Append(F(e.GrossUsdc)).Append(',')
.Append(F(e.FeeUsdc)).Append(',')
.Append(F(e.NetUsdc)).Append(',')
.Append(Q(e.TxHash)).Append(',')
.Append(e.LogIndex).Append(',')
.Append(Q(e.Source)).Append(',')
.Append(Q(e.IdempotencyKey)).Append('\n');
}
return sb.ToString();
}
/// <summary>Aggregat-Export einer Abrechnung (Kennzahl,USDC) prüfbare Zusammenfassung.</summary>
public static string Statement(PeriodStatement s)
{
var sb = new StringBuilder();
sb.AppendLine("Kennzahl,USDC");
void Row(string k, decimal v) => sb.Append(Q(k)).Append(',').Append(F(v)).Append('\n');
sb.AppendLine(Q($"Zeitraum {T(s.From)} .. {T(s.To)}" + (s.AccountId.HasValue ? $" | Konto {s.AccountId}" : " | alle Konten")) + ",");
Row("Anfangssaldo", s.OpeningBalanceUsdc);
Row("Einzahlungen", s.Deposits);
Row("Auszahlungen", s.Withdrawals);
Row("Handelsvolumen", s.TradeVolume);
Row("Redeems", s.Redeems);
Row("Rewards", s.Rewards);
Row("Fees", s.Fees);
Row("Netto-Handelsergebnis (Cash)", s.NetTradingResultUsdc);
Row("Endsaldo", s.ClosingBalanceUsdc);
sb.Append(Q("Anzahl Trades")).Append(',').Append(s.TradeCount).Append('\n');
sb.Append(Q("Anzahl Buchungen")).Append(',').Append(s.EntryCount).Append('\n');
return sb.ToString();
}
}
}
@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;
using PolyTrader.Modules.Accounting.Models;
namespace PolyTrader.Modules.Accounting.Logic
{
/// <summary>
/// Reine Währungsumrechnung (A-2): jede Transaktion/Aufstellung ist in USDC (nativ). USD ergibt sich
/// über die (dokumentierte, umstellbare) USDC≈USD-Annahme; EUR über amtliche USD→EUR-Tageskurse
/// (EZB, versioniert in acc_fx_rates). Seiteneffektfrei + unit-getestet.
/// </summary>
public static class FxConverter
{
/// <summary>Dokumentierte Vereinfachung: 1 USDC = 1 USD (im Export ausgewiesen, in Settings umstellbar).</summary>
public const decimal DefaultUsdcToUsd = 1.0m;
public static decimal UsdcToUsd(decimal usdc, decimal usdcToUsd) =>
Math.Round(usdc * usdcToUsd, 6, MidpointRounding.AwayFromZero);
public static decimal UsdToEur(decimal usd, decimal usdToEur) =>
Math.Round(usd * usdToEur, 6, MidpointRounding.AwayFromZero);
public static decimal UsdcToEur(decimal usdc, decimal usdToEur, decimal usdcToUsd) =>
Math.Round(usdc * usdcToUsd * usdToEur, 6, MidpointRounding.AwayFromZero);
/// <summary>
/// Amtlicher Kurs am oder vor dem Stichtag (Wochenend-/Feiertagskurse gibt es nicht → letzter
/// gültiger davor). null, wenn kein Kurs am/vor dem Datum vorliegt.
/// </summary>
public static FxRate? NearestOnOrBefore(IEnumerable<FxRate> rates, DateTime date)
{
DateTime day = date.Date;
return rates.Where(r => r.Date.Date <= day)
.OrderByDescending(r => r.Date)
.FirstOrDefault();
}
}
}
@@ -0,0 +1,17 @@
using System;
namespace PolyTrader.Modules.Accounting.Models
{
/// <summary>
/// Amtlicher Tages-FX-Kurs (Tabelle acc_fx_rates): USD→EUR je Datum (z. B. EZB-Referenzkurs),
/// versioniert für nachvollziehbare, reproduzierbare Umrechnung. USDC→USD wird als dokumentierte
/// 1:1-Annahme separat behandelt (nicht je Tag gespeichert).
/// </summary>
public class FxRate
{
/// <summary>Kurs-Datum (nur Datum; PK).</summary>
public DateTime Date { get; set; }
public decimal UsdToEur { get; set; }
public string Source { get; set; } = string.Empty; // z. B. "ECB" / "manual"
}
}
@@ -17,6 +17,7 @@ namespace PolyTrader.Modules.Accounting.Persistence
public DbSet<LedgerEntry> Ledger => Set<LedgerEntry>();
public DbSet<IngestRun> IngestRuns => Set<IngestRun>();
public DbSet<RawSnapshot> RawSnapshots => Set<RawSnapshot>();
public DbSet<FxRate> FxRates => Set<FxRate>();
protected override void OnModelCreating(ModelBuilder b)
{
@@ -64,6 +65,15 @@ namespace PolyTrader.Modules.Accounting.Persistence
e.Property(x => x.Json).HasColumnType("longtext");
e.HasIndex(x => x.IngestRunId);
});
b.Entity<FxRate>(e =>
{
e.ToTable("acc_fx_rates");
e.HasKey(x => x.Date);
e.Property(x => x.Date).HasColumnType("date");
e.Property(x => x.UsdToEur).HasPrecision(18, 8);
e.Property(x => x.Source).HasMaxLength(40);
});
}
}
@@ -0,0 +1,233 @@
// <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("20260720100910_AddFxRates")]
partial class AddFxRates
{
/// <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.FxRate", b =>
{
b.Property<DateTime>("Date")
.HasColumnType("date");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("UsdToEur")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.HasKey("Date");
b.ToTable("acc_fx_rates", (string)null);
});
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,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PolyTrader.Modules.Accounting.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddFxRates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "acc_fx_rates",
columns: table => new
{
Date = table.Column<DateTime>(type: "date", nullable: false),
UsdToEur = table.Column<decimal>(type: "decimal(18,8)", precision: 18, scale: 8, nullable: false),
Source = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_acc_fx_rates", x => x.Date);
})
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "acc_fx_rates");
}
}
}
@@ -22,6 +22,25 @@ namespace PolyTrader.Modules.Accounting.Persistence.Migrations
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("PolyTrader.Modules.Accounting.Models.FxRate", b =>
{
b.Property<DateTime>("Date")
.HasColumnType("date");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("UsdToEur")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.HasKey("Date");
b.ToTable("acc_fx_rates", (string)null);
});
modelBuilder.Entity("PolyTrader.Modules.Accounting.Models.IngestRun", b =>
{
b.Property<long>("Id")
@@ -15,6 +15,8 @@ namespace PolyTrader.Modules.Accounting.Persistence
decimal SumNet(int accountId);
int Count(int accountId);
List<LedgerEntry> Query(int? accountId, DateTime? from, DateTime? to, int limit);
/// <summary>ALLE Sätze des Scopes bis <paramref name="to"/> (für die Abrechnung inkl. Anfangssaldo).</summary>
List<LedgerEntry> GetUpTo(int? accountId, DateTime to);
}
public interface IIngestRunRepository
@@ -29,6 +31,13 @@ namespace PolyTrader.Modules.Accounting.Persistence
void Insert(RawSnapshot snapshot);
}
/// <summary>Amtliche FX-Tageskurse (USD→EUR), versioniert. Upsert je Datum.</summary>
public interface IFxRateRepository
{
void Upsert(FxRate rate);
List<FxRate> GetAll();
}
// ---------------- EF-Implementierungen ----------------
public class EfLedgerRepository : ILedgerRepository
@@ -77,6 +86,14 @@ namespace PolyTrader.Modules.Accounting.Persistence
if (to.HasValue) q = q.Where(x => x.Timestamp <= to.Value);
return q.OrderByDescending(x => x.Timestamp).Take(limit).ToList();
}
public List<LedgerEntry> GetUpTo(int? accountId, DateTime to)
{
using var ctx = _factory.CreateDbContext();
var q = ctx.Ledger.AsNoTracking().Where(x => x.Timestamp <= to);
if (accountId.HasValue) q = q.Where(x => x.AccountId == accountId.Value);
return q.OrderBy(x => x.Timestamp).ToList();
}
}
public class EfIngestRunRepository : IIngestRunRepository
@@ -119,4 +136,25 @@ namespace PolyTrader.Modules.Accounting.Persistence
ctx.SaveChanges();
}
}
public class EfFxRateRepository : IFxRateRepository
{
private readonly IDbContextFactory<AccountingDbContext> _factory;
public EfFxRateRepository(IDbContextFactory<AccountingDbContext> factory) => _factory = factory;
public void Upsert(FxRate rate)
{
using var ctx = _factory.CreateDbContext();
var existing = ctx.FxRates.Find(rate.Date.Date);
if (existing == null) ctx.FxRates.Add(new FxRate { Date = rate.Date.Date, UsdToEur = rate.UsdToEur, Source = rate.Source });
else { existing.UsdToEur = rate.UsdToEur; existing.Source = rate.Source; }
ctx.SaveChanges();
}
public List<FxRate> GetAll()
{
using var ctx = _factory.CreateDbContext();
return ctx.FxRates.AsNoTracking().OrderBy(x => x.Date).ToList();
}
}
}
@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using PolyTrader.Modules.Accounting.Logic;
using PolyTrader.Modules.Accounting.Models;
using PolyTrader.Modules.Accounting.Persistence;
namespace PolyTrader.Modules.Accounting.Services
{
/// <summary>
/// Anzeige-Währung einer Abrechnung. USDC/USD sind immer verfügbar (USD über die dokumentierte
/// 1:1-Annahme), EUR nur, wenn ein amtlicher Tageskurs geladen ist.
/// </summary>
public sealed record CurrencyContext(string Code, decimal Factor, bool Available, string Note);
/// <summary>
/// Report-Service (A-2): baut neutrale Periodenabrechnungen aus dem Ledger und stellt die
/// Währungs-Umrechnung (USDC/USD/EUR) bereit. Reine Aggregation liegt im <see cref="AccountingEngine"/>,
/// FX im <see cref="FxConverter"/> der Service verdrahtet nur Persistenz + pure Logik.
/// </summary>
public class AccountingReportService
{
private readonly ILedgerRepository _ledger;
private readonly IFxRateRepository _fx;
/// <summary>USDC→USD-Faktor (dokumentierte 1:1-Annahme; Zielland ggf. über Settings umstellbar).</summary>
public decimal UsdcToUsd { get; set; } = FxConverter.DefaultUsdcToUsd;
public AccountingReportService(ILedgerRepository ledger, IFxRateRepository fx)
{
_ledger = ledger;
_fx = fx;
}
public PeriodStatement BuildStatement(int? accountId, DateTime from, DateTime to) =>
AccountingEngine.BuildStatement(_ledger.GetUpTo(accountId, to), from, to, accountId);
public List<PeriodStatement> BuildMonthly(int? accountId, DateTime from, DateTime to) =>
AccountingEngine.BuildMonthlyBreakdown(_ledger.GetUpTo(accountId, to), from, to, accountId);
/// <summary>Löst die Anzeige-Währung zum Stichtag auf (für EUR wird der EZB-Kurs am/vor dem Datum genutzt).</summary>
public CurrencyContext ResolveCurrency(string code, DateTime asOf)
{
switch ((code ?? "USDC").Trim().ToUpperInvariant())
{
case "USD":
return new CurrencyContext("USD", UsdcToUsd, true, $"USDC≈USD (1:1-Annahme, Faktor {UsdcToUsd}).");
case "EUR":
var rate = FxConverter.NearestOnOrBefore(_fx.GetAll(), asOf);
if (rate == null)
return new CurrencyContext("EUR", 0m, false, "Keine EZB-Tageskurse geladen EUR nicht verfügbar.");
return new CurrencyContext("EUR", UsdcToUsd * rate.UsdToEur, true,
$"USD→EUR {rate.UsdToEur} ({rate.Date:yyyy-MM-dd}, {rate.Source}); USDC≈USD 1:1.");
default:
return new CurrencyContext("USDC", 1.0m, true, "Native Buchungswährung.");
}
}
public static decimal Convert(decimal usdc, CurrencyContext ctx) =>
Math.Round(usdc * ctx.Factor, 2, MidpointRounding.AwayFromZero);
}
}
@@ -18,6 +18,28 @@ namespace PolyTrader.Modules.Accounting.Ui
private void InitializeComponent()
{
this.tabControlAcc = new System.Windows.Forms.TabControl();
this.tabUebersicht = new System.Windows.Forms.TabPage();
this.dgvMonthly = new System.Windows.Forms.DataGridView();
this.flpKpis = new System.Windows.Forms.FlowLayoutPanel();
this.lblKpiNet = new System.Windows.Forms.Label();
this.lblKpiClosing = new System.Windows.Forms.Label();
this.lblKpiDeposits = new System.Windows.Forms.Label();
this.lblKpiWithdrawals = new System.Windows.Forms.Label();
this.lblKpiFees = new System.Windows.Forms.Label();
this.lblKpiRewards = new System.Windows.Forms.Label();
this.lblKpiVolume = new System.Windows.Forms.Label();
this.lblKpiTrades = new System.Windows.Forms.Label();
this.pnlOverviewTop = new System.Windows.Forms.Panel();
this.btnExportCsv = new System.Windows.Forms.Button();
this.btnCalc = new System.Windows.Forms.Button();
this.cbCurrency = new System.Windows.Forms.ComboBox();
this.lblWaehrung = new System.Windows.Forms.Label();
this.dtTo = new System.Windows.Forms.DateTimePicker();
this.lblBis = new System.Windows.Forms.Label();
this.dtFrom = new System.Windows.Forms.DateTimePicker();
this.lblVon = new System.Windows.Forms.Label();
this.cbOvAccount = new System.Windows.Forms.ComboBox();
this.lblOvKonto = new System.Windows.Forms.Label();
this.tabLedger = new System.Windows.Forms.TabPage();
this.dgvLedger = new System.Windows.Forms.DataGridView();
this.toolStripLedger = new System.Windows.Forms.ToolStrip();
@@ -37,6 +59,10 @@ namespace PolyTrader.Modules.Accounting.Ui
this.btnStatusRefresh = new System.Windows.Forms.ToolStripButton();
this.lblAccStatus = new System.Windows.Forms.Label();
this.tabControlAcc.SuspendLayout();
this.tabUebersicht.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.dgvMonthly)).BeginInit();
this.flpKpis.SuspendLayout();
this.pnlOverviewTop.SuspendLayout();
this.tabLedger.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.dgvLedger)).BeginInit();
this.toolStripLedger.SuspendLayout();
@@ -47,6 +73,7 @@ namespace PolyTrader.Modules.Accounting.Ui
//
// tabControlAcc
//
this.tabControlAcc.Controls.Add(this.tabUebersicht);
this.tabControlAcc.Controls.Add(this.tabLedger);
this.tabControlAcc.Controls.Add(this.tabStatus);
this.tabControlAcc.Dock = System.Windows.Forms.DockStyle.Fill;
@@ -56,6 +83,259 @@ namespace PolyTrader.Modules.Accounting.Ui
this.tabControlAcc.Size = new System.Drawing.Size(1100, 618);
this.tabControlAcc.TabIndex = 0;
//
// tabUebersicht
//
this.tabUebersicht.Controls.Add(this.dgvMonthly);
this.tabUebersicht.Controls.Add(this.flpKpis);
this.tabUebersicht.Controls.Add(this.pnlOverviewTop);
this.tabUebersicht.Location = new System.Drawing.Point(4, 24);
this.tabUebersicht.Name = "tabUebersicht";
this.tabUebersicht.Padding = new System.Windows.Forms.Padding(3);
this.tabUebersicht.Size = new System.Drawing.Size(1092, 590);
this.tabUebersicht.TabIndex = 0;
this.tabUebersicht.Text = "Übersicht / BWA";
this.tabUebersicht.UseVisualStyleBackColor = true;
//
// dgvMonthly
//
this.dgvMonthly.AllowUserToAddRows = false;
this.dgvMonthly.AllowUserToDeleteRows = false;
this.dgvMonthly.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.dgvMonthly.Dock = System.Windows.Forms.DockStyle.Fill;
this.dgvMonthly.Location = new System.Drawing.Point(3, 133);
this.dgvMonthly.Name = "dgvMonthly";
this.dgvMonthly.ReadOnly = true;
this.dgvMonthly.RowHeadersVisible = false;
this.dgvMonthly.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
this.dgvMonthly.Size = new System.Drawing.Size(1086, 454);
this.dgvMonthly.TabIndex = 2;
//
// flpKpis
//
this.flpKpis.Controls.Add(this.lblKpiNet);
this.flpKpis.Controls.Add(this.lblKpiClosing);
this.flpKpis.Controls.Add(this.lblKpiDeposits);
this.flpKpis.Controls.Add(this.lblKpiWithdrawals);
this.flpKpis.Controls.Add(this.lblKpiFees);
this.flpKpis.Controls.Add(this.lblKpiRewards);
this.flpKpis.Controls.Add(this.lblKpiVolume);
this.flpKpis.Controls.Add(this.lblKpiTrades);
this.flpKpis.Dock = System.Windows.Forms.DockStyle.Top;
this.flpKpis.Location = new System.Drawing.Point(3, 39);
this.flpKpis.Name = "flpKpis";
this.flpKpis.Padding = new System.Windows.Forms.Padding(4);
this.flpKpis.Size = new System.Drawing.Size(1086, 94);
this.flpKpis.TabIndex = 1;
//
// lblKpiNet
//
this.lblKpiNet.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.lblKpiNet.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Bold);
this.lblKpiNet.Margin = new System.Windows.Forms.Padding(4);
this.lblKpiNet.MinimumSize = new System.Drawing.Size(200, 74);
this.lblKpiNet.Name = "lblKpiNet";
this.lblKpiNet.Padding = new System.Windows.Forms.Padding(8);
this.lblKpiNet.Size = new System.Drawing.Size(200, 74);
this.lblKpiNet.TabIndex = 0;
this.lblKpiNet.Text = "Netto-Handelsergebnis\n—";
this.lblKpiNet.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// lblKpiClosing
//
this.lblKpiClosing.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.lblKpiClosing.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Bold);
this.lblKpiClosing.Margin = new System.Windows.Forms.Padding(4);
this.lblKpiClosing.MinimumSize = new System.Drawing.Size(180, 74);
this.lblKpiClosing.Name = "lblKpiClosing";
this.lblKpiClosing.Padding = new System.Windows.Forms.Padding(8);
this.lblKpiClosing.Size = new System.Drawing.Size(180, 74);
this.lblKpiClosing.TabIndex = 1;
this.lblKpiClosing.Text = "Endsaldo\n—";
this.lblKpiClosing.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// lblKpiDeposits
//
this.lblKpiDeposits.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.lblKpiDeposits.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Bold);
this.lblKpiDeposits.Margin = new System.Windows.Forms.Padding(4);
this.lblKpiDeposits.MinimumSize = new System.Drawing.Size(150, 74);
this.lblKpiDeposits.Name = "lblKpiDeposits";
this.lblKpiDeposits.Padding = new System.Windows.Forms.Padding(8);
this.lblKpiDeposits.Size = new System.Drawing.Size(150, 74);
this.lblKpiDeposits.TabIndex = 2;
this.lblKpiDeposits.Text = "Einzahlungen\n—";
this.lblKpiDeposits.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// lblKpiWithdrawals
//
this.lblKpiWithdrawals.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.lblKpiWithdrawals.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Bold);
this.lblKpiWithdrawals.Margin = new System.Windows.Forms.Padding(4);
this.lblKpiWithdrawals.MinimumSize = new System.Drawing.Size(150, 74);
this.lblKpiWithdrawals.Name = "lblKpiWithdrawals";
this.lblKpiWithdrawals.Padding = new System.Windows.Forms.Padding(8);
this.lblKpiWithdrawals.Size = new System.Drawing.Size(150, 74);
this.lblKpiWithdrawals.TabIndex = 3;
this.lblKpiWithdrawals.Text = "Auszahlungen\n—";
this.lblKpiWithdrawals.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// lblKpiFees
//
this.lblKpiFees.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.lblKpiFees.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Bold);
this.lblKpiFees.Margin = new System.Windows.Forms.Padding(4);
this.lblKpiFees.MinimumSize = new System.Drawing.Size(140, 74);
this.lblKpiFees.Name = "lblKpiFees";
this.lblKpiFees.Padding = new System.Windows.Forms.Padding(8);
this.lblKpiFees.Size = new System.Drawing.Size(140, 74);
this.lblKpiFees.TabIndex = 4;
this.lblKpiFees.Text = "Fees\n—";
this.lblKpiFees.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// lblKpiRewards
//
this.lblKpiRewards.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.lblKpiRewards.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Bold);
this.lblKpiRewards.Margin = new System.Windows.Forms.Padding(4);
this.lblKpiRewards.MinimumSize = new System.Drawing.Size(140, 74);
this.lblKpiRewards.Name = "lblKpiRewards";
this.lblKpiRewards.Padding = new System.Windows.Forms.Padding(8);
this.lblKpiRewards.Size = new System.Drawing.Size(140, 74);
this.lblKpiRewards.TabIndex = 5;
this.lblKpiRewards.Text = "Rewards\n—";
this.lblKpiRewards.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// lblKpiVolume
//
this.lblKpiVolume.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.lblKpiVolume.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Bold);
this.lblKpiVolume.Margin = new System.Windows.Forms.Padding(4);
this.lblKpiVolume.MinimumSize = new System.Drawing.Size(160, 74);
this.lblKpiVolume.Name = "lblKpiVolume";
this.lblKpiVolume.Padding = new System.Windows.Forms.Padding(8);
this.lblKpiVolume.Size = new System.Drawing.Size(160, 74);
this.lblKpiVolume.TabIndex = 6;
this.lblKpiVolume.Text = "Handelsvolumen\n—";
this.lblKpiVolume.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// lblKpiTrades
//
this.lblKpiTrades.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.lblKpiTrades.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Bold);
this.lblKpiTrades.Margin = new System.Windows.Forms.Padding(4);
this.lblKpiTrades.MinimumSize = new System.Drawing.Size(120, 74);
this.lblKpiTrades.Name = "lblKpiTrades";
this.lblKpiTrades.Padding = new System.Windows.Forms.Padding(8);
this.lblKpiTrades.Size = new System.Drawing.Size(120, 74);
this.lblKpiTrades.TabIndex = 7;
this.lblKpiTrades.Text = "Trades\n—";
this.lblKpiTrades.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// pnlOverviewTop
//
this.pnlOverviewTop.Controls.Add(this.btnExportCsv);
this.pnlOverviewTop.Controls.Add(this.btnCalc);
this.pnlOverviewTop.Controls.Add(this.cbCurrency);
this.pnlOverviewTop.Controls.Add(this.lblWaehrung);
this.pnlOverviewTop.Controls.Add(this.dtTo);
this.pnlOverviewTop.Controls.Add(this.lblBis);
this.pnlOverviewTop.Controls.Add(this.dtFrom);
this.pnlOverviewTop.Controls.Add(this.lblVon);
this.pnlOverviewTop.Controls.Add(this.cbOvAccount);
this.pnlOverviewTop.Controls.Add(this.lblOvKonto);
this.pnlOverviewTop.Dock = System.Windows.Forms.DockStyle.Top;
this.pnlOverviewTop.Location = new System.Drawing.Point(3, 3);
this.pnlOverviewTop.Name = "pnlOverviewTop";
this.pnlOverviewTop.Size = new System.Drawing.Size(1086, 36);
this.pnlOverviewTop.TabIndex = 0;
//
// btnExportCsv
//
this.btnExportCsv.Location = new System.Drawing.Point(838, 5);
this.btnExportCsv.Name = "btnExportCsv";
this.btnExportCsv.Size = new System.Drawing.Size(110, 26);
this.btnExportCsv.TabIndex = 9;
this.btnExportCsv.Text = "CSV-Export";
this.btnExportCsv.UseVisualStyleBackColor = true;
//
// btnCalc
//
this.btnCalc.Location = new System.Drawing.Point(732, 5);
this.btnCalc.Name = "btnCalc";
this.btnCalc.Size = new System.Drawing.Size(100, 26);
this.btnCalc.TabIndex = 8;
this.btnCalc.Text = "Berechnen";
this.btnCalc.UseVisualStyleBackColor = true;
//
// cbCurrency
//
this.cbCurrency.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
this.cbCurrency.Items.AddRange(new object[] { "USDC", "USD", "EUR" });
this.cbCurrency.Location = new System.Drawing.Point(645, 6);
this.cbCurrency.Name = "cbCurrency";
this.cbCurrency.Size = new System.Drawing.Size(80, 23);
this.cbCurrency.TabIndex = 7;
//
// lblWaehrung
//
this.lblWaehrung.AutoSize = true;
this.lblWaehrung.Location = new System.Drawing.Point(575, 9);
this.lblWaehrung.Name = "lblWaehrung";
this.lblWaehrung.Size = new System.Drawing.Size(64, 15);
this.lblWaehrung.TabIndex = 6;
this.lblWaehrung.Text = "Währung:";
//
// dtTo
//
this.dtTo.Format = System.Windows.Forms.DateTimePickerFormat.Short;
this.dtTo.Location = new System.Drawing.Point(445, 6);
this.dtTo.Name = "dtTo";
this.dtTo.Size = new System.Drawing.Size(110, 23);
this.dtTo.TabIndex = 5;
//
// lblBis
//
this.lblBis.AutoSize = true;
this.lblBis.Location = new System.Drawing.Point(415, 9);
this.lblBis.Name = "lblBis";
this.lblBis.Size = new System.Drawing.Size(26, 15);
this.lblBis.TabIndex = 4;
this.lblBis.Text = "Bis:";
//
// dtFrom
//
this.dtFrom.Format = System.Windows.Forms.DateTimePickerFormat.Short;
this.dtFrom.Location = new System.Drawing.Point(300, 6);
this.dtFrom.Name = "dtFrom";
this.dtFrom.Size = new System.Drawing.Size(110, 23);
this.dtFrom.TabIndex = 3;
//
// lblVon
//
this.lblVon.AutoSize = true;
this.lblVon.Location = new System.Drawing.Point(267, 9);
this.lblVon.Name = "lblVon";
this.lblVon.Size = new System.Drawing.Size(31, 15);
this.lblVon.TabIndex = 2;
this.lblVon.Text = "Von:";
//
// cbOvAccount
//
this.cbOvAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
this.cbOvAccount.Location = new System.Drawing.Point(55, 6);
this.cbOvAccount.Name = "cbOvAccount";
this.cbOvAccount.Size = new System.Drawing.Size(200, 23);
this.cbOvAccount.TabIndex = 1;
//
// lblOvKonto
//
this.lblOvKonto.AutoSize = true;
this.lblOvKonto.Location = new System.Drawing.Point(6, 9);
this.lblOvKonto.Name = "lblOvKonto";
this.lblOvKonto.Size = new System.Drawing.Size(43, 15);
this.lblOvKonto.TabIndex = 0;
this.lblOvKonto.Text = "Konto:";
//
// tabLedger
//
this.tabLedger.Controls.Add(this.dgvLedger);
@@ -64,7 +344,7 @@ namespace PolyTrader.Modules.Accounting.Ui
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.TabIndex = 1;
this.tabLedger.Text = "Ledger";
this.tabLedger.UseVisualStyleBackColor = true;
//
@@ -120,7 +400,7 @@ namespace PolyTrader.Modules.Accounting.Ui
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.TabIndex = 2;
this.tabStatus.Text = "Abruf / Status";
this.tabStatus.UseVisualStyleBackColor = true;
//
@@ -207,6 +487,11 @@ namespace PolyTrader.Modules.Accounting.Ui
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "Accounting";
this.tabControlAcc.ResumeLayout(false);
this.tabUebersicht.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)(this.dgvMonthly)).EndInit();
this.flpKpis.ResumeLayout(false);
this.pnlOverviewTop.ResumeLayout(false);
this.pnlOverviewTop.PerformLayout();
this.tabLedger.ResumeLayout(false);
this.tabLedger.PerformLayout();
((System.ComponentModel.ISupportInitialize)(this.dgvLedger)).EndInit();
@@ -223,6 +508,28 @@ namespace PolyTrader.Modules.Accounting.Ui
#endregion
private System.Windows.Forms.TabControl tabControlAcc;
private System.Windows.Forms.TabPage tabUebersicht;
private System.Windows.Forms.Panel pnlOverviewTop;
private System.Windows.Forms.Label lblOvKonto;
private System.Windows.Forms.ComboBox cbOvAccount;
private System.Windows.Forms.Label lblVon;
private System.Windows.Forms.DateTimePicker dtFrom;
private System.Windows.Forms.Label lblBis;
private System.Windows.Forms.DateTimePicker dtTo;
private System.Windows.Forms.Label lblWaehrung;
private System.Windows.Forms.ComboBox cbCurrency;
private System.Windows.Forms.Button btnCalc;
private System.Windows.Forms.Button btnExportCsv;
private System.Windows.Forms.FlowLayoutPanel flpKpis;
private System.Windows.Forms.Label lblKpiNet;
private System.Windows.Forms.Label lblKpiClosing;
private System.Windows.Forms.Label lblKpiDeposits;
private System.Windows.Forms.Label lblKpiWithdrawals;
private System.Windows.Forms.Label lblKpiFees;
private System.Windows.Forms.Label lblKpiRewards;
private System.Windows.Forms.Label lblKpiVolume;
private System.Windows.Forms.Label lblKpiTrades;
private System.Windows.Forms.DataGridView dgvMonthly;
private System.Windows.Forms.TabPage tabLedger;
private System.Windows.Forms.ToolStrip toolStripLedger;
private System.Windows.Forms.ToolStripLabel lblLedgerKonto;
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Windows.Forms;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Modules.Accounting.Logic;
using PolyTrader.Modules.Accounting.Persistence;
using PolyTrader.Modules.Accounting.Services;
using PolyTraderSharp;
@@ -11,21 +12,31 @@ 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.
/// Hauptfenster des Accounting-Moduls: Tab „Übersicht / BWA" (neutrale Periodenabrechnung + KPIs +
/// Monatsvergleich, Währung USDC/USD/EUR, CSV-Export), „Ledger" (Buchungssätze) und „Abruf / Status"
/// (Ingest-Läufe, manueller Backfill/Inkrement). Layout im Designer; Aggregation pur im
/// <see cref="AccountingEngine"/>. Die US-Steuerschicht (A-3) ist bewusst NICHT hier sie hängt an
/// den CPA-Antworten.
/// </summary>
public partial class AccountingMainForm : Form
{
private ILedgerRepository? _ledger;
private IIngestRunRepository? _runs;
private AccountingIngestService? _ingest;
private AccountingReportService? _report;
private TradingState? _state;
public AccountingMainForm()
{
InitializeComponent();
// Übersicht / BWA
btnCalc.Click += (_, _) => Recalculate();
btnExportCsv.Click += (_, _) => ExportCsv();
cbOvAccount.SelectedIndexChanged += (_, _) => Recalculate();
cbCurrency.SelectedIndexChanged += (_, _) => Recalculate();
// Ledger + Status
btnLedgerRefresh.Click += (_, _) => LoadLedger();
cbLedgerAccount.SelectedIndexChanged += (_, _) => LoadLedger();
btnStatusRefresh.Click += (_, _) => LoadRuns();
@@ -38,9 +49,17 @@ namespace PolyTrader.Modules.Accounting.Ui
_ledger = services.GetRequiredService<ILedgerRepository>();
_runs = services.GetRequiredService<IIngestRunRepository>();
_ingest = services.GetRequiredService<AccountingIngestService>();
_report = services.GetRequiredService<AccountingReportService>();
_state = services.GetRequiredService<TradingState>();
// Standard-Zeitraum: laufender Monat.
var now = DateTime.Now;
dtFrom.Value = new DateTime(now.Year, now.Month, 1);
dtTo.Value = now;
if (cbCurrency.Items.Count > 0) cbCurrency.SelectedIndex = 0; // USDC
PopulateAccounts();
Recalculate();
LoadLedger();
LoadRuns();
}
@@ -56,6 +75,8 @@ namespace PolyTrader.Modules.Accounting.Ui
.OrderBy(a => a.AccountId)
.Select(a => new AccountItem(a.AccountId, string.IsNullOrEmpty(a.Name) ? $"#{a.AccountId}" : $"{a.Name} (#{a.AccountId})")));
cbOvAccount.DisplayMember = nameof(AccountItem.Label);
cbOvAccount.DataSource = new List<AccountItem>(items);
foreach (var combo in new[] { cbLedgerAccount, cbStatusAccount })
{
combo.ComboBox.DisplayMember = nameof(AccountItem.Label);
@@ -63,8 +84,80 @@ namespace PolyTrader.Modules.Accounting.Ui
}
}
private int? SelectedAccountId(ToolStripComboBox combo) =>
combo.SelectedItem is AccountItem it ? it.Id : null;
private static int? IdOf(object? item) => item is AccountItem it ? it.Id : null;
// ---------------- Übersicht / BWA ----------------
private void Recalculate()
{
if (_report == null) return;
try
{
DateTime from = dtFrom.Value.Date;
DateTime to = dtTo.Value.Date.AddDays(1).AddTicks(-1); // inklusive gewählter Bis-Tag
int? accId = IdOf(cbOvAccount.SelectedItem);
var statement = _report.BuildStatement(accId, from, to);
var monthly = _report.BuildMonthly(accId, from, to);
var cur = _report.ResolveCurrency(cbCurrency.SelectedItem?.ToString() ?? "USDC", to);
UpdateKpis(statement, cur);
dgvMonthly.DataSource = monthly.Select(m => new MonthlyRow(m, cur)).ToList();
lblAccStatus.Text = cur.Available
? $"Währung {cur.Code}: {cur.Note}"
: $"⚠️ {cur.Note} (Anzeige in USDC).";
}
catch (Exception ex)
{
lblAccStatus.Text = $"Abrechnung nicht möglich (acc_-Migration angewendet?): {ex.Message}";
}
}
private void UpdateKpis(PeriodStatement s, CurrencyContext cur)
{
CurrencyContext view = cur.Available ? cur : new CurrencyContext("USDC", 1m, true, "");
string unit = view.Code;
decimal V(decimal usdc) => AccountingReportService.Convert(usdc, view);
lblKpiNet.Text = $"Netto-Handelsergebnis\n{V(s.NetTradingResultUsdc):N2} {unit}";
lblKpiNet.ForeColor = s.NetTradingResultUsdc >= 0 ? System.Drawing.Color.ForestGreen : System.Drawing.Color.Firebrick;
lblKpiClosing.Text = $"Endsaldo\n{V(s.ClosingBalanceUsdc):N2} {unit}";
lblKpiDeposits.Text = $"Einzahlungen\n{V(s.Deposits):N2} {unit}";
lblKpiWithdrawals.Text = $"Auszahlungen\n{V(s.Withdrawals):N2} {unit}";
lblKpiFees.Text = $"Fees\n{V(s.Fees):N2} {unit}";
lblKpiRewards.Text = $"Rewards\n{V(s.Rewards):N2} {unit}";
lblKpiVolume.Text = $"Handelsvolumen\n{V(s.TradeVolume):N2} {unit}";
lblKpiTrades.Text = $"Trades\n{s.TradeCount}";
}
private void ExportCsv()
{
if (_ledger == null || _report == null) return;
DateTime from = dtFrom.Value.Date;
DateTime to = dtTo.Value.Date.AddDays(1).AddTicks(-1);
int? accId = IdOf(cbOvAccount.SelectedItem);
using var dlg = new SaveFileDialog
{
Filter = "CSV-Datei (*.csv)|*.csv",
FileName = $"accounting_{(accId?.ToString() ?? "alle")}_{from:yyyyMMdd}-{dtTo.Value:yyyyMMdd}.csv"
};
if (dlg.ShowDialog(this) != DialogResult.OK) return;
try
{
var statement = _report.BuildStatement(accId, from, to);
var entries = _ledger.Query(accId, from, to, 100000);
string csv = CsvExporter.Statement(statement) + Environment.NewLine + CsvExporter.Ledger(entries);
System.IO.File.WriteAllText(dlg.FileName, csv, new System.Text.UTF8Encoding(true));
lblAccStatus.Text = $"CSV exportiert: {dlg.FileName} ({entries.Count} Buchungen).";
}
catch (Exception ex)
{
lblAccStatus.Text = $"CSV-Export fehlgeschlagen: {ex.Message}";
}
}
// ---------------- Ledger ----------------
@@ -73,7 +166,7 @@ namespace PolyTrader.Modules.Accounting.Ui
if (_ledger == null) return;
try
{
var rows = _ledger.Query(SelectedAccountId(cbLedgerAccount), null, null, 1000);
var rows = _ledger.Query(IdOf(cbLedgerAccount.SelectedItem), 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.)"
@@ -92,7 +185,7 @@ namespace PolyTrader.Modules.Accounting.Ui
if (_runs == null) return;
try
{
dgvRuns.DataSource = _runs.GetRecent(SelectedAccountId(cbStatusAccount), 100);
dgvRuns.DataSource = _runs.GetRecent(IdOf(cbStatusAccount.SelectedItem), 100);
}
catch (Exception ex)
{
@@ -112,6 +205,7 @@ namespace PolyTrader.Modules.Accounting.Ui
lblAccStatus.Text = "Abruf abgeschlossen.";
LoadRuns();
LoadLedger();
Recalculate();
}
catch (Exception ex)
{
@@ -126,7 +220,38 @@ namespace PolyTrader.Modules.Accounting.Ui
private sealed record AccountItem(int? Id, string Label);
/// <summary>Anzeige-Zeile fürs Ledger-Grid (kompakte, lesbare Spalten).</summary>
/// <summary>Anzeige-Zeile des Monatsvergleichs (Beträge in der gewählten Währung).</summary>
private sealed class MonthlyRow
{
public MonthlyRow(PeriodStatement m, CurrencyContext cur)
{
CurrencyContext view = cur.Available ? cur : new CurrencyContext("USDC", 1m, true, "");
decimal V(decimal usdc) => AccountingReportService.Convert(usdc, view);
Monat = m.From.ToString("yyyy-MM");
Anfangssaldo = V(m.OpeningBalanceUsdc);
Einzahlungen = V(m.Deposits);
Auszahlungen = V(m.Withdrawals);
Handelsvolumen = V(m.TradeVolume);
Rewards = V(m.Rewards);
Fees = V(m.Fees);
Handelsergebnis = V(m.NetTradingResultUsdc);
Endsaldo = V(m.ClosingBalanceUsdc);
Trades = m.TradeCount;
}
public string Monat { get; }
public decimal Anfangssaldo { get; }
public decimal Einzahlungen { get; }
public decimal Auszahlungen { get; }
public decimal Handelsvolumen { get; }
public decimal Rewards { get; }
public decimal Fees { get; }
public decimal Handelsergebnis { get; }
public decimal Endsaldo { get; }
public int Trades { get; }
}
/// <summary>Anzeige-Zeile fürs Ledger-Grid.</summary>
private sealed class LedgerRow
{
public LedgerRow(Models.LedgerEntry e)