diff --git a/docs/konzepte/KONZEPT-Modul-Accounting.md b/docs/konzepte/KONZEPT-Modul-Accounting.md index 159721f..c7570eb 100644 --- a/docs/konzepte/KONZEPT-Modul-Accounting.md +++ b/docs/konzepte/KONZEPT-Modul-Accounting.md @@ -214,6 +214,14 @@ Sync-/Buchungsfehler unserer Trading-Seite. Bericht in der UI + Export. - **A-2 Abrechnung + Übersicht + FX:** `AccountingEngine` + BWA-UI + Periodenabrechnung je Account/alle, Werte in USDC/USD/EUR (Tageskurse in `acc_fx_rates`). **Akzeptanz:** Monatsabrechnung stimmt gegen Balance-Anker; Kennzahlen plausibel; FX nachvollziehbar. + **✅ UMGESETZT (2026-07-20):** `AccountingEngine` (pur): `BuildStatement` (Anfangs-/Endsaldo, + Ein-/Auszahlungen, Handelsvolumen, Redeems, Rewards, Fees, Netto-Handelsergebnis Cash-Basis exkl. + Ein-/Auszahlungen; Invariante Endsaldo−Anfang = Ergebnis+Einz.−Ausz.) + `BuildMonthlyBreakdown` + (verkettete Monats-Anfangssalden). `FxConverter` (pur, USDC≈USD-1:1-Annahme dokumentiert; USD→EUR + über `acc_fx_rates`, Nearest-on-or-before). `CsvExporter` (pur, RFC-4180, kulturinvariant). + `AccountingReportService` (Abrechnung + Währungs-View USDC/USD/EUR). UI-Tab „Übersicht/BWA" + (designerfähig): KPI-Kacheln + Monatsvergleich + Zeitraum-/Konto-/Währungswahl + CSV-Export. +6 Tests. + **Offen für Zielland:** EZB-Kurs-Ingest (acc_fx_rates füllen) → dann ist EUR verfügbar (USDC/USD sofort). - **A-3 US-Steuerschicht:** `UsTaxEngine` (FIFO-Lot-Matching, Haltefristen, Gain/Loss) + Form-8949-/ Schedule-D-Ansicht + Annahmen-Dokumentation. **Akzeptanz:** Summe realisierter Gain/Loss stimmt gegen die neutrale Abrechnung; Annahmen ausgewiesen. diff --git a/src/PolyTrader.Modules.Accounting/AccountingModule.cs b/src/PolyTrader.Modules.Accounting/AccountingModule.cs index 6f9c032..c5360bb 100644 --- a/src/PolyTrader.Modules.Accounting/AccountingModule.cs +++ b/src/PolyTrader.Modules.Accounting/AccountingModule.cs @@ -32,6 +32,10 @@ namespace PolyTrader.Modules.Accounting services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + + // A-2: neutrale Periodenabrechnung/BWA + Währungs-View (USDC/USD/EUR). + services.AddSingleton(); // Ingest-Quellen: offline Null-Stubs (das Modul läuft ohne Live-Anbindung und bucht korrekt // nichts). Im Zielland werden die echten Quellen (Polymarket /activity, Alchemy-Transfers, diff --git a/src/PolyTrader.Modules.Accounting/Logic/AccountingEngine.cs b/src/PolyTrader.Modules.Accounting/Logic/AccountingEngine.cs new file mode 100644 index 0000000..f2d06f1 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Logic/AccountingEngine.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PolyTrader.Modules.Accounting.Models; + +namespace PolyTrader.Modules.Accounting.Logic +{ + /// + /// 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 . + /// + 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) + { + /// Invariante: Endsaldo − Anfangssaldo = Handelsergebnis + Einzahlungen − Auszahlungen. + public decimal BalanceChange => ClosingBalanceUsdc - OpeningBalanceUsdc; + } + + public static class AccountingEngine + { + private static bool IsCashflowType(LedgerEventType t) => + t is LedgerEventType.Deposit or LedgerEventType.Withdrawal; + + /// + /// Baut die Abrechnung für [, ]. + /// enthält ALLE Ledger-Sätze des Scopes bis (für den Anfangssaldo werden die + /// Sätze vor kumuliert). Grenzen inklusive. + /// + public static PeriodStatement BuildStatement( + IEnumerable 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 pred, Func 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); + } + + /// + /// Zerlegt den Zeitraum in Kalendermonate und liefert je Monat eine Abrechnung (für den + /// BWA-Perioden-/Monatsvergleich). Anfangssaldo jedes Monats = Endsaldo des Vormonats. + /// + public static List BuildMonthlyBreakdown( + IEnumerable allUpToTo, DateTime from, DateTime to, int? accountId) + { + var list = allUpToTo.ToList(); + var result = new List(); + 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; + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Logic/CsvExporter.cs b/src/PolyTrader.Modules.Accounting/Logic/CsvExporter.cs new file mode 100644 index 0000000..326257d --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Logic/CsvExporter.cs @@ -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 +{ + /// + /// 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. + /// + 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; + } + + /// Vollständiger Ledger-Export (eine Zeile je Buchungssatz, inkl. Nachweisspalten). + public static string Ledger(IEnumerable 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(); + } + + /// Aggregat-Export einer Abrechnung (Kennzahl,USDC) – prüfbare Zusammenfassung. + 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(); + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Logic/FxConverter.cs b/src/PolyTrader.Modules.Accounting/Logic/FxConverter.cs new file mode 100644 index 0000000..026c9cc --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Logic/FxConverter.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PolyTrader.Modules.Accounting.Models; + +namespace PolyTrader.Modules.Accounting.Logic +{ + /// + /// 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. + /// + public static class FxConverter + { + /// Dokumentierte Vereinfachung: 1 USDC = 1 USD (im Export ausgewiesen, in Settings umstellbar). + 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); + + /// + /// 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. + /// + public static FxRate? NearestOnOrBefore(IEnumerable rates, DateTime date) + { + DateTime day = date.Date; + return rates.Where(r => r.Date.Date <= day) + .OrderByDescending(r => r.Date) + .FirstOrDefault(); + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Models/FxRate.cs b/src/PolyTrader.Modules.Accounting/Models/FxRate.cs new file mode 100644 index 0000000..43410e4 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Models/FxRate.cs @@ -0,0 +1,17 @@ +using System; + +namespace PolyTrader.Modules.Accounting.Models +{ + /// + /// 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). + /// + public class FxRate + { + /// Kurs-Datum (nur Datum; PK). + public DateTime Date { get; set; } + public decimal UsdToEur { get; set; } + public string Source { get; set; } = string.Empty; // z. B. "ECB" / "manual" + } +} diff --git a/src/PolyTrader.Modules.Accounting/Persistence/AccountingDbContext.cs b/src/PolyTrader.Modules.Accounting/Persistence/AccountingDbContext.cs index 8c2d479..d9fa4bc 100644 --- a/src/PolyTrader.Modules.Accounting/Persistence/AccountingDbContext.cs +++ b/src/PolyTrader.Modules.Accounting/Persistence/AccountingDbContext.cs @@ -17,6 +17,7 @@ namespace PolyTrader.Modules.Accounting.Persistence public DbSet Ledger => Set(); public DbSet IngestRuns => Set(); public DbSet RawSnapshots => Set(); + public DbSet FxRates => Set(); 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(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); + }); } } diff --git a/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720100910_AddFxRates.Designer.cs b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720100910_AddFxRates.Designer.cs new file mode 100644 index 0000000..98293a1 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720100910_AddFxRates.Designer.cs @@ -0,0 +1,233 @@ +// +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 + { + /// + 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("Date") + .HasColumnType("date"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Backfill") + .HasColumnType("tinyint(1)"); + + b.Property("BalanceAnchorUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("BalanceDeltaUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("DuplicateEntries") + .HasColumnType("int"); + + b.Property("FinishedAt") + .HasColumnType("datetime(6)"); + + b.Property("FromTimestamp") + .HasColumnType("datetime(6)"); + + b.Property("LedgerNetUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("NewEntries") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Success") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "StartedAt"); + + b.ToTable("acc_ingest_runs", (string)null); + }); + + modelBuilder.Entity("PolyTrader.Modules.Accounting.Models.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("FeeUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("GrossUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IngestBatchId") + .HasColumnType("bigint"); + + b.Property("IngestedAt") + .HasColumnType("datetime(6)"); + + b.Property("LogIndex") + .HasColumnType("int"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("NetUsdc") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PriceUsdc") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Size") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("TxHash") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("IdempotencyKey") + .IsUnique(); + + b.HasIndex("AccountId", "Timestamp"); + + b.ToTable("acc_ledger", (string)null); + }); + + modelBuilder.Entity("PolyTrader.Modules.Accounting.Models.RawSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CapturedAt") + .HasColumnType("datetime(6)"); + + b.Property("IngestRunId") + .HasColumnType("bigint"); + + b.Property("Json") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("IngestRunId"); + + b.ToTable("acc_raw", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720100910_AddFxRates.cs b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720100910_AddFxRates.cs new file mode 100644 index 0000000..41fafea --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/20260720100910_AddFxRates.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PolyTrader.Modules.Accounting.Persistence.Migrations +{ + /// + public partial class AddFxRates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "acc_fx_rates", + columns: table => new + { + Date = table.Column(type: "date", nullable: false), + UsdToEur = table.Column(type: "decimal(18,8)", precision: 18, scale: 8, nullable: false), + Source = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "acc_fx_rates"); + } + } +} diff --git a/src/PolyTrader.Modules.Accounting/Persistence/Migrations/AccountingDbContextModelSnapshot.cs b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/AccountingDbContextModelSnapshot.cs index e08b9a2..dfbf1b9 100644 --- a/src/PolyTrader.Modules.Accounting/Persistence/Migrations/AccountingDbContextModelSnapshot.cs +++ b/src/PolyTrader.Modules.Accounting/Persistence/Migrations/AccountingDbContextModelSnapshot.cs @@ -22,6 +22,25 @@ namespace PolyTrader.Modules.Accounting.Persistence.Migrations MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + modelBuilder.Entity("PolyTrader.Modules.Accounting.Models.FxRate", b => + { + b.Property("Date") + .HasColumnType("date"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("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("Id") diff --git a/src/PolyTrader.Modules.Accounting/Persistence/Repositories.cs b/src/PolyTrader.Modules.Accounting/Persistence/Repositories.cs index 9dd6547..5666d55 100644 --- a/src/PolyTrader.Modules.Accounting/Persistence/Repositories.cs +++ b/src/PolyTrader.Modules.Accounting/Persistence/Repositories.cs @@ -15,6 +15,8 @@ namespace PolyTrader.Modules.Accounting.Persistence decimal SumNet(int accountId); int Count(int accountId); List Query(int? accountId, DateTime? from, DateTime? to, int limit); + /// ALLE Sätze des Scopes bis (für die Abrechnung inkl. Anfangssaldo). + List GetUpTo(int? accountId, DateTime to); } public interface IIngestRunRepository @@ -29,6 +31,13 @@ namespace PolyTrader.Modules.Accounting.Persistence void Insert(RawSnapshot snapshot); } + /// Amtliche FX-Tageskurse (USD→EUR), versioniert. Upsert je Datum. + public interface IFxRateRepository + { + void Upsert(FxRate rate); + List 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 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 _factory; + public EfFxRateRepository(IDbContextFactory 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 GetAll() + { + using var ctx = _factory.CreateDbContext(); + return ctx.FxRates.AsNoTracking().OrderBy(x => x.Date).ToList(); + } + } } diff --git a/src/PolyTrader.Modules.Accounting/Services/AccountingReportService.cs b/src/PolyTrader.Modules.Accounting/Services/AccountingReportService.cs new file mode 100644 index 0000000..99b2ec3 --- /dev/null +++ b/src/PolyTrader.Modules.Accounting/Services/AccountingReportService.cs @@ -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 +{ + /// + /// 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. + /// + public sealed record CurrencyContext(string Code, decimal Factor, bool Available, string Note); + + /// + /// Report-Service (A-2): baut neutrale Periodenabrechnungen aus dem Ledger und stellt die + /// Währungs-Umrechnung (USDC/USD/EUR) bereit. Reine Aggregation liegt im , + /// FX im – der Service verdrahtet nur Persistenz + pure Logik. + /// + public class AccountingReportService + { + private readonly ILedgerRepository _ledger; + private readonly IFxRateRepository _fx; + + /// USDC→USD-Faktor (dokumentierte 1:1-Annahme; Zielland ggf. über Settings umstellbar). + 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 BuildMonthly(int? accountId, DateTime from, DateTime to) => + AccountingEngine.BuildMonthlyBreakdown(_ledger.GetUpTo(accountId, to), from, to, accountId); + + /// Löst die Anzeige-Währung zum Stichtag auf (für EUR wird der EZB-Kurs am/vor dem Datum genutzt). + 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); + } +} diff --git a/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs index ec5658e..5b44557 100644 --- a/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs +++ b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.Designer.cs @@ -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; diff --git a/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs index 1823995..b22b641 100644 --- a/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs +++ b/src/PolyTrader.Modules.Accounting/Ui/AccountingMainForm.cs @@ -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 { /// - /// 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 + /// . Die US-Steuerschicht (A-3) ist bewusst NICHT hier – sie hängt an + /// den CPA-Antworten. /// 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(); _runs = services.GetRequiredService(); _ingest = services.GetRequiredService(); + _report = services.GetRequiredService(); _state = services.GetRequiredService(); + // 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(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); - /// Anzeige-Zeile fürs Ledger-Grid (kompakte, lesbare Spalten). + /// Anzeige-Zeile des Monatsvergleichs (Beträge in der gewählten Währung). + 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; } + } + + /// Anzeige-Zeile fürs Ledger-Grid. private sealed class LedgerRow { public LedgerRow(Models.LedgerEntry e) diff --git a/tests/PolyTrader.Tests/AccountingReportTests.cs b/tests/PolyTrader.Tests/AccountingReportTests.cs new file mode 100644 index 0000000..8e699a6 --- /dev/null +++ b/tests/PolyTrader.Tests/AccountingReportTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PolyTrader.Modules.Accounting.Logic; +using PolyTrader.Modules.Accounting.Models; +using Xunit; + +namespace PolyTrader.Tests +{ + /// Sicherheitsnetz für die neutrale Abrechnung (A-2): Periodenaggregation, FX, CSV. + public class AccountingReportTests + { + private static readonly DateTime Jun = new(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Jul = new(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc); + + private static LedgerEntry E(LedgerEventType type, decimal net, DateTime ts, + decimal gross = 0m, decimal fee = 0m) => new() + { + AccountId = 1, EventType = type, Timestamp = ts, NetUsdc = net, + GrossUsdc = gross == 0m ? Math.Abs(net) : gross, FeeUsdc = fee + }; + + // ---------------- AccountingEngine ---------------- + + [Fact] + public void Statement_aggregates_period_and_carries_opening_balance() + { + var entries = new List + { + // VOR dem Zeitraum → nur Anfangssaldo + E(LedgerEventType.Deposit, 1000m, new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), gross: 1000m), + // im Zeitraum (Juli) + E(LedgerEventType.TradeBuy, -100m, Jul, gross: 100m, fee: 1m), + E(LedgerEventType.TradeSell, 130m, Jul, gross: 130m, fee: 1m), + E(LedgerEventType.Reward, 5m, Jul, gross: 5m), + E(LedgerEventType.Deposit, 200m, Jul, gross: 200m), + E(LedgerEventType.Withdrawal, -50m, Jul, gross: 50m), + }; + + var from = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc); + var to = new DateTime(2026, 7, 31, 23, 59, 59, DateTimeKind.Utc); + var s = AccountingEngine.BuildStatement(entries, from, to, 1); + + Assert.Equal(1000m, s.OpeningBalanceUsdc); + Assert.Equal(200m, s.Deposits); + Assert.Equal(50m, s.Withdrawals); + Assert.Equal(230m, s.TradeVolume); // 100 + 130 + Assert.Equal(5m, s.Rewards); + Assert.Equal(2m, s.Fees); + Assert.Equal(35m, s.NetTradingResultUsdc); // -100 +130 +5 (exkl. Ein-/Auszahlungen) + Assert.Equal(2, s.TradeCount); + // Invariante: Endsaldo − Anfangssaldo = Handelsergebnis + Einzahlungen − Auszahlungen + Assert.Equal(1185m, s.ClosingBalanceUsdc); // 1000 + 35 + 200 - 50 + Assert.Equal(s.NetTradingResultUsdc + s.Deposits - s.Withdrawals, s.BalanceChange); + } + + [Fact] + public void Monthly_breakdown_chains_opening_balances() + { + var entries = new List + { + E(LedgerEventType.Deposit, 100m, Jun, gross: 100m), // Juni + E(LedgerEventType.TradeSell, 40m, Jul, gross: 40m), // Juli + }; + var from = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + var to = new DateTime(2026, 7, 31, 23, 59, 59, DateTimeKind.Utc); + + var months = AccountingEngine.BuildMonthlyBreakdown(entries, from, to, 1); + + Assert.Equal(2, months.Count); + Assert.Equal(0m, months[0].OpeningBalanceUsdc); // Juni startet bei 0 + Assert.Equal(100m, months[0].ClosingBalanceUsdc); + Assert.Equal(100m, months[1].OpeningBalanceUsdc); // Juli erbt Juni-Endsaldo + Assert.Equal(140m, months[1].ClosingBalanceUsdc); + } + + // ---------------- FxConverter ---------------- + + [Fact] + public void Fx_conversions_usdc_usd_eur() + { + Assert.Equal(100m, FxConverter.UsdcToUsd(100m, 1.0m)); + Assert.Equal(92m, FxConverter.UsdToEur(100m, 0.92m)); + Assert.Equal(92m, FxConverter.UsdcToEur(100m, 0.92m, 1.0m)); + } + + [Fact] + public void Fx_nearest_on_or_before_picks_latest_valid_rate() + { + var rates = new List + { + new() { Date = new DateTime(2026, 7, 3), UsdToEur = 0.90m }, + new() { Date = new DateTime(2026, 7, 5), UsdToEur = 0.92m }, // Freitag + }; + // Sonntag 2026-07-05 gibt es keinen Kurs → letzter gültiger (Freitag). + var pick = FxConverter.NearestOnOrBefore(rates, new DateTime(2026, 7, 6)); + Assert.NotNull(pick); + Assert.Equal(0.92m, pick!.UsdToEur); + + Assert.Null(FxConverter.NearestOnOrBefore(rates, new DateTime(2026, 7, 1))); // vor allen Kursen + } + + // ---------------- CsvExporter ---------------- + + [Fact] + public void Csv_ledger_has_header_and_escapes_commas() + { + var entries = new List + { + new() { AccountId = 1, EventType = LedgerEventType.TradeBuy, Timestamp = Jul, + MarketSlug = "will-x-win, really?", Side = "BUY", Size = 10m, PriceUsdc = 0.5m, + GrossUsdc = 5m, FeeUsdc = 0.1m, NetUsdc = -5.1m, TxHash = "0xabc", Source = "polymarket-activity" } + }; + string csv = CsvExporter.Ledger(entries); + var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.StartsWith("Timestamp,AccountId,EventType", lines[0]); + Assert.Contains("\"will-x-win, really?\"", lines[1]); // Komma-Feld gequotet + Assert.Contains("-5.1", lines[1]); // kulturinvariant (Punkt) + } + + [Fact] + public void Csv_statement_lists_key_figures() + { + var s = new PeriodStatement(1, Jul, Jul, 100m, 135m, 0m, 0m, 230m, 0m, 5m, 2m, 35m, 2, 3); + string csv = CsvExporter.Statement(s); + Assert.Contains("Endsaldo,135", csv); + Assert.Contains("Netto-Handelsergebnis (Cash),35", csv); + } + } +}