diff --git a/IBKRTrader.App.csproj b/IBKRTrader.App.csproj index 2c4e752..894b7b6 100644 --- a/IBKRTrader.App.csproj +++ b/IBKRTrader.App.csproj @@ -43,6 +43,8 @@ + + diff --git a/IBKRTrader.slnx b/IBKRTrader.slnx index 47f7475..8a7cc48 100644 --- a/IBKRTrader.slnx +++ b/IBKRTrader.slnx @@ -2,5 +2,7 @@ + + diff --git a/Program.cs b/Program.cs index 065f160..58b6367 100644 --- a/Program.cs +++ b/Program.cs @@ -5,12 +5,16 @@ using IBKRTrader.Core.DependencyInjection; using IBKRTrader.Core.IBKR; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Modularity; +using IBKRTrader.Core.Persistence; +using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Security; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Trading; using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers.BuiltIn; +using IBKRTrader.Modules.Accounting; using IBKRTrader.Modules.CongressTrading; +using IBKRTrader.Modules.Supervisor; using IBKRTrader.UI; using IBKRTrader.UI.Views; using Microsoft.Extensions.Configuration; @@ -42,7 +46,7 @@ internal static class Program ApplicationConfiguration.Initialize(); - var modules = new List { new CongressTradingModule() }; + var modules = new List { new CongressTradingModule(), new AccountingModule(), new SupervisorModule() }; AppHost = Host.CreateDefaultBuilder() .UseContentRoot(AppContext.BaseDirectory) @@ -110,6 +114,10 @@ internal static class Program services.AddSingleton(); services.AddSingleton(); + // Datenfundament für Analyse/Forensik (Supervisor): Entscheidungsjournal + Order-Events. + services.AddSingleton(); + services.AddSingleton(); + // Trading-Kern services.AddSingleton(); services.AddSingleton(); @@ -156,6 +164,8 @@ internal static class Program ["core.logs"] = Properties.Resources.error_log, ["core.settings"] = Properties.Resources.setting_tools, ["congresstrading.main"] = Properties.Resources.cross_reference, + ["accounting.main"] = Properties.Resources.coins_in_hand, + ["supervisor.main"] = Properties.Resources.token_quantifier, }; foreach (var view in uiHost.Views) if (view.Icon is null && map.TryGetValue(view.Id, out var img)) @@ -253,7 +263,7 @@ internal static class Program { ApplicationConfiguration.Initialize(); - var modules = new List { new CongressTradingModule() }; + var modules = new List { new CongressTradingModule(), new AccountingModule(), new SupervisorModule() }; using var host = Host.CreateDefaultBuilder() .UseContentRoot(AppContext.BaseDirectory) diff --git a/README.md b/README.md index e6805a8..ad3a48b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory) - **Persistenz**: EF Core (Pomelo/MariaDB), Migrationen **extern** angewendet (nicht zur Laufzeit). - **Trading-Kern**: `IExecutionService` (Signal→Risiko→Order→Buchung), `IRiskService`, `IPortfolioService`, Broker hinter `IBrokerClient` (Default: `NullBrokerClient` – handelt nie, bis IBKR angebunden). +- **Analyse-Datenfundament**: `core_decision_journal` (jede Entscheidung + ReasonCode), `core_order_events`, + `SignalId`-Korrelation, JSONL-Log-Sink (`Logs/{yyyy-MM-dd}.jsonl`) – speist den Supervisor. - Details: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). ## Build & Test @@ -30,6 +32,9 @@ dotnet run --project IBKRTrader.App.csproj # App starten - `appsettings.Local.json` (gitignored) hält den DB-Connection-String (`Database:MySqlConnectionString`). - `settings.json` (gitignored) – App-Settings (IBKR-Ports, Logging, Worker, Trading). - Optional `IBKRTRADER_MASTER_KEY` bzw. `master.key` für at-rest-Verschlüsselung (AES-256-GCM). +- Supervisor (optional): `IBKRTRADER_OPENROUTER_KEY` bzw. `openrouter.key` (KI-Analyse), sowie die + Opt-ins `IBKRTRADER_SUPERVISOR_DAILY` (Tagesbericht, Stunde 0–23) und `IBKRTRADER_MCP_PORT` (MCP-Light, + nur 127.0.0.1). ## Datenbank aufsetzen Schema wird per EF-Migrationen extern angewendet – siehe [scripts/README.md](scripts/README.md): @@ -40,9 +45,19 @@ powershell -File scripts/provision-db.ps1 ## Module - **CongressTrading** – kopiert US-Kongress-Trades (capitoltrades.com) → `TradeSignal` → ExecutionService. +- **Accounting** – von der Trading-DB unabhängige Buchführung aus dem IBKR-Kontoauszug (Activity Flex + Query) → append-only Ledger `acc_*`, Periodenabrechnung/BWA, FX (USD/EUR), CSV/PDF-Export. Kein Handel. + Live-Abruf hinter Interfaces (Offline-Null-Stubs); Steuerschicht bewusst offen. Konzept: + [docs/konzepte/KONZEPT-Modul-Accounting.md](docs/konzepte/KONZEPT-Modul-Accounting.md). +- **Supervisor** – read-only KI-Analyse/Forensik über alle Module (OpenRouter-Agent + read-only + Tool-Registry, Dossier-Browser, optional MCP-Light). Stützt sich auf das Core-Datenfundament + (`core_decision_journal`, `core_order_events`, `SignalId`, JSONL-Logs). Konzept: + [docs/konzepte/KONZEPT-Modul-Supervisor.md](docs/konzepte/KONZEPT-Modul-Supervisor.md). ## Status / Nächstes - Kurskorrektur auf das PolytraderSharp-Konzept (R1–R7) abgeschlossen. +- **Accounting**- und **Supervisor**-Modul (inkl. Core-Datenfundament S-0) ergänzt; Live-Abruf (IBKR + Flex / OpenRouter-Key) und Steuerschicht sind bewusst noch offen (Stubs/Platzhalter). - **Nächster Meilenstein:** IBKR-Broker über die **TWS API / IB Gateway** (blockiert bis Paper-Zugang) – Plan: [docs/IBKR-Integration.md](docs/IBKR-Integration.md). - **Sicherheit:** DB-Passwort rotieren (liegt in der Git-Historie, Commit `ebeb035`). diff --git a/docs/konzepte/KONZEPT-Modul-Accounting.md b/docs/konzepte/KONZEPT-Modul-Accounting.md new file mode 100644 index 0000000..f8cd3e0 --- /dev/null +++ b/docs/konzepte/KONZEPT-Modul-Accounting.md @@ -0,0 +1,69 @@ +# Konzept: Modul „Accounting" (Buchhaltung/Reporting aller Konten) + +> Stand: 2026-07-30 +> Ziel: Vollständige, **von unserer Trading-DB unabhängige**, buchhalterisch korrekte Erfassung ALLER +> Kontobewegungen der IBKR-Konten. Periodische (meist monatliche), vor einer Steuerbehörde +> nachvollziehbare Aufstellungen — je Konto ODER über alle Konten, für frei wählbare Zeiträume. +> BWA-artige Kennzahlen-Übersicht in der UI. Export als CSV und PDF. **Kein Handel; reines +> Ingest-/Reporting-Modul.** +> +> Vorbild: gleichnamiges Modul in PolytraderSharp (Polymarket). Hier auf IBKR-Aktien übertragen. + +## 0. Leitprinzipien +1. **Unabhängige Quelle = IBKR-Kontoauszug, NICHT unsere DB.** Das Modul erhebt die Buchungsgrundlage + ausschließlich über eigene Abrufe des **IBKR Activity Flex Query (XML)** und speichert sie roh + + normalisiert in eigenen `acc_`-Tabellen. Der Flex Web Service (Token + Query-Id) braucht **keine** + laufende TWS-Socket-Verbindung. Unsere eigenen Trade-Logs dienen nur dem optionalen Abgleich, nie + als Buchungsgrundlage. +2. **Nachvollziehbarkeit / Audit.** Jeder Buchungssatz führt über `TransactionId` (IBKR tradeID / + transactionID) und den unveränderlichen `IdempotencyKey` auf einen prüfbaren Nachweis zurück. Der + Roh-Ingest ist **append-only**; Abrechnungen sind daraus reproduzierbar. +3. **Lesend / idempotent.** Überlappende Wiederholungs-Abrufe buchen nichts doppelt (Unique-Index auf + `IdempotencyKey`, Upsert statt Insert). + +## 1. Architektur-Einbettung +Projekt `src/IBKRTrader.Modules.Accounting/` als `IModule` (`Name="Accounting"`, `DbPrefix="acc_"`), +Registrierung in `Program.cs`. Referenziert nur den Core. Eigener `AccountingDbContext`, eigene UI +(ein Fenster mit Tabs), eigene Settings-Sektion. + +## 2. Datenbeschaffung +- **Activity Flex Query** = primärer Kontoauszug: `` (Käufe/Verkäufe: Preis, Menge, Kommission, + Währung, FX-Rate zur Basiswährung, tradeID) und `` (Dividenden, Quellensteuer, + Zinsen, Ein-/Auszahlungen, Gebühren). +- **Backfill + Inkrementell**: Erstlauf lädt die volle Historie, danach nur Neues ab dem letzten + bekannten Zeitpunkt mit Sicherheits-Lookback (Standard 24 h). +- **Idempotenz-Schlüssel** je Satz: `TRD||` bzw. `CASH||`. +- **Balance-Anker**: gemeldeter Kontosaldo je Abruf als Soll-Ist-Kontrollpunkt. +- Der Abruf liegt hinter Interfaces (`IStatementSource`/`IBalanceAnchorSource`/`IAccountingAccountSource`) + mit **Offline-Null-Stubs** — das Modul läuft ohne Live-Anbindung vollständig (bucht dann korrekt nichts). + Der Live-Flex-Abruf ist **Zielland-Arbeit**. + +## 3. Persistenz (`acc_`-Tabellen, append-only) +| Tabelle | Inhalt | +|---|---| +| `acc_ledger` | Normalisierte, unveränderliche Buchungssätze (Typ, Vorzeichen=Cash-Wirkung, native + Basiswährung, TransactionId, **IdempotencyKey unique**) | +| `acc_ingest_runs` | Abruf-Protokoll je Konto (Von/Bis, #neu/#Duplikate, Balance-Anker-Δ) | +| `acc_raw` | Rohdaten-Snapshots je Batch (Nachweis) | +| `acc_fx_rates` | amtliche USD→EUR-Tageskurse (EZB) je Datum | + +## 4. Logik (pur, unit-getestet — `Logic/`) +- `AccountingClassifier` — Flex-Zeile → Buchungssatz (Typ, Vorzeichen, Idempotenz-Key). Ein-/Auszahlung + per Vorzeichen (kombinierte IBKR-Kategorie). +- `AccountingEngine` — Periodenabrechnung (Anfangs-/Endsaldo, Einlagen/Entnahmen, Handelsvolumen, + Dividenden, Zinsen, Fees, Quellensteuer, Netto-Handelsergebnis Cash-Basis) + Monatsvergleich. + Invariante: Endsaldo−Anfang = Ergebnis + Einzahlungen − Auszahlungen. +- `FxConverter` — USD→EUR (Nearest-on-or-before). `CsvExporter` (RFC-4180, kulturinvariant). + `PdfExporter` (PDFsharp/MigraDoc, MIT). +- Realisierte GuV nutzt den Core-`RealizedPnlEngine` (FIFO) — kein Duplikat. + +## 5. UI (WinForms, ein Fenster mit Tabs) +Übersicht/BWA (KPI-Kacheln + Monatsvergleich, Zeitraum-/Konto-/Währungswahl), Ledger (filterbar), +Steuer (Platzhalter, s. u.), Abrechnung/Export (CSV/PDF), Abruf/Status (Ingest-Läufe, Soll-Ist, manueller +Trigger). DB-Zugriff nur auf Interaktion (Smoke-UI-sicher). + +## 6. Bewusst offen / Zielland-Arbeit +- **Live-IBKR-Flex-Abruf** (Token/Query-Id) + Balance-Anker → echte Buchungen (heute Null-Stub). +- **Steuerschicht**: Jurisdiktion (DE-Kapitalertragsteuer / US Form 8949) noch **nicht festgelegt**. + Der neutrale Ledger + die Abrechnung gelten unabhängig davon; die Steuer-UI/Engine ist als klar + abgetrennter, später füllbarer Platzhalter angelegt. **Keine Steuerberatung.** +- **EZB-FX-Ingest** (`acc_fx_rates` füllen) → EUR-Ansicht; USD (Basis) ist sofort verfügbar. diff --git a/docs/konzepte/KONZEPT-Modul-Supervisor.md b/docs/konzepte/KONZEPT-Modul-Supervisor.md new file mode 100644 index 0000000..907f0e4 --- /dev/null +++ b/docs/konzepte/KONZEPT-Modul-Supervisor.md @@ -0,0 +1,63 @@ +# Konzept: Modul „Supervisor" (KI-gestützte Handels-Analyse & Forensik) + +> Stand: 2026-07-30 +> Ziel: ALLES, was IBKRTrader getan (und bewusst NICHT getan) hat, detailliert analysierbar machen — +> Entscheidungen, Orders, Trades und Logs — und die Analyse durch ein KI-Modell (OpenRouter) durchführen +> lassen: Warum hat ein Trade funktioniert? Warum nicht? Woran lag es? +> **Leitidee: Erst das Datenfundament, dann die KI.** Ein Modell kann nur erklären, was aufgezeichnet wurde. +> +> Vorbild: gleichnamiges Modul in PolytraderSharp. Hier auf IBKR übertragen; strikt read-only. + +## S-0 Datenfundament (Core — umgesetzt) +Grundlage jeder guten Analyse, sofort auch OHNE KI nützlich (abfragbare Rejects, Log-Forensik): +- **`core_decision_journal`** — JEDE Entscheidung (Executed/Rejected/Skipped/Failed) mit `ReasonCode` + (Enum, als String persistiert), SignalId, Kontext-JSON und Freitext. Geschrieben vom `ExecutionService`. +- **`core_order_events`** — Order-Lifecycle (Placed/Filled/PlaceFailed/…): Preise, Menge, Broker-Antwort. +- **`SignalId`** wird durch `TradeSignal → ExecutionService → Portfolio → core_trade_history` + durchgereicht → verbindet Signal → Entscheidung(en) → Order(s) → Trade. +- **JSONL-Log-Sink** — zusätzlich zur Textdatei eine Zeile je Event nach `Logs/{yyyy-MM-dd}.jsonl` + (`ts, level, source, cid=SignalId, message`); zeilenweise filter-/parsebar. +- Pure Core-Analytik: `RealizedPnlEngine` (FIFO), `TradeAnalytics` (KPIs), `DossierBuilder`. +- Schreibpfade sind **fehlertolerant** — ein Journal-/DB-Fehler bricht den Handel nie. + +## S-1 Dossier +`DossierService` setzt zu einer SignalId Entscheidungen + Order-Events + Trades + JSONL-Log-Auszug +zusammen; `DossierBuilder` (Core, pur) rendert JSON (fürs Modell) und Markdown (für Menschen). + +## S-2 Agent + Tool-Registry +- In-Prozess-Function-Calling-Loop gegen **OpenRouter** (`OpenRouterClient`, OpenAI-kompatibel). +- **Read-only-Tools** (`SupervisorTools`): `query_decisions`, `query_order_events`, `query_trades`, + `get_dossier`, `read_logs`, `get_kpis`, `get_architecture_context`, `query_counterfactuals`. + **Kein Tool kann handeln, canceln oder schreiben.** +- **Profile** (`SupervisorProfiles`): Allgemein / Technik / CongressTrading = System-Prompt + Tool-Subset + über EINER Infrastruktur (bewusst keine Agent-zu-Agent-Orchestrierung). +- Harte Iterationsgrenze gegen Endlosschleifen; jeder Tool-Aufruf wird in der UI sichtbar geloggt. +- System-Kontext: kuratiertes Architektur-/Verhaltensdokument (`ArchitectureContext`, inline versioniert). + +## S-3 Berichte & Counterfactual +- `sup_reports` — jede Analyse (Frage/Antwort/Profil/Modell/Tool-Aufrufe) → der Supervisor ist selbst + auditierbar. +- `CounterfactualJob` — „was wäre aus abgelehnten BUYs geworden?" (späterer Kurs vs. Signalpreis). + Die Kursauflösung liegt hinter `ICounterfactualResolutionSource` mit **Null-Stub** (Zielland-Arbeit). +- `DailyReportService` — täglicher Bericht, **opt-in** via `IBKRTRADER_SUPERVISOR_DAILY` (Stunde 0–23). + +## S-4 MCP-Light +`McpLightServer` exponiert dieselbe read-only Tool-Registry als lokalen MCP-Endpoint für externe Clients +(z. B. Claude Code). **Opt-in** via `IBKRTRADER_MCP_PORT`, bindet nur `127.0.0.1`. Handler `McpJsonRpc` +ist pur + unit-getestet (initialize/ping/tools.list/tools.call). + +## Architektur & Unterbringung +Projekt `src/IBKRTrader.Modules.Supervisor/` als `IModule` (`Name="Supervisor"`, `DbPrefix="sup_"`), +referenziert nur den Core. Eigenes Fenster mit Tabs: Analyse (Chat), Dossier-Browser, Berichte, Settings. + +## Sicherheit +- **OpenRouter = bewusst freigegebener externer Datenempfänger.** Es werden nur Analyse-Daten der Tools + gesendet, niemals Secrets/Keys/Connection-Strings. +- Separater API-Key (`IBKRTRADER_OPENROUTER_KEY` oder gitignorierte `openrouter.key`), getrennt von + künftigen Trading-Keys. +- **Read-only by design** — kein Order-/Schreib-Tool. Prompt-Injection über Fremdtexte bleibt auf + „falsche Analyse" begrenzt, kann nie handeln. + +## Bewusst offen / Zielland-Arbeit +- Counterfactual-Kursauflösung für Aktien (späterer Kurs) — Interface + Stub vorhanden. +- Externer Versand des Tagesberichts (z. B. Threema) — heute nur Persistenz/Log. diff --git a/scripts/provision-db.ps1 b/scripts/provision-db.ps1 index ebbedd7..17c7c2c 100644 --- a/scripts/provision-db.ps1 +++ b/scripts/provision-db.ps1 @@ -45,4 +45,12 @@ Write-Host "Wende CongressTradingDbContext-Migrationen an..." -ForegroundColor C dotnet ef database update --project src/IBKRTrader.Modules.CongressTrading --startup-project src/IBKRTrader.Modules.CongressTrading --context CongressTradingDbContext if ($LASTEXITCODE -ne 0) { Write-Error "CongressTradingDbContext-Migration fehlgeschlagen."; exit 1 } +Write-Host "Wende AccountingDbContext-Migrationen an..." -ForegroundColor Cyan +dotnet ef database update --project src/IBKRTrader.Modules.Accounting --startup-project src/IBKRTrader.Modules.Accounting --context AccountingDbContext +if ($LASTEXITCODE -ne 0) { Write-Error "AccountingDbContext-Migration fehlgeschlagen."; exit 1 } + +Write-Host "Wende SupervisorDbContext-Migrationen an..." -ForegroundColor Cyan +dotnet ef database update --project src/IBKRTrader.Modules.Supervisor --startup-project src/IBKRTrader.Modules.Supervisor --context SupervisorDbContext +if ($LASTEXITCODE -ne 0) { Write-Error "SupervisorDbContext-Migration fehlgeschlagen."; exit 1 } + Write-Host "DB-Provisioning abgeschlossen." -ForegroundColor Green diff --git a/src/IBKRTrader.Core/Analytics/DossierBuilder.cs b/src/IBKRTrader.Core/Analytics/DossierBuilder.cs new file mode 100644 index 0000000..6e4cffb --- /dev/null +++ b/src/IBKRTrader.Core/Analytics/DossierBuilder.cs @@ -0,0 +1,95 @@ +using System.Text; +using System.Text.Json; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Entities; + +namespace IBKRTrader.Core.Analytics; + +/// +/// Das komplette, rekonstruierte Bild zu einer SignalId: Entscheidungskette, Order-Events, +/// gebuchte Trades und der zugehörige JSONL-Log-Auszug. Read-only zusammengesetzt. +/// +public sealed record TradeDossier( + string SignalId, + IReadOnlyList Decisions, + IReadOnlyList OrderEvents, + IReadOnlyList Trades, + IReadOnlyList LogLines); + +/// +/// Reiner Zusammenbau + Rendering eines Dossiers (JSON fürs Modell, Markdown für Menschen). Keine I/O – +/// die Beschaffung (DB-Queries, JSONL-Lesen) liegt im DossierService des Supervisor-Moduls. +/// +public static class DossierBuilder +{ + public static TradeDossier Build( + string signalId, + IEnumerable decisions, + IEnumerable orderEvents, + IEnumerable trades, + IEnumerable logLines) => + new(signalId, + decisions.OrderBy(d => d.Timestamp).ToList(), + orderEvents.OrderBy(e => e.Timestamp).ToList(), + trades.OrderBy(t => t.TradedAt).ToList(), + logLines.OrderBy(l => l.Ts).ToList()); + + public static string ToMarkdown(TradeDossier d) + { + var sb = new StringBuilder(); + sb.AppendLine($"# Dossier – Signal `{d.SignalId}`"); + sb.AppendLine(); + + sb.AppendLine("## Entscheidungen"); + if (d.Decisions.Count == 0) sb.AppendLine("_(keine)_"); + foreach (var r in d.Decisions) + sb.AppendLine($"- {r.Timestamp:u} · **{r.Decision}** ({r.Reason}) · {r.Module} · {r.Side} {r.Symbol} @ {r.SignalPrice:F2} · {r.Message}"); + sb.AppendLine(); + + sb.AppendLine("## Order-Events"); + if (d.OrderEvents.Count == 0) sb.AppendLine("_(keine)_"); + foreach (var e in d.OrderEvents) + sb.AppendLine($"- {e.Timestamp:u} · **{e.EventType}** · {e.Side} {e.Quantity}x {e.Symbol} @ {e.Price:F2} ({e.OrderType}) · {e.Response}"); + sb.AppendLine(); + + sb.AppendLine("## Trades"); + if (d.Trades.Count == 0) sb.AppendLine("_(keine)_"); + foreach (var t in d.Trades) + sb.AppendLine($"- {t.TradedAt:u} · {t.Action} {t.Quantity}x {t.Symbol} @ {t.Price:F2} = {t.TotalValue:F2} · {t.Status}"); + sb.AppendLine(); + + sb.AppendLine("## Log-Auszug"); + if (d.LogLines.Count == 0) sb.AppendLine("_(keine passenden JSONL-Zeilen)_"); + foreach (var l in d.LogLines) + sb.AppendLine($"- {l.Ts:u} [{l.Level}] {l.Source}: {l.Message}"); + + return sb.ToString(); + } + + public static string ToJson(TradeDossier d) + { + var payload = new + { + signalId = d.SignalId, + decisions = d.Decisions.Select(r => new + { + ts = r.Timestamp, r.Module, r.Symbol, r.Side, price = r.SignalPrice, + decision = r.Decision.ToString(), reason = r.Reason.ToString(), r.Message, ctx = r.ContextJson + }), + orderEvents = d.OrderEvents.Select(e => new + { + ts = e.Timestamp, e.Module, e.Symbol, eventType = e.EventType.ToString(), + e.Side, e.Price, e.Quantity, e.OrderType, e.Response, details = e.DetailsJson + }), + trades = d.Trades.Select(t => new + { + ts = t.TradedAt, t.Module, t.Symbol, t.Action, t.Quantity, t.Price, t.TotalValue, t.Status + }), + logLines = d.LogLines.Select(l => new { ts = l.Ts, l.Level, l.Source, l.Message }) + }; + return JsonSerializer.Serialize(payload, new JsonSerializerOptions + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + } +} diff --git a/src/IBKRTrader.Core/Analytics/RealizedPnlEngine.cs b/src/IBKRTrader.Core/Analytics/RealizedPnlEngine.cs new file mode 100644 index 0000000..74c4f1f --- /dev/null +++ b/src/IBKRTrader.Core/Analytics/RealizedPnlEngine.cs @@ -0,0 +1,81 @@ +using IBKRTrader.Core.Persistence.Entities; + +namespace IBKRTrader.Core.Analytics; + +/// +/// Ein geschlossener (realisierter) Teil-Trade: eine SELL-Menge, gegen ihre FIFO-gematchten BUY-Lots +/// abgerechnet. Aus den Fills der core_trade_history rein rechnerisch abgeleitet. +/// +public sealed record RealizedTrade( + string Module, + string Symbol, + decimal Quantity, + decimal BuyPrice, + decimal SellPrice, + DateTime OpenedAt, + DateTime ClosedAt) +{ + /// Realisierte GuV dieser Menge (Erlös − Einstand); Fees sind hier nicht berücksichtigt. + public decimal RealizedPnl => (SellPrice - BuyPrice) * Quantity; +} + +/// +/// Reines, seiteneffektfreies FIFO-Lot-Matching über Fills (BUY öffnet Lots, SELL realisiert gegen die +/// ältesten offenen Lots) — je (Modul, Symbol). Grundlage sowohl für die Supervisor-KPIs +/// () als auch für die realisierte GuV im Accounting-Modul (kein Duplikat). +/// Geldkritisch → unit-getestet. Long-only-Sicht: SELL-Mengen ohne passendes offenes Lot werden +/// ignoriert (kein Leerverkauf modelliert). +/// +public static class RealizedPnlEngine +{ + private sealed class Lot + { + public decimal Quantity; + public decimal Price; + public DateTime OpenedAt; + } + + /// Matcht alle Fills zu realisierten Teil-Trades (chronologisch, FIFO je Modul+Symbol). + public static IReadOnlyList Match(IEnumerable fills) + { + var result = new List(); + + var groups = fills + .GroupBy(f => (f.Module, f.Symbol)); + + foreach (var g in groups) + { + var open = new Queue(); + foreach (var f in g.OrderBy(x => x.TradedAt).ThenBy(x => x.Id)) + { + bool isBuy = string.Equals(f.Action, "BUY", StringComparison.OrdinalIgnoreCase); + if (isBuy) + { + open.Enqueue(new Lot { Quantity = f.Quantity, Price = f.Price, OpenedAt = f.TradedAt }); + continue; + } + + // SELL: gegen die ältesten offenen Lots abrechnen. + decimal remaining = f.Quantity; + while (remaining > 0 && open.Count > 0) + { + var lot = open.Peek(); + decimal matched = Math.Min(remaining, lot.Quantity); + result.Add(new RealizedTrade( + g.Key.Module, g.Key.Symbol, matched, lot.Price, f.Price, lot.OpenedAt, f.TradedAt)); + + lot.Quantity -= matched; + remaining -= matched; + if (lot.Quantity <= 0) open.Dequeue(); + } + // Überschüssige SELL-Menge ohne offenes Lot: ignoriert (kein Short modelliert). + } + } + + return result; + } + + /// Summe der realisierten GuV über alle gematchten Teil-Trades. + public static decimal TotalRealized(IEnumerable fills) => + Match(fills).Sum(t => t.RealizedPnl); +} diff --git a/src/IBKRTrader.Core/Analytics/TradeAnalytics.cs b/src/IBKRTrader.Core/Analytics/TradeAnalytics.cs new file mode 100644 index 0000000..4e9c2c3 --- /dev/null +++ b/src/IBKRTrader.Core/Analytics/TradeAnalytics.cs @@ -0,0 +1,47 @@ +using IBKRTrader.Core.Persistence.Entities; + +namespace IBKRTrader.Core.Analytics; + +/// Kennzahlen über realisierte Trades. +public sealed record Kpis( + int TradeCount, + decimal NetPnl, + double WinRatePct, + decimal AvgPnlPerTrade, + double ProfitFactor); + +/// GuV-Aufteilung nach einem Schlüssel (z. B. Modul). +public sealed record PnlBucket(string Key, decimal Pnl, int Count); + +/// +/// Reine KPI-Berechnung über die Fills der core_trade_history: erst FIFO-Realisierung +/// (), dann Aggregat. Genutzt von den Supervisor-Tools (get_kpis). +/// +public static class TradeAnalytics +{ + public static Kpis ComputeKpis(IEnumerable fills) + { + var realized = RealizedPnlEngine.Match(fills); + int count = realized.Count; + if (count == 0) return new Kpis(0, 0m, 0d, 0m, 0d); + + decimal net = realized.Sum(t => t.RealizedPnl); + int winners = realized.Count(t => t.RealizedPnl > 0); + decimal grossProfit = realized.Where(t => t.RealizedPnl > 0).Sum(t => t.RealizedPnl); + decimal grossLoss = Math.Abs(realized.Where(t => t.RealizedPnl < 0).Sum(t => t.RealizedPnl)); + + double winRate = 100d * winners / count; + double profitFactor = grossLoss == 0m ? (grossProfit > 0m ? double.PositiveInfinity : 0d) + : (double)(grossProfit / grossLoss); + + return new Kpis(count, net, winRate, net / count, profitFactor); + } + + /// Realisierte GuV je Modul (absteigend nach GuV). + public static IReadOnlyList PnlByModule(IEnumerable fills) => + RealizedPnlEngine.Match(fills) + .GroupBy(t => t.Module) + .Select(g => new PnlBucket(g.Key, g.Sum(t => t.RealizedPnl), g.Count())) + .OrderByDescending(b => b.Pnl) + .ToList(); +} diff --git a/src/IBKRTrader.Core/Logging/LogJson.cs b/src/IBKRTrader.Core/Logging/LogJson.cs new file mode 100644 index 0000000..c3533b1 --- /dev/null +++ b/src/IBKRTrader.Core/Logging/LogJson.cs @@ -0,0 +1,59 @@ +using System.Text; +using System.Text.Json; + +namespace IBKRTrader.Core.Logging; + +/// +/// Pure, seiteneffektfreie (De-)Serialisierung einer JSONL-Log-Zeile. JSONL (eine JSON-Zeile je Event) +/// ist append-fähig, streambar und zeilenweise filterbar – KI-freundlich und effizient. Die eigentliche +/// Datei-I/O liegt im ; hier nur das Format, damit es unit-getestet werden +/// kann (Round-Trip). Feldnamen bewusst kurz: ts, level, source, cid, message. +/// +public static class LogJson +{ + private static readonly JsonSerializerOptions Opts = new() + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + /// Eine geparste JSONL-Zeile (Beiwerk – fehlende/kaputte Zeilen liefern null). + public sealed record ParsedLogLine(DateTime Ts, string Level, string Source, string? Cid, string Message); + + /// Serialisiert ein Log-Event zu genau einer JSON-Zeile (ohne Zeilenumbruch). + public static string WriteLine(DateTime ts, AppLogLevel level, string source, string message, string? cid) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("ts", ts.ToUniversalTime().ToString("O")); + w.WriteString("level", level.ToString()); + w.WriteString("source", source ?? ""); + if (!string.IsNullOrEmpty(cid)) w.WriteString("cid", cid); + w.WriteString("message", message ?? ""); + w.WriteEndObject(); + } + return Encoding.UTF8.GetString(ms.ToArray()); + } + + /// Parst eine JSONL-Zeile. Gibt null zurück, wenn die Zeile leer oder kein gültiges JSON ist. + public static ParsedLogLine? ParseLine(string? line) + { + if (string.IsNullOrWhiteSpace(line)) return null; + try + { + using var doc = JsonDocument.Parse(line); + var r = doc.RootElement; + DateTime ts = r.TryGetProperty("ts", out var tsp) && tsp.TryGetDateTime(out var d) ? d : default; + string level = r.TryGetProperty("level", out var lp) ? lp.GetString() ?? "" : ""; + string source = r.TryGetProperty("source", out var sp) ? sp.GetString() ?? "" : ""; + string? cid = r.TryGetProperty("cid", out var cp) ? cp.GetString() : null; + string msg = r.TryGetProperty("message", out var mp) ? mp.GetString() ?? "" : ""; + return new ParsedLogLine(ts, level, source, cid, msg); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/IBKRTrader.Core/Logging/LoggingService.cs b/src/IBKRTrader.Core/Logging/LoggingService.cs index 11aab33..778bda5 100644 --- a/src/IBKRTrader.Core/Logging/LoggingService.cs +++ b/src/IBKRTrader.Core/Logging/LoggingService.cs @@ -11,7 +11,8 @@ public class LoggingService { private RichTextBox? _rtb; private AppLogLevel _minLevel = AppLogLevel.Info; - private readonly object _fileLock = new(); + private readonly object _fileLock = new(); + private readonly object _jsonlLock = new(); private static readonly string LogBaseDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); @@ -33,11 +34,23 @@ public class LoggingService public void Error(string module, string message, Exception? ex = null) => Write(AppLogLevel.Error, module, message, ex); - public void Write(AppLogLevel level, string module, string message, Exception? ex = null) + // Überladungen mit CorrelationId (SignalId) – schreiben zusätzlich strukturiert ins JSONL, + // sodass der Supervisor die komplette Kette „alles zu diesem Signal" filtern kann. + public void Info (string module, string message, string? cid, Exception? ex = null) + => Write(AppLogLevel.Info, module, message, ex, cid); + + public void Warn (string module, string message, string? cid, Exception? ex = null) + => Write(AppLogLevel.Warn, module, message, ex, cid); + + public void Error(string module, string message, string? cid, Exception? ex = null) + => Write(AppLogLevel.Error, module, message, ex, cid); + + public void Write(AppLogLevel level, string module, string message, Exception? ex = null, string? cid = null) { if (level < _minLevel) return; var entry = new LogEntry(DateTime.Now, level, module, message, ex); WriteToFile(entry); + WriteToJsonl(entry, cid); WriteToRtb(entry); } @@ -61,6 +74,27 @@ public class LoggingService catch { /* Logging darf niemals abstürzen */ } } + // ─── JSONL (KI-freundlicher Zweit-Sink) ───────────────────────────────────── + + /// + /// Schreibt zusätzlich eine JSON-Zeile nach Logs\{yyyy-MM-dd}.jsonl (Dual-Sink). Zeilenweise + /// filter-/parsebar (Datum/Level/Quelle/Text/CorrelationId) – Grundlage für Log Viewer + Supervisor. + /// + private void WriteToJsonl(LogEntry e, string? cid) + { + try + { + Directory.CreateDirectory(LogBaseDir); + var file = Path.Combine(LogBaseDir, $"{e.Timestamp:yyyy-MM-dd}.jsonl"); + var message = e.Exception != null ? $"{e.Message} | {e.Exception.Message}" : e.Message; + var json = LogJson.WriteLine(e.Timestamp, e.Level, e.Module, message, cid); + + lock (_jsonlLock) + File.AppendAllText(file, json + "\n"); + } + catch { /* Logging darf niemals abstürzen */ } + } + // ─── RichTextBox ────────────────────────────────────────────────────────── private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150); diff --git a/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs b/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs index 3f613a2..b2be094 100644 --- a/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs +++ b/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs @@ -18,6 +18,10 @@ public class CoreDbContext : DbContext public DbSet WorkerLog => Set(); public DbSet Settings => Set(); + // Datenfundament für Analyse/Forensik (Supervisor) + public DbSet DecisionJournal => Set(); + public DbSet OrderEvents => Set(); + // IBKR-Marktdaten public DbSet Instruments => Set(); public DbSet MarketBars => Set(); @@ -41,6 +45,7 @@ public class CoreDbContext : DbContext e.Property(x => x.Module).HasMaxLength(50); e.Property(x => x.Symbol).HasMaxLength(20); e.Property(x => x.Action).HasMaxLength(10); + e.Property(x => x.SignalId).HasMaxLength(64); e.Property(x => x.IbkrOrderId).HasMaxLength(100); e.Property(x => x.Status).HasMaxLength(50); e.Property(x => x.Quantity).HasPrecision(18, 4); @@ -48,6 +53,7 @@ public class CoreDbContext : DbContext e.Property(x => x.TotalValue).HasPrecision(18, 4); e.HasIndex(x => x.Symbol); e.HasIndex(x => x.Module); + e.HasIndex(x => x.SignalId); }); b.Entity(e => @@ -119,5 +125,39 @@ public class CoreDbContext : DbContext e.Property(x => x.Source).HasMaxLength(50); e.Property(x => x.Ticker).HasMaxLength(50); }); + + b.Entity(e => + { + e.ToTable("core_decision_journal"); + e.HasKey(x => x.Id); + e.Property(x => x.SignalId).HasMaxLength(64); + e.Property(x => x.Module).HasMaxLength(50); + e.Property(x => x.Symbol).HasMaxLength(20); + e.Property(x => x.Side).HasMaxLength(10); + e.Property(x => x.SignalPrice).HasPrecision(18, 4); + e.Property(x => x.Decision).HasConversion().HasMaxLength(20); + e.Property(x => x.Reason).HasConversion().HasMaxLength(40); + e.Property(x => x.ContextJson).HasColumnType("text"); + e.Property(x => x.Message).HasColumnType("text"); + e.HasIndex(x => x.SignalId); + e.HasIndex(x => new { x.Module, x.Timestamp }); + }); + + b.Entity(e => + { + e.ToTable("core_order_events"); + e.HasKey(x => x.Id); + e.Property(x => x.SignalId).HasMaxLength(64); + e.Property(x => x.Module).HasMaxLength(50); + e.Property(x => x.Symbol).HasMaxLength(20); + e.Property(x => x.Side).HasMaxLength(10); + e.Property(x => x.OrderType).HasMaxLength(20); + e.Property(x => x.Price).HasPrecision(18, 4); + e.Property(x => x.EventType).HasConversion().HasMaxLength(20); + e.Property(x => x.Response).HasColumnType("text"); + e.Property(x => x.DetailsJson).HasColumnType("text"); + e.HasIndex(x => x.SignalId); + e.HasIndex(x => new { x.Symbol, x.Timestamp }); + }); } } diff --git a/src/IBKRTrader.Core/Persistence/Ef/EfAnalysisJournals.cs b/src/IBKRTrader.Core/Persistence/Ef/EfAnalysisJournals.cs new file mode 100644 index 0000000..2419bd6 --- /dev/null +++ b/src/IBKRTrader.Core/Persistence/Ef/EfAnalysisJournals.cs @@ -0,0 +1,84 @@ +using System.Linq.Expressions; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Core.Persistence.Ef; + +/// +/// EF-Implementierung des Entscheidungsjournals. ist bewusst fehlertolerant: +/// ein Journal-/DB-Fehler darf den Geld-Pfad (ExecutionService) niemals brechen – er wird geloggt +/// und verworfen. Kurzlebiger DbContext je Operation (thread-safe über die Factory). +/// +public sealed class EfDecisionJournal : IDecisionJournal +{ + private readonly IDbContextFactory _dbf; + private readonly LoggingService _logger; + + public EfDecisionJournal(IDbContextFactory dbf, LoggingService logger) + { + _dbf = dbf; + _logger = logger; + } + + public void Write(CoreDecisionRecord record) + { + try + { + using var db = _dbf.CreateDbContext(); + db.DecisionJournal.Add(record); + db.SaveChanges(); + } + catch (Exception ex) + { + _logger.Warn("Core", $"DecisionJournal-Write fehlgeschlagen (ignoriert): {ex.Message}"); + } + } + + public List Query(Expression> predicate, int limit = 1000) + { + using var db = _dbf.CreateDbContext(); + return db.DecisionJournal.AsNoTracking() + .Where(predicate) + .OrderByDescending(r => r.Timestamp) + .Take(limit) + .ToList(); + } +} + +/// EF-Implementierung des Order-Lifecycle-Logs (gleiche Robustheits-Garantie). +public sealed class EfOrderEventLog : IOrderEventLog +{ + private readonly IDbContextFactory _dbf; + private readonly LoggingService _logger; + + public EfOrderEventLog(IDbContextFactory dbf, LoggingService logger) + { + _dbf = dbf; + _logger = logger; + } + + public void Write(CoreOrderEvent record) + { + try + { + using var db = _dbf.CreateDbContext(); + db.OrderEvents.Add(record); + db.SaveChanges(); + } + catch (Exception ex) + { + _logger.Warn("Core", $"OrderEvent-Write fehlgeschlagen (ignoriert): {ex.Message}"); + } + } + + public List Query(Expression> predicate, int limit = 1000) + { + using var db = _dbf.CreateDbContext(); + return db.OrderEvents.AsNoTracking() + .Where(predicate) + .OrderByDescending(r => r.Timestamp) + .Take(limit) + .ToList(); + } +} diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.Designer.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.Designer.cs new file mode 100644 index 0000000..8f5442d --- /dev/null +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.Designer.cs @@ -0,0 +1,484 @@ +// +using System; +using IBKRTrader.Core.Persistence.Ef; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IBKRTrader.Core.Persistence.Ef.Migrations +{ + [DbContext(typeof(CoreDbContext))] + [Migration("20260730164359_AddAnalysisFoundation")] + partial class AddAnalysisFoundation + { + /// + 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("IBKRTrader.Core.IBKR.IBKRExternalIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Ticker"); + + b.HasIndex("InstrumentId", "Source", "Ticker") + .IsUnique(); + + b.ToTable("core_ibkr_external_identifiers", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRInstrument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("CompanyName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Exchange") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IbkrConid") + .HasColumnType("bigint"); + + b.Property("Industry") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Isin") + .HasMaxLength(12) + .HasColumnType("varchar(12)"); + + b.Property("LastFetched") + .HasColumnType("datetime(6)"); + + b.Property("PrimaryExchange") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SecType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Sector") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Active"); + + b.HasIndex("IbkrConid") + .IsUnique(); + + b.HasIndex("Symbol"); + + b.ToTable("core_ibkr_instruments", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRMarketBar", b => + { + b.Property("InstrumentId") + .HasColumnType("bigint"); + + b.Property("BarSize") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("BarCount") + .HasColumnType("int"); + + b.Property("Close") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("High") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Low") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Open") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.Property("Volume") + .HasColumnType("bigint"); + + b.Property("Wap") + .HasPrecision(12, 6) + .HasColumnType("decimal(12,6)"); + + b.HasKey("InstrumentId", "BarSize", "Timestamp"); + + b.HasIndex("Timestamp"); + + b.ToTable("core_ibkr_market_data", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreBudget", b => + { + b.Property("Module") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxPerTrade") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("TotalBudget") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UsedBudget") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Module"); + + b.ToTable("core_budget", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ContextJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SignalPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SignalId"); + + b.HasIndex("Module", "Timestamp"); + + b.ToTable("core_decision_journal", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("Response") + .IsRequired() + .HasColumnType("text"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SignalId"); + + b.HasIndex("Symbol", "Timestamp"); + + b.ToTable("core_order_events", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b => + { + b.Property("Module") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Symbol") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("AvgPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Module", "Symbol"); + + b.ToTable("core_position", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("core_settings", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreTrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IbkrOrderId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SignalId") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TotalValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TradedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Module"); + + b.HasIndex("SignalId"); + + b.HasIndex("Symbol"); + + b.ToTable("core_trade_history", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreWorkerLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FinishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .HasColumnType("longtext"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WorkerName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("WorkerName", "StartedAt"); + + b.ToTable("core_worker_log", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.cs new file mode 100644 index 0000000..f12ce41 --- /dev/null +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.cs @@ -0,0 +1,130 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IBKRTrader.Core.Persistence.Ef.Migrations +{ + /// + public partial class AddAnalysisFoundation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SignalId", + table: "core_trade_history", + type: "varchar(64)", + maxLength: 64, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "core_decision_journal", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Timestamp = table.Column(type: "datetime(6)", nullable: false), + SignalId = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Module = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Symbol = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Side = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SignalPrice = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + Decision = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Reason = table.Column(type: "varchar(40)", maxLength: 40, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ContextJson = table.Column(type: "text", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Message = table.Column(type: "text", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_core_decision_journal", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "core_order_events", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Timestamp = table.Column(type: "datetime(6)", nullable: false), + SignalId = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Module = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Symbol = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + EventType = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Side = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Price = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + Quantity = table.Column(type: "int", nullable: false), + OrderType = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Response = table.Column(type: "text", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + DetailsJson = table.Column(type: "text", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_core_order_events", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_core_trade_history_SignalId", + table: "core_trade_history", + column: "SignalId"); + + migrationBuilder.CreateIndex( + name: "IX_core_decision_journal_Module_Timestamp", + table: "core_decision_journal", + columns: new[] { "Module", "Timestamp" }); + + migrationBuilder.CreateIndex( + name: "IX_core_decision_journal_SignalId", + table: "core_decision_journal", + column: "SignalId"); + + migrationBuilder.CreateIndex( + name: "IX_core_order_events_SignalId", + table: "core_order_events", + column: "SignalId"); + + migrationBuilder.CreateIndex( + name: "IX_core_order_events_Symbol_Timestamp", + table: "core_order_events", + columns: new[] { "Symbol", "Timestamp" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "core_decision_journal"); + + migrationBuilder.DropTable( + name: "core_order_events"); + + migrationBuilder.DropIndex( + name: "IX_core_trade_history_SignalId", + table: "core_trade_history"); + + migrationBuilder.DropColumn( + name: "SignalId", + table: "core_trade_history"); + } + } +} diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs index a8def56..7f17d98 100644 --- a/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs +++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs @@ -201,6 +201,133 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations b.ToTable("core_budget", (string)null); }); + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ContextJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SignalPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SignalId"); + + b.HasIndex("Module", "Timestamp"); + + b.ToTable("core_decision_journal", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("Response") + .IsRequired() + .HasColumnType("text"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SignalId"); + + b.HasIndex("Symbol", "Timestamp"); + + b.ToTable("core_order_events", (string)null); + }); + modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b => { b.Property("Module") @@ -279,6 +406,10 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations .HasPrecision(18, 4) .HasColumnType("decimal(18,4)"); + b.Property("SignalId") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + b.Property("Status") .HasMaxLength(50) .HasColumnType("varchar(50)"); @@ -299,6 +430,8 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations b.HasIndex("Module"); + b.HasIndex("SignalId"); + b.HasIndex("Symbol"); b.ToTable("core_trade_history", (string)null); diff --git a/src/IBKRTrader.Core/Persistence/Entities/AnalysisEntities.cs b/src/IBKRTrader.Core/Persistence/Entities/AnalysisEntities.cs new file mode 100644 index 0000000..fe74315 --- /dev/null +++ b/src/IBKRTrader.Core/Persistence/Entities/AnalysisEntities.cs @@ -0,0 +1,100 @@ +namespace IBKRTrader.Core.Persistence.Entities; + +/// Ausgang einer Handelsentscheidung im Entscheidungsjournal. +public enum TradeDecision +{ + Executed, // Aktion ausgeführt (Order platziert) + Rejected, // aktiv abgelehnt (Risiko-/Plausibilitätsregel) + Skipped, // bewusst übersprungen (z. B. Trading global aus, kein Kurs) + Failed // versucht, aber fehlgeschlagen (z. B. Order-Fehler) +} + +/// +/// Strukturierter Grund einer Entscheidung (statt Freitext). Als STRING persistiert – neue Werte können +/// gefahrlos ergänzt werden. Deckt die heutigen Verzweigungen im ExecutionService ab und lässt +/// Raum für künftige Modul-/Risiko-Regeln. Vorbild: PolytraderSharp DecisionReason. +/// +public enum DecisionReason +{ + None = 0, + + // ----- Modus / Zustand ----- + TradingDisabled, // globaler Hauptschalter aus + NoQuote, // kein Kurs für das Symbol verfügbar + + // ----- Risiko / Sizing ----- + RiskRejected, // Risikoprüfung abgelehnt (Grund im Message-/ContextJson) + PositionLimitReached, // Modul-/Positions-Limit erreicht + MaxSlippageExceeded, // Abweichung Limit ↔ Kurs zu groß + InsufficientFunds, // verfügbares Guthaben reicht nicht + DuplicateOrder, // bereits offene/gleiche Order + + // ----- Ausführung ----- + OrderPlaced, // Order erfolgreich platziert + OrderFailed // Broker/Order-Fehler beim Platzieren +} + +/// Art eines Order-Lifecycle-Ereignisses (als String persistiert – erweiterbar). +public enum OrderEventType +{ + Placed, // Order an den Broker gesendet, Ergebnis in Response + PlaceFailed, // Platzierung fehlgeschlagen + Filled, // vollständig ausgeführt + PartiallyFilled, // teilweise ausgeführt + Cancelled // storniert (Grund in DetailsJson) +} + +/// +/// Eine Zeile im Entscheidungsjournal (core_decision_journal): JEDE Handelsentscheidung – +/// ausgeführt, abgelehnt oder übersprungen – strukturiert und abfragbar. Grundlage für +/// Supervisor-Analysen („warum (nicht) gehandelt?"). Siehe docs/konzepte/KONZEPT-Modul-Supervisor.md. +/// +public class CoreDecisionRecord +{ + public long Id { get; set; } + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + /// Korrelation: verbindet Signal → Entscheidung(en) → Order(s) → Trade. + public string SignalId { get; set; } = ""; + + public string Module { get; set; } = ""; + public string Symbol { get; set; } = ""; + public string Side { get; set; } = ""; // BUY / SELL + public decimal SignalPrice { get; set; } + + public TradeDecision Decision { get; set; } + public DecisionReason Reason { get; set; } + + /// Kompakte Kontext-Zahlen als JSON (Limitwerte, Budgets, berechnete Größen …). + public string ContextJson { get; set; } = ""; + + /// Menschlicher Begründungstext (wie bisher im Log). + public string Message { get; set; } = ""; +} + +/// +/// Ein Order-Lifecycle-Ereignis (core_order_events): Platzierungen, Broker-Antworten, Cancels – als +/// Daten statt nur als Log. Zusammen mit dem Entscheidungsjournal ergibt das die rekonstruierbare Kette +/// je Signal (Dossier). +/// +public class CoreOrderEvent +{ + public long Id { get; set; } + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + public string SignalId { get; set; } = ""; + public string Module { get; set; } = ""; + public string Symbol { get; set; } = ""; + + public OrderEventType EventType { get; set; } + public string Side { get; set; } = ""; // BUY / SELL + public decimal Price { get; set; } + public int Quantity { get; set; } + public string OrderType { get; set; } = ""; // Market / Limit + + /// Broker-Antwort ("OK" oder Fehlertext) bzw. Ergebnis der Aktion. + public string Response { get; set; } = ""; + + /// Zusatzkontext als kompaktes JSON (z. B. Fill-Preis, Teilmenge, Grund). + public string DetailsJson { get; set; } = ""; +} diff --git a/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs b/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs index de7d8c7..a624e18 100644 --- a/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs +++ b/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs @@ -21,6 +21,8 @@ public class CoreTrade public decimal Price { get; set; } public decimal TotalValue { get; set; } public DateTime TradedAt { get; set; } + /// Korrelation zu Entscheidungsjournal/Order-Events (leer für Trades ohne Signal-Kette). + public string? SignalId { get; set; } public string? IbkrOrderId { get; set; } public string? Status { get; set; } public string? Notes { get; set; } diff --git a/src/IBKRTrader.Core/Persistence/IAnalysisJournals.cs b/src/IBKRTrader.Core/Persistence/IAnalysisJournals.cs new file mode 100644 index 0000000..75b6b5e --- /dev/null +++ b/src/IBKRTrader.Core/Persistence/IAnalysisJournals.cs @@ -0,0 +1,21 @@ +using System.Linq.Expressions; +using IBKRTrader.Core.Persistence.Entities; + +namespace IBKRTrader.Core.Persistence; + +/// +/// Entscheidungsjournal (core_decision_journal). darf den Trading-Pfad NIEMALS +/// brechen – Implementierungen fangen Persistenzfehler ab (Log statt Exception). +/// +public interface IDecisionJournal +{ + void Write(CoreDecisionRecord record); + List Query(Expression> predicate, int limit = 1000); +} + +/// Order-Lifecycle-Log (core_order_events). Gleiche Robustheits-Garantie wie das Journal. +public interface IOrderEventLog +{ + void Write(CoreOrderEvent record); + List Query(Expression> predicate, int limit = 1000); +} diff --git a/src/IBKRTrader.Core/Settings/AppSettings.cs b/src/IBKRTrader.Core/Settings/AppSettings.cs index 6912493..3112240 100644 --- a/src/IBKRTrader.Core/Settings/AppSettings.cs +++ b/src/IBKRTrader.Core/Settings/AppSettings.cs @@ -214,6 +214,53 @@ public class TradingSettings public override string ToString() => $"{Mode} – {(TradingEnabled ? "aktiv" : "inaktiv")}"; } +// ─── Accounting ────────────────────────────────────────────────────────────── + +[TypeConverter(typeof(ExpandableObjectConverter))] +public class AccountingSettings +{ + [Category("Accounting")] + [DisplayName("Basiswährung")] + [Description("Kontobasiswährung für die Abrechnung (Standard: USD)")] + public string BaseCurrency { get; set; } = "USD"; + + [Category("Accounting")] + [DisplayName("Ingest-Intervall (Stunden)")] + [Description("Abstand der automatischen Kontoauszug-Abrufe (Live-Quelle; offline ungenutzt)")] + public int IngestIntervalHours { get; set; } = 6; + + [Category("Accounting")] + [DisplayName("Flex Query-Id")] + [Description("IBKR Activity Flex Query-Id (Zielland – Live-Abruf; leer = offline)")] + public string FlexQueryId { get; set; } = ""; + + [Category("Accounting")] + [DisplayName("Flex-Token")] + [Description("IBKR Flex Web Service Token (Zielland; verschlüsselt ablegen, nicht im Klartext)")] + [PasswordPropertyText(true)] + public string FlexToken { get; set; } = ""; + + public override string ToString() => $"Basis {BaseCurrency}, alle {IngestIntervalHours} h"; +} + +// ─── Supervisor ────────────────────────────────────────────────────────────── + +[TypeConverter(typeof(ExpandableObjectConverter))] +public class SupervisorSettings +{ + [Category("Supervisor")] + [DisplayName("Modell")] + [Description("OpenRouter-Modell für die Analyse (Standard: openrouter/auto)")] + public string Model { get; set; } = "openrouter/auto"; + + [Category("Supervisor")] + [DisplayName("Token-Budget/Analyse")] + [Description("Weiches Token-Budget je Analyse-Lauf (Hinweis; harte Grenze = max. Tool-Iterationen)")] + public int TokenBudgetPerRun { get; set; } = 60000; + + public override string ToString() => Model; +} + // ─── Root ──────────────────────────────────────────────────────────────────── public class AppSettings @@ -252,4 +299,14 @@ public class AppSettings [DisplayName("Trading")] [Description("Handelsmodus und Risiko-Parameter")] public TradingSettings Trading { get; set; } = new(); + + [Category("Accounting")] + [DisplayName("Accounting")] + [Description("Buchhaltung/Reporting (unabhängiger Kontoauszug-Ingest)")] + public AccountingSettings Accounting { get; set; } = new(); + + [Category("Supervisor")] + [DisplayName("Supervisor")] + [Description("KI-Analyse/Forensik (OpenRouter, read-only)")] + public SupervisorSettings Supervisor { get; set; } = new(); } diff --git a/src/IBKRTrader.Core/Trading/ExecutionService.cs b/src/IBKRTrader.Core/Trading/ExecutionService.cs index 6d7c7e4..89548bb 100644 --- a/src/IBKRTrader.Core/Trading/ExecutionService.cs +++ b/src/IBKRTrader.Core/Trading/ExecutionService.cs @@ -1,4 +1,7 @@ +using System.Text.Json; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence; +using IBKRTrader.Core.Persistence.Entities; using IBKRTrader.Core.Settings; namespace IBKRTrader.Core.Trading; @@ -6,42 +9,59 @@ namespace IBKRTrader.Core.Trading; /// /// Führt Modul-Signale aus: globaler Schalter → Kurs → Konto → Risiko → Order → Buchung. /// Kennt kein Modul – Module rufen nur mit ihrem Signal auf. +/// +/// Schreibt an jeder Verzweigung strukturiert ins Entscheidungsjournal (core_decision_journal) und bei +/// Order-Aktionen ins Order-Lifecycle-Log (core_order_events) und reicht die SignalId bis in die +/// Trade-Historie durch – Grundlage für die Supervisor-Forensik. Journal-Fehler brechen den Handel nie. /// public sealed class ExecutionService : IExecutionService { - private readonly IBrokerClient _broker; - private readonly IRiskService _risk; + private readonly IBrokerClient _broker; + private readonly IRiskService _risk; private readonly IPortfolioService _portfolio; - private readonly SettingsService _settings; - private readonly LoggingService _logger; + private readonly SettingsService _settings; + private readonly LoggingService _logger; + private readonly IDecisionJournal _journal; + private readonly IOrderEventLog _orderEvents; public ExecutionService( IBrokerClient broker, IRiskService risk, IPortfolioService portfolio, SettingsService settings, - LoggingService logger) + LoggingService logger, + IDecisionJournal journal, + IOrderEventLog orderEvents) { - _broker = broker; - _risk = risk; - _portfolio = portfolio; - _settings = settings; - _logger = logger; + _broker = broker; + _risk = risk; + _portfolio = portfolio; + _settings = settings; + _logger = logger; + _journal = journal; + _orderEvents = orderEvents; } public async Task ExecuteAsync(TradeSignal signal, CancellationToken ct = default) { var trading = _settings.Settings.Trading; var module = signal.SourceModule; + var sideStr = signal.Side == TradeSide.Buy ? "BUY" : "SELL"; // 1. Globaler Hauptschalter if (!trading.TradingEnabled) + { + Journal(signal, TradeDecision.Skipped, DecisionReason.TradingDisabled, "Trading global deaktiviert."); return Log(module, ExecutionResult.Skip("Trading global deaktiviert.")); + } // 2. Kurs var quote = await _broker.GetQuoteAsync(signal.Symbol, ct); if (quote is null || quote.Last <= 0) + { + Journal(signal, TradeDecision.Skipped, DecisionReason.NoQuote, $"Kein Kurs für {signal.Symbol} verfügbar."); return Log(module, ExecutionResult.Skip($"Kein Kurs für {signal.Symbol} verfügbar.")); + } // 3. Konto + 4. bestehende Exposure/Position var account = await _broker.GetAccountStateAsync(ct); @@ -62,7 +82,11 @@ public sealed class ExecutionService : IExecutionService (decimal)trading.MaxSlippagePercent); var decision = _risk.Evaluate(signal, context, riskParams); if (!decision.Approved) + { + Journal(signal, TradeDecision.Rejected, DecisionReason.RiskRejected, decision.Reason, + new { price = quote.Last, netLiq = account.NetLiquidation, exposure, existingQty }); return Log(module, ExecutionResult.Skip(decision.Reason)); + } // 6. Order platzieren var order = new OrderRequest @@ -76,16 +100,62 @@ public sealed class ExecutionService : IExecutionService var result = await _broker.PlaceOrderAsync(order, ct); if (!result.Success) + { + OrderEvent(signal, OrderEventType.PlaceFailed, sideStr, order, quote.Last, result.Error ?? "Order fehlgeschlagen."); + Journal(signal, TradeDecision.Failed, DecisionReason.OrderFailed, result.Error ?? "Order fehlgeschlagen."); return Log(module, ExecutionResult.Error(result.Error ?? "Order fehlgeschlagen.", result)); + } + + OrderEvent(signal, OrderEventType.Filled, sideStr, order, result.AvgFillPrice, result.OrderId ?? "OK", + new { filled = result.FilledQuantity, avgPrice = result.AvgFillPrice }); // 7. Buchung await _portfolio.RecordFillAsync( module, signal.Symbol, signal.Side, - result.FilledQuantity, result.AvgFillPrice, result.OrderId, ct); + result.FilledQuantity, result.AvgFillPrice, result.OrderId, signal.SignalId, ct); + + Journal(signal, TradeDecision.Executed, DecisionReason.OrderPlaced, + $"Ausgeführt: {sideStr} {result.FilledQuantity}x {signal.Symbol} @ {result.AvgFillPrice:F2}", + new { orderId = result.OrderId, filled = result.FilledQuantity, avgPrice = result.AvgFillPrice }); return Log(module, ExecutionResult.Execute(result)); } + // ─── Journal-/Order-Event-Helfer (fehlertolerant über die EF-Implementierung) ──────────────── + + private void Journal(TradeSignal signal, TradeDecision decision, DecisionReason reason, + string message, object? context = null) => + _journal.Write(new CoreDecisionRecord + { + Timestamp = DateTime.UtcNow, + SignalId = signal.SignalId, + Module = signal.SourceModule, + Symbol = signal.Symbol, + Side = signal.Side == TradeSide.Buy ? "BUY" : "SELL", + SignalPrice = signal.LimitPrice ?? 0m, + Decision = decision, + Reason = reason, + ContextJson = context is null ? "" : JsonSerializer.Serialize(context), + Message = message + }); + + private void OrderEvent(TradeSignal signal, OrderEventType type, string side, OrderRequest order, + decimal price, string response, object? details = null) => + _orderEvents.Write(new CoreOrderEvent + { + Timestamp = DateTime.UtcNow, + SignalId = signal.SignalId, + Module = signal.SourceModule, + Symbol = signal.Symbol, + EventType = type, + Side = side, + Price = price, + Quantity = order.Quantity, + OrderType = order.Type.ToString(), + Response = response, + DetailsJson = details is null ? "" : JsonSerializer.Serialize(details) + }); + private ExecutionResult Log(string module, ExecutionResult result) { var text = $"[{result.Action}] {result.Reason}"; diff --git a/src/IBKRTrader.Core/Trading/IPortfolioService.cs b/src/IBKRTrader.Core/Trading/IPortfolioService.cs index f1544c4..6ddc306 100644 --- a/src/IBKRTrader.Core/Trading/IPortfolioService.cs +++ b/src/IBKRTrader.Core/Trading/IPortfolioService.cs @@ -15,7 +15,8 @@ public interface IPortfolioService /// Verbucht einen Fill: aktualisiert Position, Budget und Trade-Historie. Task RecordFillAsync( string module, string symbol, TradeSide side, - int quantity, decimal price, string? orderId, CancellationToken ct = default); + int quantity, decimal price, string? orderId, string? signalId = null, + CancellationToken ct = default); /// Alle offenen Positionen eines Moduls. Task> GetPositionsAsync(string module, CancellationToken ct = default); diff --git a/src/IBKRTrader.Core/Trading/PortfolioService.cs b/src/IBKRTrader.Core/Trading/PortfolioService.cs index a132bd9..aec5983 100644 --- a/src/IBKRTrader.Core/Trading/PortfolioService.cs +++ b/src/IBKRTrader.Core/Trading/PortfolioService.cs @@ -54,12 +54,13 @@ public sealed class PortfolioService : IPortfolioService public async Task RecordFillAsync( string module, string symbol, TradeSide side, - int quantity, decimal price, string? orderId, CancellationToken ct = default) + int quantity, decimal price, string? orderId, string? signalId = null, + CancellationToken ct = default) { if (quantity <= 0) return; var action = side == TradeSide.Buy ? "BUY" : "SELL"; - await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId); + await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId, signalId: signalId); await using (var db = await _dbf.CreateDbContextAsync(ct)) { diff --git a/src/IBKRTrader.Core/Trading/TradeHistoryService.cs b/src/IBKRTrader.Core/Trading/TradeHistoryService.cs index abd96fe..7fd1047 100644 --- a/src/IBKRTrader.Core/Trading/TradeHistoryService.cs +++ b/src/IBKRTrader.Core/Trading/TradeHistoryService.cs @@ -19,7 +19,8 @@ public class TradeHistoryService public async Task RecordTradeAsync( string module, string symbol, string action, - decimal quantity, decimal price, string? ibkrOrderId = null, string? notes = null) + decimal quantity, decimal price, string? ibkrOrderId = null, string? notes = null, + string? signalId = null) { await using var db = await _dbf.CreateDbContextAsync(); db.TradeHistory.Add(new CoreTrade @@ -31,6 +32,7 @@ public class TradeHistoryService Price = price, TotalValue = quantity * price, TradedAt = DateTime.UtcNow, + SignalId = signalId, IbkrOrderId = ibkrOrderId, Status = "Executed", Notes = notes, diff --git a/src/IBKRTrader.Core/Trading/TradingModels.cs b/src/IBKRTrader.Core/Trading/TradingModels.cs index 044fb86..7de0db6 100644 --- a/src/IBKRTrader.Core/Trading/TradingModels.cs +++ b/src/IBKRTrader.Core/Trading/TradingModels.cs @@ -15,6 +15,12 @@ public enum TradingMode { Paper, Live } /// public sealed record TradeSignal { + /// + /// Korrelations-ID der Signal-Kette (Signal → Entscheidung(en) → Order(s) → Trade). Standardmäßig + /// neu erzeugt; ein Modul kann eine eigene ID setzen, um mehrere Signale zu verknüpfen. + /// + public string SignalId { get; init; } = Guid.NewGuid().ToString("N"); + /// Ticker-Symbol (z. B. "AAPL"). public required string Symbol { get; init; } diff --git a/src/IBKRTrader.Modules.Accounting/AccountingModule.cs b/src/IBKRTrader.Modules.Accounting/AccountingModule.cs new file mode 100644 index 0000000..34caf1a --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/AccountingModule.cs @@ -0,0 +1,70 @@ +using IBKRTrader.Core.Configuration; +using IBKRTrader.Core.DependencyInjection; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Modularity; +using IBKRTrader.Modules.Accounting.Persistence; +using IBKRTrader.Modules.Accounting.Services; +using IBKRTrader.Modules.Accounting.Ui; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.Modules.Accounting; + +/// +/// Modul „Accounting": von unserer Trading-DB UNABHÄNGIGE, buchhalterisch korrekte Erfassung aller +/// Kontobewegungen (IBKR-Kontoauszug → append-only Ledger) mit Periodenabrechnung/BWA, FX (USD/EUR) und +/// CSV/PDF-Export. Reines Ingest-/Reporting-Modul, KEIN Handel. Konzept: +/// docs/konzepte/KONZEPT-Modul-Accounting.md. +/// +/// Live-Abruf (IBKR Flex Query) liegt hinter Interfaces mit Null-Stubs → das Modul läuft offline und +/// bucht dann korrekt nichts. Die konkrete Steuerschicht ist bewusst offen (neutraler Ledger gilt +/// unabhängig davon). +/// +public sealed class AccountingModule : IModule +{ + public string Name => "Accounting"; + public string DbPrefix => "acc_"; + + public void RegisterServices(IServiceCollection services, IConfiguration configuration) + { + var conn = ServiceCollectionExtensions.EffectiveConnectionString(configuration["Database:MySqlConnectionString"]); + services.AddDbContextFactory(o => o.UseMySql(conn, DatabaseServerVersion.Value)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + + // Ingest-Quellen: Offline-Null-Stubs. Im Zielland werden die echten IBKR-Flex-Quellen registriert. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + } + + public void RegisterUi(IModuleUiHost host, IServiceProvider services) + { + host.RegisterView(new ModuleView + { + Id = "accounting.main", + Title = "Accounting", + Group = Name, + Order = 400, + CreateForm = () => new AccountingMainForm( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()) + }); + } + + // DB-Schema wird extern per `dotnet ef database update` angewendet (keine Laufzeit-Migration). + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj b/src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj new file mode 100644 index 0000000..4c26bd9 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj @@ -0,0 +1,33 @@ + + + + net10.0-windows + enable + enable + + true + en + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + <_Parameter1>IBKRTrader.Tests + + + + diff --git a/src/IBKRTrader.Modules.Accounting/Logic/AccountingClassifier.cs b/src/IBKRTrader.Modules.Accounting/Logic/AccountingClassifier.cs new file mode 100644 index 0000000..cf70509 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Logic/AccountingClassifier.cs @@ -0,0 +1,88 @@ +using IBKRTrader.Modules.Accounting.Models; + +namespace IBKRTrader.Modules.Accounting.Logic; + +/// +/// Reine, seiteneffektfreie Klassifikation roher IBKR-Auszugszeilen → normalisierter Buchungssatz +/// (Typ, Vorzeichen, Idempotenz-Schlüssel). Geldkritisch → vollständig unit-getestet. Enthält keine +/// I/O; die Datenbeschaffung liegt in den Ingest-Services hinter Interfaces. +/// +/// Vorzeichenkonvention : Cash-Wirkung aufs Konto (+ Zufluss / − Abfluss). +/// Annahme (im Export dokumentiert): BUY kostet Brutto+Kommission, SELL bringt Brutto−Kommission. +/// +public static class AccountingClassifier +{ + public static LedgerEntry ClassifyExecution(RawExecution e, long ingestBatchId) + { + bool isSell = string.Equals(e.Side?.Trim(), "SELL", StringComparison.OrdinalIgnoreCase); + LedgerEventType type = isSell ? LedgerEventType.TradeSell : LedgerEventType.TradeBuy; + + decimal gross = Math.Abs(e.GrossBase); + decimal fee = Math.Abs(e.FeeBase); + decimal net = isSell ? gross - fee : -(gross + fee); + + return new LedgerEntry + { + AccountId = e.AccountId, + EventType = type, + Timestamp = e.Timestamp, + Symbol = e.Symbol, + AssetClass = e.AssetClass, + Currency = e.Currency, + Side = isSell ? "SELL" : "BUY", + Quantity = e.Quantity, + PriceNative = e.PriceNative, + GrossBase = gross, + FeeBase = fee, + NetBase = net, + TransactionId = e.TradeId, + Source = "ibkr-flex", + IngestBatchId = ingestBatchId, + IdempotencyKey = $"TRD|{type}|{e.TradeId.Trim()}" + }; + } + + public static LedgerEntry ClassifyCashTransaction(RawCashTransaction t, long ingestBatchId) + { + LedgerEventType type = MapCashType(t.Type); + decimal amount = t.AmountBase; // signiert wie im Auszug + decimal gross = Math.Abs(amount); + + // Ein-/Auszahlung: Richtung bestimmt das Vorzeichen (IBKR-Kategorie oft kombiniert "Deposits/Withdrawals"). + if (type is LedgerEventType.Deposit or LedgerEventType.Withdrawal) + type = amount >= 0 ? LedgerEventType.Deposit : LedgerEventType.Withdrawal; + + return new LedgerEntry + { + AccountId = t.AccountId, + EventType = type, + Timestamp = t.Timestamp, + Symbol = t.Symbol, + Currency = t.Currency, + GrossBase = gross, + FeeBase = type == LedgerEventType.Fee ? gross : 0m, + NetBase = amount, + TransactionId = t.TransactionId, + Source = "ibkr-flex", + IngestBatchId = ingestBatchId, + IdempotencyKey = $"CASH|{type}|{t.TransactionId.Trim()}" + }; + } + + /// Σ NetBase – die Buchhaltungs-Sicht des Kontosaldos (für den Balance-Anker-Abgleich). + public static decimal SumNet(IEnumerable entries) => entries.Sum(e => e.NetBase); + + // ----- intern ----- + + internal static LedgerEventType MapCashType(string? ibkrType) + { + string t = (ibkrType ?? string.Empty).Trim().ToUpperInvariant(); + if (t.Contains("WITHHOLDING") || t.Contains("QUELLENSTEUER")) return LedgerEventType.TaxWithholding; + if (t.Contains("DIVIDEND")) return LedgerEventType.Dividend; + if (t.Contains("INTEREST") || t.Contains("ZINS")) return LedgerEventType.Interest; + if (t.Contains("DEPOSIT") || t.Contains("EINZAHLUNG")) return LedgerEventType.Deposit; + if (t.Contains("WITHDRAWAL") || t.Contains("AUSZAHLUNG")) return LedgerEventType.Withdrawal; + if (t.Contains("FEE") || t.Contains("COMMISSION") || t.Contains("GEBÜHR")) return LedgerEventType.Fee; + return LedgerEventType.Other; + } +} diff --git a/src/IBKRTrader.Modules.Accounting/Logic/AccountingEngine.cs b/src/IBKRTrader.Modules.Accounting/Logic/AccountingEngine.cs new file mode 100644 index 0000000..3f1a5d4 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Logic/AccountingEngine.cs @@ -0,0 +1,89 @@ +using IBKRTrader.Modules.Accounting.Models; + +namespace IBKRTrader.Modules.Accounting.Logic; + +/// +/// Neutrale Periodenabrechnung: aggregiert die Ledger-Sätze eines Zeitraums (× Account oder alle) zu +/// einer prüfbaren Aufstellung — länderneutral, ohne steuerliche Einordnung (die käme getrennt in einer +/// späteren Steuerschicht). Alle Beträge in Kontobasiswährung; die EUR-Umrechnung liegt separat im +/// . Vorbild: PolytraderSharp AccountingEngine. +/// +public sealed record PeriodStatement( + string? AccountId, + DateTime From, + DateTime To, + decimal OpeningBalance, + decimal ClosingBalance, + decimal Deposits, + decimal Withdrawals, + decimal TradeVolume, + decimal Dividends, + decimal Interest, + decimal Fees, + decimal TaxWithheld, + decimal NetTradingResult, // operatives Ergebnis (Cash-Basis, EXKL. Ein-/Auszahlungen) + int TradeCount, + int EntryCount) +{ + /// Invariante: Endsaldo − Anfangssaldo = Ergebnis + Einzahlungen − Auszahlungen. + public decimal BalanceChange => ClosingBalance - OpeningBalance; +} + +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, string? accountId) + { + var list = allUpToTo.Where(e => e.Timestamp <= to).ToList(); + + decimal opening = list.Where(e => e.Timestamp < from).Sum(e => e.NetBase); + var period = list.Where(e => e.Timestamp >= from && e.Timestamp <= to).ToList(); + + decimal SumWhere(Func pred, Func sel) => + period.Where(pred).Sum(sel); + + decimal deposits = SumWhere(e => e.EventType == LedgerEventType.Deposit, e => e.GrossBase); + decimal withdrawals = SumWhere(e => e.EventType == LedgerEventType.Withdrawal, e => e.GrossBase); + decimal tradeVolume = SumWhere(e => e.EventType is LedgerEventType.TradeBuy or LedgerEventType.TradeSell, e => e.GrossBase); + decimal dividends = SumWhere(e => e.EventType == LedgerEventType.Dividend, e => e.NetBase); + decimal interest = SumWhere(e => e.EventType == LedgerEventType.Interest, e => e.NetBase); + decimal taxWithheld = SumWhere(e => e.EventType == LedgerEventType.TaxWithholding, e => e.GrossBase); + decimal fees = period.Sum(e => e.FeeBase); + decimal netTrading = period.Where(e => !IsCashflowType(e.EventType)).Sum(e => e.NetBase); + decimal closing = opening + period.Sum(e => e.NetBase); + int tradeCount = period.Count(e => e.EventType is LedgerEventType.TradeBuy or LedgerEventType.TradeSell); + + return new PeriodStatement(accountId, from, to, opening, closing, deposits, withdrawals, + tradeVolume, dividends, interest, fees, taxWithheld, netTrading, tradeCount, period.Count); + } + + /// + /// Zerlegt den Zeitraum in Kalendermonate und liefert je Monat eine Abrechnung (für den + /// Perioden-/Monatsvergleich). Anfangssaldo jedes Monats = Endsaldo des Vormonats. + /// + public static List BuildMonthlyBreakdown( + IEnumerable allUpToTo, DateTime from, DateTime to, string? 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/IBKRTrader.Modules.Accounting/Logic/CsvExporter.cs b/src/IBKRTrader.Modules.Accounting/Logic/CsvExporter.cs new file mode 100644 index 0000000..77f343f --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Logic/CsvExporter.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using System.Text; +using IBKRTrader.Modules.Accounting.Models; + +namespace IBKRTrader.Modules.Accounting.Logic; + +/// +/// Reiner CSV-Export: 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.Append("Timestamp,AccountId,EventType,Side,Symbol,AssetClass,Currency,Quantity,PriceNative,GrossBase,FeeBase,NetBase,TransactionId,Source,IdempotencyKey\n"); + foreach (var e in entries.OrderBy(e => e.Timestamp)) + { + sb.Append(T(e.Timestamp)).Append(',') + .Append(Q(e.AccountId)).Append(',') + .Append(e.EventType).Append(',') + .Append(Q(e.Side)).Append(',') + .Append(Q(e.Symbol)).Append(',') + .Append(Q(e.AssetClass)).Append(',') + .Append(Q(e.Currency)).Append(',') + .Append(F(e.Quantity)).Append(',') + .Append(F(e.PriceNative)).Append(',') + .Append(F(e.GrossBase)).Append(',') + .Append(F(e.FeeBase)).Append(',') + .Append(F(e.NetBase)).Append(',') + .Append(Q(e.TransactionId)).Append(',') + .Append(Q(e.Source)).Append(',') + .Append(Q(e.IdempotencyKey)).Append('\n'); + } + return sb.ToString(); + } + + /// Aggregat-Export einer Abrechnung (Kennzahl,Wert) – prüfbare Zusammenfassung. + public static string Statement(PeriodStatement s, string currencyCode = "USD") + { + var sb = new StringBuilder(); + sb.Append("Kennzahl,").Append(Q(currencyCode)).Append('\n'); + void Row(string k, decimal v) => sb.Append(Q(k)).Append(',').Append(F(v)).Append('\n'); + sb.Append(Q($"Zeitraum {T(s.From)} .. {T(s.To)}" + (s.AccountId is null ? " | alle Konten" : $" | Konto {s.AccountId}"))).Append(",\n"); + Row("Anfangssaldo", s.OpeningBalance); + Row("Einzahlungen", s.Deposits); + Row("Auszahlungen", s.Withdrawals); + Row("Handelsvolumen", s.TradeVolume); + Row("Dividenden", s.Dividends); + Row("Zinsen", s.Interest); + Row("Fees", s.Fees); + Row("Quellensteuer", s.TaxWithheld); + Row("Netto-Handelsergebnis (Cash)", s.NetTradingResult); + Row("Endsaldo", s.ClosingBalance); + 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/IBKRTrader.Modules.Accounting/Logic/FxConverter.cs b/src/IBKRTrader.Modules.Accounting/Logic/FxConverter.cs new file mode 100644 index 0000000..ec582a2 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Logic/FxConverter.cs @@ -0,0 +1,39 @@ +using IBKRTrader.Modules.Accounting.Models; + +namespace IBKRTrader.Modules.Accounting.Logic; + +/// +/// Reine FX-Umrechnung der Basiswährung (USD) → EUR über amtliche Tageskurse (acc_fx_rates, +/// EZB-Referenzkurs). „Nearest-on-or-before": zu einem Datum gilt der jüngste Kurs an oder vor diesem +/// Tag (Wochenenden/Feiertage). Gibt es keinen passenden Kurs, ist der EUR-Wert nicht bestimmbar (null). +/// +public sealed class FxConverter +{ + // aufsteigend nach Datum sortierte Kurse + private readonly List _rates; + + public FxConverter(IEnumerable rates) + { + _rates = rates.OrderBy(r => r.Date.Date).ToList(); + } + + /// USD→EUR-Kurs, der an oder vor gültig war (null, wenn keiner existiert). + public decimal? UsdToEurOn(DateTime date) + { + decimal? rate = null; + var day = date.Date; + foreach (var r in _rates) + { + if (r.Date.Date <= day) rate = r.UsdToEur; + else break; + } + return rate; + } + + /// Rechnet einen USD-Betrag zum Kurs des Datums in EUR um (null, wenn kein Kurs vorliegt). + public decimal? UsdToEur(decimal usdAmount, DateTime date) + { + var rate = UsdToEurOn(date); + return rate.HasValue ? decimal.Round(usdAmount * rate.Value, 2, MidpointRounding.AwayFromZero) : null; + } +} diff --git a/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs b/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs new file mode 100644 index 0000000..5289444 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs @@ -0,0 +1,162 @@ +using System.Security.Cryptography; +using System.Text; +using IBKRTrader.Modules.Accounting.Models; +using MigraDoc.DocumentObjectModel; +using MigraDoc.DocumentObjectModel.Tables; +using MigraDoc.Rendering; + +namespace IBKRTrader.Modules.Accounting.Logic; + +/// +/// PDF-Export der neutralen Abrechnung via PDFsharp/MigraDoc (MIT, keine Umsatzschwelle). Kopf + +/// Aggregat-Tabelle + Monatsvergleich + Transaktionsliste + Methodik-/Nachweis-Seite. Länderneutral +/// (KEINE steuerliche Einordnung). Beträge in der übergebenen Anzeige-Währung; die Umrechnung erfolgt +/// außerhalb (Services), hier nur der Faktor. +/// +public static class PdfExporter +{ + public static byte[] Render( + PeriodStatement statement, + IReadOnlyList monthly, + IReadOnlyList periodEntries, + string currencyCode, decimal currencyFactor, string currencyNote) + { + decimal V(decimal baseAmount) => Math.Round(baseAmount * currencyFactor, 2, MidpointRounding.AwayFromZero); + string M(decimal baseAmount) => V(baseAmount).ToString("N2") + " " + currencyCode; + + var doc = new Document(); + doc.Info.Title = "Buchhalterische Abrechnung"; + var style = doc.Styles["Normal"]!; + style.Font.Name = "Segoe UI"; + style.Font.Size = 9; + + var section = doc.AddSection(); + section.PageSetup.Orientation = MigraDoc.DocumentObjectModel.Orientation.Landscape; + section.PageSetup.LeftMargin = Unit.FromCentimeter(1.5); + section.PageSetup.RightMargin = Unit.FromCentimeter(1.5); + + // ---- Kopf ---- + var head = section.AddParagraph("Buchhalterische Abrechnung (neutral)"); + head.Format.Font.Size = 16; head.Format.Font.Bold = true; + head.Format.SpaceAfter = Unit.FromMillimeter(2); + + var meta = section.AddParagraph(); + meta.Format.SpaceAfter = Unit.FromMillimeter(4); + meta.AddText($"Konto: {statement.AccountId ?? "alle Konten"}"); + meta.AddLineBreak(); + meta.AddText($"Zeitraum: {statement.From:yyyy-MM-dd} bis {statement.To:yyyy-MM-dd}"); + meta.AddLineBreak(); + meta.AddText($"Währung: {currencyCode} ({currencyNote})"); + meta.AddLineBreak(); + meta.AddText($"Erstellt: {DateTime.Now:yyyy-MM-dd HH:mm}"); + + // ---- Aggregat ---- + AddSectionTitle(section, "Zusammenfassung"); + var agg = NewTable(section, new[] { 8.0, 6.0 }); + AddKeyValue(agg, "Anfangssaldo", M(statement.OpeningBalance)); + AddKeyValue(agg, "Einzahlungen", M(statement.Deposits)); + AddKeyValue(agg, "Auszahlungen", M(statement.Withdrawals)); + AddKeyValue(agg, "Handelsvolumen", M(statement.TradeVolume)); + AddKeyValue(agg, "Dividenden", M(statement.Dividends)); + AddKeyValue(agg, "Zinsen", M(statement.Interest)); + AddKeyValue(agg, "Fees", M(statement.Fees)); + AddKeyValue(agg, "Quellensteuer", M(statement.TaxWithheld)); + AddKeyValue(agg, "Netto-Handelsergebnis (Cash-Basis)", M(statement.NetTradingResult)); + AddKeyValue(agg, "Endsaldo", M(statement.ClosingBalance)); + AddKeyValue(agg, "Anzahl Trades", statement.TradeCount.ToString()); + AddKeyValue(agg, "Anzahl Buchungen", statement.EntryCount.ToString()); + + // ---- Monatsvergleich ---- + if (monthly.Count > 1) + { + AddSectionTitle(section, "Monatsvergleich"); + var mt = NewTable(section, new[] { 3.0, 3.5, 3.5, 3.5, 3.5, 3.5, 3.5, 3.0 }); + HeaderRow(mt, "Monat", "Anfang", "Einz.", "Ausz.", "Volumen", "Fees", "Ergebnis", "Endsaldo"); + foreach (var m in monthly) + DataRow(mt, m.From.ToString("yyyy-MM"), M(m.OpeningBalance), M(m.Deposits), M(m.Withdrawals), + M(m.TradeVolume), M(m.Fees), M(m.NetTradingResult), M(m.ClosingBalance)); + } + + // ---- Transaktionsliste ---- + AddSectionTitle(section, $"Transaktionen ({periodEntries.Count})"); + var lt = NewTable(section, new[] { 3.5, 3.0, 2.0, 3.0, 2.5, 2.5, 2.5, 4.0 }); + HeaderRow(lt, "Zeit (UTC)", "Typ", "Side", "Symbol", "Menge", "Preis", "Netto", "Transaktion"); + foreach (var e in periodEntries.OrderBy(e => e.Timestamp)) + DataRow(lt, e.Timestamp.ToString("yyyy-MM-dd HH:mm"), e.EventType.ToString(), e.Side, + Trim(e.Symbol, 12), e.Quantity.ToString("0.###"), e.PriceNative.ToString("0.###"), + M(e.NetBase), Trim(e.TransactionId, 22)); + + // ---- Methodik / Nachweis ---- + AddSectionTitle(section, "Methodik & Nachweis"); + var method = section.AddParagraph(); + method.Format.Font.Size = 8; + method.AddText("• Buchungsgrundlage sind ausschließlich unabhängige IBKR-Kontoauszüge (Activity Flex Query), nicht unsere Trading-DB; append-only."); + method.AddLineBreak(); + method.AddText("• Netto-Handelsergebnis ist Cash-Basis (Erlöse − Kosten − Fees) und schließt Ein-/Auszahlungen aus."); + method.AddLineBreak(); + method.AddText($"• Währungsumrechnung: {currencyNote}"); + method.AddLineBreak(); + method.AddText("• Dies ist eine neutrale, prüfbare Aufstellung und KEINE Steuerberatung. Eine steuerliche Einordnung erfolgt getrennt."); + method.AddLineBreak(); + method.AddText($"• Daten-Hash (SHA-256 über Ledger+Aggregat): {DataHash(statement, periodEntries)}"); + + var renderer = new PdfDocumentRenderer { Document = doc }; + renderer.RenderDocument(); + using var ms = new MemoryStream(); + renderer.PdfDocument.Save(ms, false); + return ms.ToArray(); + } + + // ---- MigraDoc-Helfer ---- + + private static void AddSectionTitle(Section s, string text) + { + var p = s.AddParagraph(text); + p.Format.Font.Size = 12; p.Format.Font.Bold = true; + p.Format.SpaceBefore = Unit.FromMillimeter(4); p.Format.SpaceAfter = Unit.FromMillimeter(1); + } + + private static Table NewTable(Section s, double[] widthsCm) + { + var t = s.AddTable(); + t.Borders.Width = 0.25; t.Borders.Color = Colors.LightGray; + foreach (var w in widthsCm) t.AddColumn(Unit.FromCentimeter(w)); + return t; + } + + private static void AddKeyValue(Table t, string key, string value) + { + var r = t.AddRow(); + r.Cells[0].AddParagraph(key); + var vp = r.Cells[1].AddParagraph(value); + vp.Format.Alignment = ParagraphAlignment.Right; + } + + private static void HeaderRow(Table t, params string[] cells) + { + var r = t.AddRow(); + r.Shading.Color = Colors.WhiteSmoke; + for (int i = 0; i < cells.Length; i++) + { + var p = r.Cells[i].AddParagraph(cells[i]); + p.Format.Font.Bold = true; + } + } + + private static void DataRow(Table t, params string[] cells) + { + var r = t.AddRow(); + for (int i = 0; i < cells.Length; i++) + r.Cells[i].AddParagraph(cells[i] ?? string.Empty); + } + + private static string Trim(string s, int max) => + string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s[..max] + "…"); + + private static string DataHash(PeriodStatement s, IReadOnlyList entries) + { + string material = CsvExporter.Statement(s) + CsvExporter.Ledger(entries); + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(material)); + return Convert.ToHexString(hash)[..16].ToLowerInvariant(); + } +} diff --git a/src/IBKRTrader.Modules.Accounting/Migrations/20260730165418_InitialAccounting.Designer.cs b/src/IBKRTrader.Modules.Accounting/Migrations/20260730165418_InitialAccounting.Designer.cs new file mode 100644 index 0000000..9abc12b --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Migrations/20260730165418_InitialAccounting.Designer.cs @@ -0,0 +1,236 @@ +// +using System; +using IBKRTrader.Modules.Accounting.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IBKRTrader.Modules.Accounting.Migrations +{ + [DbContext(typeof(AccountingDbContext))] + [Migration("20260730165418_InitialAccounting")] + partial class InitialAccounting + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("IBKRTrader.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("IBKRTrader.Modules.Accounting.Models.IngestRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Backfill") + .HasColumnType("tinyint(1)"); + + b.Property("BalanceAnchorBase") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("BalanceDeltaBase") + .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("LedgerNetBase") + .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("IBKRTrader.Modules.Accounting.Models.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AssetClass") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("FeeBase") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("GrossBase") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("IngestBatchId") + .HasColumnType("bigint"); + + b.Property("IngestedAt") + .HasColumnType("datetime(6)"); + + b.Property("NetBase") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("PriceNative") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Quantity") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("TransactionId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("IdempotencyKey") + .IsUnique(); + + b.HasIndex("AccountId", "Timestamp"); + + b.ToTable("acc_ledger", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Modules.Accounting.Models.RawSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + 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/IBKRTrader.Modules.Accounting/Migrations/20260730165418_InitialAccounting.cs b/src/IBKRTrader.Modules.Accounting/Migrations/20260730165418_InitialAccounting.cs new file mode 100644 index 0000000..72c68b3 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Migrations/20260730165418_InitialAccounting.cs @@ -0,0 +1,163 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IBKRTrader.Modules.Accounting.Migrations +{ + /// + public partial class InitialAccounting : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + 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"); + + migrationBuilder.CreateTable( + name: "acc_ingest_runs", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + AccountId = table.Column(type: "varchar(30)", maxLength: 30, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Backfill = table.Column(type: "tinyint(1)", nullable: false), + StartedAt = table.Column(type: "datetime(6)", nullable: false), + FinishedAt = table.Column(type: "datetime(6)", nullable: true), + FromTimestamp = table.Column(type: "datetime(6)", nullable: true), + NewEntries = table.Column(type: "int", nullable: false), + DuplicateEntries = table.Column(type: "int", nullable: false), + Success = table.Column(type: "tinyint(1)", nullable: false), + Message = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + BalanceAnchorBase = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: true), + LedgerNetBase = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: true), + BalanceDeltaBase = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_acc_ingest_runs", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "acc_ledger", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + AccountId = table.Column(type: "varchar(30)", maxLength: 30, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + EventType = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Timestamp = table.Column(type: "datetime(6)", nullable: false), + Symbol = table.Column(type: "varchar(30)", maxLength: 30, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + AssetClass = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Currency = table.Column(type: "varchar(5)", maxLength: 5, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Side = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Quantity = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false), + PriceNative = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false), + GrossBase = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false), + FeeBase = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false), + NetBase = table.Column(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false), + TransactionId = table.Column(type: "varchar(60)", maxLength: 60, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Source = table.Column(type: "varchar(40)", maxLength: 40, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + IngestBatchId = table.Column(type: "bigint", nullable: false), + IngestedAt = table.Column(type: "datetime(6)", nullable: false), + IdempotencyKey = table.Column(type: "varchar(120)", maxLength: 120, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_acc_ledger", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "acc_raw", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + IngestRunId = table.Column(type: "bigint", nullable: false), + AccountId = table.Column(type: "varchar(30)", maxLength: 30, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SourceKind = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Json = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + CapturedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_acc_raw", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_acc_ingest_runs_AccountId_StartedAt", + table: "acc_ingest_runs", + columns: new[] { "AccountId", "StartedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_acc_ledger_AccountId_Timestamp", + table: "acc_ledger", + columns: new[] { "AccountId", "Timestamp" }); + + migrationBuilder.CreateIndex( + name: "IX_acc_ledger_EventType", + table: "acc_ledger", + column: "EventType"); + + migrationBuilder.CreateIndex( + name: "IX_acc_ledger_IdempotencyKey", + table: "acc_ledger", + column: "IdempotencyKey", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_acc_raw_IngestRunId", + table: "acc_raw", + column: "IngestRunId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "acc_fx_rates"); + + migrationBuilder.DropTable( + name: "acc_ingest_runs"); + + migrationBuilder.DropTable( + name: "acc_ledger"); + + migrationBuilder.DropTable( + name: "acc_raw"); + } + } +} diff --git a/src/IBKRTrader.Modules.Accounting/Migrations/AccountingDbContextModelSnapshot.cs b/src/IBKRTrader.Modules.Accounting/Migrations/AccountingDbContextModelSnapshot.cs new file mode 100644 index 0000000..c28ce0e --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Migrations/AccountingDbContextModelSnapshot.cs @@ -0,0 +1,233 @@ +// +using System; +using IBKRTrader.Modules.Accounting.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IBKRTrader.Modules.Accounting.Migrations +{ + [DbContext(typeof(AccountingDbContext))] + partial class AccountingDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("IBKRTrader.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("IBKRTrader.Modules.Accounting.Models.IngestRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Backfill") + .HasColumnType("tinyint(1)"); + + b.Property("BalanceAnchorBase") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("BalanceDeltaBase") + .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("LedgerNetBase") + .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("IBKRTrader.Modules.Accounting.Models.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AssetClass") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("FeeBase") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("GrossBase") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("IngestBatchId") + .HasColumnType("bigint"); + + b.Property("IngestedAt") + .HasColumnType("datetime(6)"); + + b.Property("NetBase") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("PriceNative") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Quantity") + .HasPrecision(28, 8) + .HasColumnType("decimal(28,8)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("TransactionId") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("IdempotencyKey") + .IsUnique(); + + b.HasIndex("AccountId", "Timestamp"); + + b.ToTable("acc_ledger", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Modules.Accounting.Models.RawSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + 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/IBKRTrader.Modules.Accounting/Models/FxRate.cs b/src/IBKRTrader.Modules.Accounting/Models/FxRate.cs new file mode 100644 index 0000000..ad1c123 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Models/FxRate.cs @@ -0,0 +1,13 @@ +namespace IBKRTrader.Modules.Accounting.Models; + +/// +/// Amtlicher FX-Tageskurs zur EUR-Ansicht (Tabelle acc_fx_rates). = wie viele +/// EUR ein USD am wert war (EZB-Referenzkurs, Zielland-Ingest). Basiswährung ist +/// USD; die EUR-Umrechnung liegt im (Nearest-on-or-before). +/// +public class FxRate +{ + public DateTime Date { get; set; } // nur Datum (Tag) + public decimal UsdToEur { get; set; } + public string Source { get; set; } = ""; // z. B. "ECB" +} diff --git a/src/IBKRTrader.Modules.Accounting/Models/IngestRun.cs b/src/IBKRTrader.Modules.Accounting/Models/IngestRun.cs new file mode 100644 index 0000000..b3ad1f5 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Models/IngestRun.cs @@ -0,0 +1,28 @@ +namespace IBKRTrader.Modules.Accounting.Models; + +/// +/// Protokoll eines Ingest-Laufs (Tabelle acc_ingest_runs): je Account ein Datensatz pro Abruf mit +/// Zeitraum, Ergebnis (neu/Duplikate) und Balance-Anker (Soll-Ist als Vollständigkeits-Signal). +/// +public class IngestRun +{ + public long Id { get; set; } + public string AccountId { get; set; } = ""; + public bool Backfill { get; set; } + + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + public DateTime? FinishedAt { get; set; } + public DateTime? FromTimestamp { get; set; } + + public int NewEntries { get; set; } + public int DuplicateEntries { get; set; } + public bool Success { get; set; } + public string Message { get; set; } = ""; + + /// Vom Broker gemeldeter Kontosaldo (Basiswährung), falls verfügbar. + public decimal? BalanceAnchorBase { get; set; } + /// Σ NetBase des Ledgers (Buchhaltungs-Saldo). + public decimal? LedgerNetBase { get; set; } + /// Anker − Ledger (≈ 0 = vollständig). + public decimal? BalanceDeltaBase { get; set; } +} diff --git a/src/IBKRTrader.Modules.Accounting/Models/LedgerEntry.cs b/src/IBKRTrader.Modules.Accounting/Models/LedgerEntry.cs new file mode 100644 index 0000000..b4f6ecf --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Models/LedgerEntry.cs @@ -0,0 +1,56 @@ +namespace IBKRTrader.Modules.Accounting.Models; + +/// +/// Buchungssatz-Typ im neutralen Ledger (als String persistiert – erweiterbar). Deckt die +/// IBKR-Aktienwelt ab: Trades, Dividenden, Zinsen, Gebühren, Quellensteuer und Ein-/Auszahlungen. +/// +public enum LedgerEventType +{ + TradeBuy, // Kauf-Fill: Cash raus (Kosten + Kommission) + TradeSell, // Verkauf-Fill: Cash rein (Erlös − Kommission) + Dividend, // Dividende (Einnahme) + Interest, // Broker-Zinsen (+/−) + Fee, // eigenständige Gebühr/Kommission (Ausgabe) + TaxWithholding, // einbehaltene Quellensteuer (Ausgabe) + Deposit, // Einzahlung aufs Konto (Cash rein) + Withdrawal, // Auszahlung vom Konto (Cash raus) + Other // unbekannter Typ – roh erfasst, geldneutral bis geklärt +} + +/// +/// Unveränderlicher, normalisierter Buchungssatz (Tabelle acc_ledger). Buchungsgrundlage ist +/// AUSSCHLIESSLICH die unabhängige IBKR-Quelle (Activity Flex Query), nie unsere eigene Trading-DB. +/// Jeder Satz führt über und den auf einen +/// prüfbaren Nachweis zurück; überlappende Abrufe buchen dank des Unique-Keys nicht doppelt. +/// +/// Geldbeträge liegen in der Kontobasiswährung (Flex liefert je Transaktion die Basiswährung +/// + FX-Rate); die native Handelswährung/Preis bleiben zusätzlich für den Prüf-/Detail-View erhalten. +/// Vorzeichenkonvention : Cash-Wirkung aufs Konto (+ Zufluss / − Abfluss). +/// +public class LedgerEntry +{ + public long Id { get; set; } // Autoincrement-PK + public string AccountId { get; set; } = ""; // IBKR-Kontocode (z. B. U1234567 / DU… Paper) + public LedgerEventType EventType { get; set; } + public DateTime Timestamp { get; set; } // Ereigniszeit (UTC) + + public string Symbol { get; set; } = ""; // Ticker + public string AssetClass { get; set; } = ""; // STK, OPT, … + public string Currency { get; set; } = ""; // native Handelswährung + public string Side { get; set; } = ""; // BUY/SELL bei Trades + + public decimal Quantity { get; set; } // Stück + public decimal PriceNative { get; set; } // Preis je Stück (native Währung) + public decimal GrossBase { get; set; } // absolute Bruttobewegung (Basiswährung) + public decimal FeeBase { get; set; } // Kommission/Gebühr (Basiswährung) + public decimal NetBase { get; set; } // signierte Cash-Wirkung (Basiswährung, +/−) + + public string TransactionId { get; set; } = ""; // IBKR tradeID / transactionID + public string Source { get; set; } = ""; // "ibkr-flex" + + public long IngestBatchId { get; set; } // = IngestRun.Id + public DateTime IngestedAt { get; set; } = DateTime.UtcNow; + + /// Stabiler Idempotenz-Schlüssel (unique). Gleiches Ereignis ⇒ gleicher Schlüssel. + public string IdempotencyKey { get; set; } = ""; +} diff --git a/src/IBKRTrader.Modules.Accounting/Models/RawInputs.cs b/src/IBKRTrader.Modules.Accounting/Models/RawInputs.cs new file mode 100644 index 0000000..1f0b99f --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Models/RawInputs.cs @@ -0,0 +1,43 @@ +namespace IBKRTrader.Modules.Accounting.Models; + +/// +/// Roh-Ausführung aus dem IBKR-Kontoauszug (Activity Flex Query, <Trade>). Bereits auf die +/// Buchungsfelder reduziert: native Handelswährung/Preis für den Detail-View, Brutto/Kommission +/// bereits in Kontobasiswährung (Flex liefert je Trade die FX-Rate zur Basiswährung). Die pure +/// übernimmt daraus Typ, Vorzeichen und Idempotenz-Key. +/// +public sealed record RawExecution +{ + public string AccountId { get; init; } = ""; + public string TradeId { get; init; } = ""; + public DateTime Timestamp { get; init; } + public string Symbol { get; init; } = ""; + public string AssetClass { get; init; } = ""; + public string Currency { get; init; } = ""; // native + public string Side { get; init; } = ""; // BUY / SELL + public decimal Quantity { get; init; } + public decimal PriceNative { get; init; } + public decimal GrossBase { get; init; } // absolute Notional in Basiswährung + public decimal FeeBase { get; init; } // Kommission (Basiswährung, absolut) + + /// Rohzeile (JSON/XML) für den Nachweis-Snapshot. + public string RawJson { get; init; } = ""; +} + +/// +/// Roh-Kassenbewegung aus dem IBKR-Kontoauszug (<CashTransaction>): Dividenden, Quellensteuer, +/// Zinsen, Ein-/Auszahlungen, Gebühren. ist der signierte Betrag in +/// Kontobasiswährung, wie im Auszug ausgewiesen (+ Zufluss / − Abfluss). +/// +public sealed record RawCashTransaction +{ + public string AccountId { get; init; } = ""; + public string TransactionId { get; init; } = ""; + public DateTime Timestamp { get; init; } + public string Type { get; init; } = ""; // IBKR-Typ ("Dividends", "Withholding Tax", …) + public string Symbol { get; init; } = ""; + public string Currency { get; init; } = ""; + public decimal AmountBase { get; init; } // signierter Betrag (Basiswährung) + + public string RawJson { get; init; } = ""; +} diff --git a/src/IBKRTrader.Modules.Accounting/Models/RawSnapshot.cs b/src/IBKRTrader.Modules.Accounting/Models/RawSnapshot.cs new file mode 100644 index 0000000..a15a804 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Models/RawSnapshot.cs @@ -0,0 +1,15 @@ +namespace IBKRTrader.Modules.Accounting.Models; + +/// +/// Rohdaten-Schnappschuss je Ingest-Batch (Tabelle acc_raw): die unveränderte Quell-Antwort als +/// Nachweis + für Reproduzierbarkeit, zusätzlich zu den normalisierten Ledger-Sätzen. +/// +public class RawSnapshot +{ + public long Id { get; set; } + public long IngestRunId { get; set; } + public string AccountId { get; set; } = ""; + public string SourceKind { get; set; } = ""; // "trades" / "cash" + public string Json { get; set; } = ""; + public DateTime CapturedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/IBKRTrader.Modules.Accounting/Persistence/AccountingDbContext.cs b/src/IBKRTrader.Modules.Accounting/Persistence/AccountingDbContext.cs new file mode 100644 index 0000000..5ebac0d --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Persistence/AccountingDbContext.cs @@ -0,0 +1,96 @@ +using IBKRTrader.Core.Configuration; +using IBKRTrader.Modules.Accounting.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace IBKRTrader.Modules.Accounting.Persistence; + +/// +/// EF-Kontext des Accounting-Moduls (gleiche MariaDB, Tabellen mit Präfix acc_). Append-only Ledger mit +/// Autoincrement-PKs und Unique-Index auf dem Idempotenz-Schlüssel (kein Doppel-Buchen). +/// +public class AccountingDbContext : DbContext +{ + public AccountingDbContext(DbContextOptions options) : base(options) { } + + public DbSet Ledger => Set(); + public DbSet IngestRuns => Set(); + public DbSet RawSnapshots => Set(); + public DbSet FxRates => Set(); + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("acc_ledger"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedOnAdd(); + e.Property(x => x.AccountId).HasMaxLength(30); + e.Property(x => x.EventType).HasConversion().HasMaxLength(20); + e.Property(x => x.Symbol).HasMaxLength(30); + e.Property(x => x.AssetClass).HasMaxLength(10); + e.Property(x => x.Currency).HasMaxLength(5); + e.Property(x => x.Side).HasMaxLength(10); + e.Property(x => x.Source).HasMaxLength(40); + e.Property(x => x.TransactionId).HasMaxLength(60); + e.Property(x => x.IdempotencyKey).HasMaxLength(120); + e.Property(x => x.Quantity).HasPrecision(28, 8); + e.Property(x => x.PriceNative).HasPrecision(18, 6); + e.Property(x => x.GrossBase).HasPrecision(28, 8); + e.Property(x => x.FeeBase).HasPrecision(28, 8); + e.Property(x => x.NetBase).HasPrecision(28, 8); + e.HasIndex(x => x.IdempotencyKey).IsUnique(); // Idempotenz: kein Doppel-Buchen + e.HasIndex(x => new { x.AccountId, x.Timestamp }); + e.HasIndex(x => x.EventType); + }); + + b.Entity(e => + { + e.ToTable("acc_ingest_runs"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedOnAdd(); + e.Property(x => x.AccountId).HasMaxLength(30); + e.Property(x => x.Message).HasMaxLength(1000); + e.Property(x => x.BalanceAnchorBase).HasPrecision(28, 8); + e.Property(x => x.LedgerNetBase).HasPrecision(28, 8); + e.Property(x => x.BalanceDeltaBase).HasPrecision(28, 8); + e.HasIndex(x => new { x.AccountId, x.StartedAt }); + }); + + b.Entity(e => + { + e.ToTable("acc_raw"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedOnAdd(); + e.Property(x => x.AccountId).HasMaxLength(30); + e.Property(x => x.SourceKind).HasMaxLength(20); + 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); + }); + } +} + +/// Design-Time-Factory für EF-Tooling (dotnet ef). Connection aus env IBKRTRADER_MYSQL. +public class AccountingDbContextFactory : IDesignTimeDbContextFactory +{ + public AccountingDbContext CreateDbContext(string[] args) + { + var conn = Environment.GetEnvironmentVariable("IBKRTRADER_MYSQL") + ?? "Server=localhost;Port=3306;Database=ibkrtrader;User ID=root;Password=;"; + + var options = new DbContextOptionsBuilder() + .UseMySql(conn, DatabaseServerVersion.Value) + .Options; + + return new AccountingDbContext(options); + } +} diff --git a/src/IBKRTrader.Modules.Accounting/Persistence/Repositories.cs b/src/IBKRTrader.Modules.Accounting/Persistence/Repositories.cs new file mode 100644 index 0000000..99b0438 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Persistence/Repositories.cs @@ -0,0 +1,163 @@ +using IBKRTrader.Modules.Accounting.Models; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Modules.Accounting.Persistence; + +/// Append-only Ledger-Zugriff mit idempotentem Upsert (Doppel-Buchen ausgeschlossen). +public interface ILedgerRepository +{ + /// Fügt den Satz ein, falls sein IdempotencyKey neu ist. true = neu gebucht, false = Duplikat. + bool Upsert(LedgerEntry entry); + DateTime? LatestTimestamp(string accountId); + decimal SumNet(string accountId); + int Count(string accountId); + List DistinctAccounts(); + List Query(string? accountId, DateTime? from, DateTime? to, int limit); + /// ALLE Sätze des Scopes bis (für die Abrechnung inkl. Anfangssaldo). + List GetUpTo(string? accountId, DateTime to); +} + +public interface IIngestRunRepository +{ + void Insert(IngestRun run); // setzt Id + void Update(IngestRun run); + List GetRecent(string? accountId, int limit); +} + +public interface IRawSnapshotRepository +{ + 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 sealed class EfLedgerRepository : ILedgerRepository +{ + private readonly IDbContextFactory _dbf; + public EfLedgerRepository(IDbContextFactory dbf) => _dbf = dbf; + + public bool Upsert(LedgerEntry entry) + { + using var db = _dbf.CreateDbContext(); + bool exists = db.Ledger.AsNoTracking().Any(x => x.IdempotencyKey == entry.IdempotencyKey); + if (exists) return false; + db.Ledger.Add(entry); + db.SaveChanges(); + return true; + } + + public DateTime? LatestTimestamp(string accountId) + { + using var db = _dbf.CreateDbContext(); + return db.Ledger.AsNoTracking() + .Where(x => x.AccountId == accountId) + .OrderByDescending(x => x.Timestamp) + .Select(x => (DateTime?)x.Timestamp) + .FirstOrDefault(); + } + + public decimal SumNet(string accountId) + { + using var db = _dbf.CreateDbContext(); + return db.Ledger.AsNoTracking().Where(x => x.AccountId == accountId).Sum(x => (decimal?)x.NetBase) ?? 0m; + } + + public int Count(string accountId) + { + using var db = _dbf.CreateDbContext(); + return db.Ledger.AsNoTracking().Count(x => x.AccountId == accountId); + } + + public List DistinctAccounts() + { + using var db = _dbf.CreateDbContext(); + return db.Ledger.AsNoTracking().Select(x => x.AccountId).Distinct().OrderBy(x => x).ToList(); + } + + public List Query(string? accountId, DateTime? from, DateTime? to, int limit) + { + using var db = _dbf.CreateDbContext(); + var q = db.Ledger.AsNoTracking().AsQueryable(); + if (!string.IsNullOrEmpty(accountId)) q = q.Where(x => x.AccountId == accountId); + if (from.HasValue) q = q.Where(x => x.Timestamp >= from.Value); + if (to.HasValue) q = q.Where(x => x.Timestamp <= to.Value); + return q.OrderByDescending(x => x.Timestamp).Take(limit).ToList(); + } + + public List GetUpTo(string? accountId, DateTime to) + { + using var db = _dbf.CreateDbContext(); + var q = db.Ledger.AsNoTracking().Where(x => x.Timestamp <= to); + if (!string.IsNullOrEmpty(accountId)) q = q.Where(x => x.AccountId == accountId); + return q.OrderBy(x => x.Timestamp).ToList(); + } +} + +public sealed class EfIngestRunRepository : IIngestRunRepository +{ + private readonly IDbContextFactory _dbf; + public EfIngestRunRepository(IDbContextFactory dbf) => _dbf = dbf; + + public void Insert(IngestRun run) + { + using var db = _dbf.CreateDbContext(); + db.IngestRuns.Add(run); + db.SaveChanges(); // füllt run.Id (Autoincrement) + } + + public void Update(IngestRun run) + { + using var db = _dbf.CreateDbContext(); + db.IngestRuns.Update(run); + db.SaveChanges(); + } + + public List GetRecent(string? accountId, int limit) + { + using var db = _dbf.CreateDbContext(); + var q = db.IngestRuns.AsNoTracking().AsQueryable(); + if (!string.IsNullOrEmpty(accountId)) q = q.Where(x => x.AccountId == accountId); + return q.OrderByDescending(x => x.StartedAt).Take(limit).ToList(); + } +} + +public sealed class EfRawSnapshotRepository : IRawSnapshotRepository +{ + private readonly IDbContextFactory _dbf; + public EfRawSnapshotRepository(IDbContextFactory dbf) => _dbf = dbf; + + public void Insert(RawSnapshot snapshot) + { + using var db = _dbf.CreateDbContext(); + db.RawSnapshots.Add(snapshot); + db.SaveChanges(); + } +} + +public sealed class EfFxRateRepository : IFxRateRepository +{ + private readonly IDbContextFactory _dbf; + public EfFxRateRepository(IDbContextFactory dbf) => _dbf = dbf; + + public void Upsert(FxRate rate) + { + using var db = _dbf.CreateDbContext(); + var existing = db.FxRates.Find(rate.Date.Date); + if (existing == null) db.FxRates.Add(new FxRate { Date = rate.Date.Date, UsdToEur = rate.UsdToEur, Source = rate.Source }); + else { existing.UsdToEur = rate.UsdToEur; existing.Source = rate.Source; } + db.SaveChanges(); + } + + public List GetAll() + { + using var db = _dbf.CreateDbContext(); + return db.FxRates.AsNoTracking().OrderBy(x => x.Date).ToList(); + } +} diff --git a/src/IBKRTrader.Modules.Accounting/Services/AccountingIngestService.cs b/src/IBKRTrader.Modules.Accounting/Services/AccountingIngestService.cs new file mode 100644 index 0000000..e1b1983 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Services/AccountingIngestService.cs @@ -0,0 +1,145 @@ +using IBKRTrader.Core.Logging; +using IBKRTrader.Modules.Accounting.Logic; +using IBKRTrader.Modules.Accounting.Models; +using IBKRTrader.Modules.Accounting.Persistence; +using Microsoft.Extensions.Hosting; + +namespace IBKRTrader.Modules.Accounting.Services; + +/// +/// Ingest-Orchestrierung: erhebt je Konto die unabhängige Buchungsgrundlage aus dem IBKR-Kontoauszug +/// (Flex Query), klassifiziert sie pur () und bucht sie idempotent in +/// den append-only Ledger. Protokolliert jeden Lauf (acc_ingest_runs) inkl. Balance-Anker (Soll-Ist). +/// Rein LESEND – keine Orders. Der Abruf liegt hinter Interfaces; mit den Null-Quellen läuft das Modul +/// offline (bucht korrekt nichts). Testbarer Kern: . +/// +public sealed class AccountingIngestService : BackgroundService +{ + /// Sicherheits-Überlappung gegen Auszugs-Lag beim inkrementellen Abruf. + internal const int IncrementalLookbackHours = 24; + + private static readonly TimeSpan Interval = TimeSpan.FromHours(6); + + private readonly IAccountingAccountSource _accounts; + private readonly ILedgerRepository _ledger; + private readonly IIngestRunRepository _runs; + private readonly IRawSnapshotRepository _raw; + private readonly IStatementSource _statement; + private readonly IBalanceAnchorSource _balance; + private readonly LoggingService _logger; + + public AccountingIngestService( + IAccountingAccountSource accounts, ILedgerRepository ledger, IIngestRunRepository runs, + IRawSnapshotRepository raw, IStatementSource statement, IBalanceAnchorSource balance, + LoggingService logger) + { + _accounts = accounts; + _ledger = ledger; + _runs = runs; + _raw = raw; + _statement = statement; + _balance = balance; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); } // nach Core-Init + catch (OperationCanceledException) { return; } + + _logger.Info("Accounting", "Accounting-Ingest gestartet (unabhängiger IBKR-Flex-Abruf, read-only)."); + + while (!stoppingToken.IsCancellationRequested) + { + try { await IngestAllAsync(backfill: false, stoppingToken); } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _logger.Error("Accounting", $"Accounting-Ingest Fehler: {ex.Message}", ex); } + + try { await Task.Delay(Interval, stoppingToken); } + catch (OperationCanceledException) { break; } + } + } + + /// Ein Durchlauf über alle bekannten Konten (offline: keine → nichts zu tun). + public async Task IngestAllAsync(bool backfill, CancellationToken ct) + { + var accounts = await _accounts.GetAccountIdsAsync(ct); + foreach (var accountId in accounts) + { + if (ct.IsCancellationRequested) break; + var run = await IngestAccountAsync(accountId, backfill, ct); + if (run.NewEntries > 0 || !run.Success) + _logger.Info("Accounting", $"📒 {accountId}: {run.Message}" + + (run.BalanceDeltaBase.HasValue ? $" (Balance-Δ {run.BalanceDeltaBase:F2})" : "")); + } + } + + /// + /// Testbarer Kern: erhebt + bucht einen Account, protokolliert den Lauf inkl. Balance-Anker. + /// Fehler brechen den Gesamt-Ingest nicht (im Run vermerkt). + /// + public async Task IngestAccountAsync(string accountId, bool backfill, CancellationToken ct) + { + var run = new IngestRun { AccountId = accountId, Backfill = backfill, StartedAt = DateTime.UtcNow }; + _runs.Insert(run); // Id vergeben → dient als IngestBatchId + long batchId = run.Id; + int newCount = 0, dupCount = 0; + + try + { + DateTime? since = backfill + ? null + : _ledger.LatestTimestamp(accountId)?.AddHours(-IncrementalLookbackHours); + run.FromTimestamp = since; + + // 1) Ausführungen (Käufe/Verkäufe) + var executions = await _statement.GetExecutionsAsync(accountId, since, ct); + if (executions.Count > 0) + _raw.Insert(new RawSnapshot { IngestRunId = batchId, AccountId = accountId, SourceKind = "trades", Json = SnapshotJson(executions.Select(x => x.RawJson)) }); + foreach (var x in executions) + { + var entry = AccountingClassifier.ClassifyExecution(x, batchId); + if (_ledger.Upsert(entry)) newCount++; else dupCount++; + } + + // 2) Kassenbewegungen (Dividenden, Steuer, Zinsen, Ein-/Auszahlungen) + var cash = await _statement.GetCashTransactionsAsync(accountId, since, ct); + if (cash.Count > 0) + _raw.Insert(new RawSnapshot { IngestRunId = batchId, AccountId = accountId, SourceKind = "cash", Json = SnapshotJson(cash.Select(x => x.RawJson)) }); + foreach (var c in cash) + { + var entry = AccountingClassifier.ClassifyCashTransaction(c, batchId); + if (_ledger.Upsert(entry)) newCount++; else dupCount++; + } + + // 3) Balance-Anker (Vollständigkeits-Wächter) + decimal? anchor = await _balance.GetBalanceAsync(accountId, ct); + decimal ledgerNet = _ledger.SumNet(accountId); + run.BalanceAnchorBase = anchor; + run.LedgerNetBase = ledgerNet; + run.BalanceDeltaBase = anchor.HasValue ? anchor.Value - ledgerNet : null; + + run.NewEntries = newCount; + run.DuplicateEntries = dupCount; + run.Success = true; + run.Message = $"{newCount} neu, {dupCount} Duplikate ({(backfill ? "Backfill" : "inkrementell")})."; + } + catch (Exception ex) + { + run.Success = false; + run.NewEntries = newCount; + run.DuplicateEntries = dupCount; + run.Message = $"Fehler: {ex.Message}"; + } + + run.FinishedAt = DateTime.UtcNow; + _runs.Update(run); + return run; + } + + private static string SnapshotJson(IEnumerable rawItems) + { + var items = rawItems.Where(s => !string.IsNullOrEmpty(s)).ToList(); + return items.Count == 0 ? "[]" : "[" + string.Join(",", items) + "]"; + } +} diff --git a/src/IBKRTrader.Modules.Accounting/Services/AccountingReportService.cs b/src/IBKRTrader.Modules.Accounting/Services/AccountingReportService.cs new file mode 100644 index 0000000..4b3931a --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Services/AccountingReportService.cs @@ -0,0 +1,45 @@ +using IBKRTrader.Modules.Accounting.Logic; +using IBKRTrader.Modules.Accounting.Persistence; + +namespace IBKRTrader.Modules.Accounting.Services; + +/// Anzeige-Währung einer Abrechnung: Umrechnungsfaktor (aus Basiswährung) + dokumentierter Hinweis. +public sealed record CurrencyView(string Code, decimal Factor, string Note); + +/// +/// Baut die neutrale Periodenabrechnung + Monatsvergleich (via ) und +/// stellt Währungs-Views bereit: USD (Basiswährung, Faktor 1) sofort; EUR über den EZB-Kurs am +/// Periodenende (Näherung für Aggregate, im Hinweis dokumentiert – exakte tagesgenaue Umrechnung liegt +/// im auf Transaktionsebene). +/// +public sealed class AccountingReportService +{ + private readonly ILedgerRepository _ledger; + private readonly IFxRateRepository _fx; + + public AccountingReportService(ILedgerRepository ledger, IFxRateRepository fx) + { + _ledger = ledger; + _fx = fx; + } + + public PeriodStatement BuildStatement(string? accountId, DateTime from, DateTime to) => + AccountingEngine.BuildStatement(_ledger.GetUpTo(accountId, to), from, to, accountId); + + public List BuildMonthly(string? accountId, DateTime from, DateTime to) => + AccountingEngine.BuildMonthlyBreakdown(_ledger.GetUpTo(accountId, to), from, to, accountId); + + /// Währungs-View für die übergebene Anzeige-Währung (USD/EUR), bezogen auf das Periodenende. + public CurrencyView GetCurrencyView(string code, DateTime periodEnd) + { + if (string.Equals(code, "EUR", StringComparison.OrdinalIgnoreCase)) + { + var conv = new FxConverter(_fx.GetAll()); + var rate = conv.UsdToEurOn(periodEnd); + return rate.HasValue + ? new CurrencyView("EUR", rate.Value, $"USD→EUR-Kurs am {periodEnd:yyyy-MM-dd} (EZB, Näherung für Aggregate)") + : new CurrencyView("USD", 1m, "Kein EZB-Kurs für EUR verfügbar – Anzeige in Basiswährung USD."); + } + return new CurrencyView("USD", 1m, "Basiswährung USD"); + } +} diff --git a/src/IBKRTrader.Modules.Accounting/Services/IngestSources.cs b/src/IBKRTrader.Modules.Accounting/Services/IngestSources.cs new file mode 100644 index 0000000..2f3c8b6 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Services/IngestSources.cs @@ -0,0 +1,53 @@ +using IBKRTrader.Modules.Accounting.Models; + +namespace IBKRTrader.Modules.Accounting.Services; + +/// +/// Unabhängige IBKR-Kontoauszugs-Quelle (Activity Flex Query). Interface, damit die Buchungslogik ohne +/// Live-Abruf testbar/offline lauffähig ist; die Live-Implementierung (Zielland) ruft den Flex Web +/// Service (Token + Query-Id) ab, mappt die XML auf / +/// und speichert den Rohschnappschuss. Der Flex-Abruf braucht KEINE laufende TWS-Socket-Verbindung. +/// +public interface IStatementSource +{ + /// Ausführungen ab (null = volle Historie/Backfill). + Task> GetExecutionsAsync(string accountId, DateTime? since, CancellationToken ct); + + /// Kassenbewegungen (Dividenden, Steuer, Zinsen, Ein-/Auszahlungen) ab . + Task> GetCashTransactionsAsync(string accountId, DateTime? since, CancellationToken ct); +} + +/// Kontosaldo (Basiswährung) als Balance-Anker (Soll-Ist). Live-Impl über Flex/TWS (Zielland). +public interface IBalanceAnchorSource +{ + Task GetBalanceAsync(string accountId, CancellationToken ct); +} + +/// Liefert die zu erfassenden IBKR-Kontocodes. Offline leer → der Ingest bucht nichts. +public interface IAccountingAccountSource +{ + Task> GetAccountIdsAsync(CancellationToken ct); +} + +// ---------------- Offline-Null-Stubs (Muster wie NullBrokerClient) ---------------- + +/// Das Modul läuft ohne Live-Anbindung vollständig; der Ingest bucht dann korrekt nichts. +public sealed class NullStatementSource : IStatementSource +{ + public Task> GetExecutionsAsync(string accountId, DateTime? since, CancellationToken ct) + => Task.FromResult((IReadOnlyList)Array.Empty()); + + public Task> GetCashTransactionsAsync(string accountId, DateTime? since, CancellationToken ct) + => Task.FromResult((IReadOnlyList)Array.Empty()); +} + +public sealed class NullBalanceAnchorSource : IBalanceAnchorSource +{ + public Task GetBalanceAsync(string accountId, CancellationToken ct) => Task.FromResult((decimal?)null); +} + +public sealed class NullAccountSource : IAccountingAccountSource +{ + public Task> GetAccountIdsAsync(CancellationToken ct) + => Task.FromResult((IReadOnlyList)Array.Empty()); +} diff --git a/src/IBKRTrader.Modules.Accounting/Ui/AccountingMainForm.cs b/src/IBKRTrader.Modules.Accounting/Ui/AccountingMainForm.cs new file mode 100644 index 0000000..316a7e5 --- /dev/null +++ b/src/IBKRTrader.Modules.Accounting/Ui/AccountingMainForm.cs @@ -0,0 +1,302 @@ +using IBKRTrader.Core.Logging; +using IBKRTrader.Modules.Accounting.Logic; +using IBKRTrader.Modules.Accounting.Persistence; +using IBKRTrader.Modules.Accounting.Services; + +namespace IBKRTrader.Modules.Accounting.Ui; + +/// +/// Fenster des Accounting-Moduls: Übersicht/BWA, Ledger, Steuer (Platzhalter), Abrechnung/Export, +/// Abruf/Status. Alle DB-Zugriffe laufen NUR auf Nutzer-Interaktion (nicht im Konstruktor) – so +/// konstruiert der Smoke-UI-Check das Fenster auch ohne DB fehlerfrei. +/// +public sealed class AccountingMainForm : Form +{ + private readonly ILedgerRepository _ledger; + private readonly IIngestRunRepository _runs; + private readonly AccountingReportService _report; + private readonly AccountingIngestService _ingest; + private readonly LoggingService _logger; + + private readonly DateTimePicker _from = new() { Format = DateTimePickerFormat.Short, Width = 110 }; + private readonly DateTimePicker _to = new() { Format = DateTimePickerFormat.Short, Width = 110 }; + private readonly ComboBox _account = new() { DropDownStyle = ComboBoxStyle.DropDown, Width = 140 }; + private readonly ComboBox _currency = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 80 }; + + private readonly Label _kpis = new() { AutoSize = true, Location = new Point(12, 8) }; + private readonly DataGridView _monthly = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill }; + private readonly DataGridView _ledgerGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill }; + private readonly DataGridView _runsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill }; + private readonly Label _status = new() { AutoSize = true, ForeColor = SystemColors.GrayText, Location = new Point(12, 8) }; + + public AccountingMainForm( + ILedgerRepository ledger, IIngestRunRepository runs, AccountingReportService report, + AccountingIngestService ingest, LoggingService logger) + { + _ledger = ledger; + _runs = runs; + _report = report; + _ingest = ingest; + _logger = logger; + + Text = "Accounting"; + Width = 1000; + Height = 680; + StartPosition = FormStartPosition.CenterScreen; + MinimumSize = new Size(760, 480); + + _from.Value = DateTime.Today.AddMonths(-1); + _to.Value = DateTime.Today; + _currency.Items.AddRange(new object[] { "USD", "EUR" }); + _currency.SelectedIndex = 0; + + BuildLayout(); + } + + private void BuildLayout() + { + var tabs = new TabControl { Dock = DockStyle.Fill }; + + // ── gemeinsame Filterleiste ── + var filter = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) }; + filter.Controls.Add(new Label { Text = "Von", AutoSize = true, Margin = new Padding(0, 8, 4, 0) }); + filter.Controls.Add(_from); + filter.Controls.Add(new Label { Text = "Bis", AutoSize = true, Margin = new Padding(8, 8, 4, 0) }); + filter.Controls.Add(_to); + filter.Controls.Add(new Label { Text = "Konto", AutoSize = true, Margin = new Padding(8, 8, 4, 0) }); + filter.Controls.Add(_account); + filter.Controls.Add(new Label { Text = "Währung", AutoSize = true, Margin = new Padding(8, 8, 4, 0) }); + filter.Controls.Add(_currency); + var btnRefresh = new Button { Text = "Aktualisieren", Width = 120, Margin = new Padding(12, 3, 0, 0) }; + btnRefresh.Click += (_, _) => RefreshAll(); + filter.Controls.Add(btnRefresh); + + // ── Tab: Übersicht/BWA ── + var tabOverview = new TabPage("Übersicht / BWA"); + _monthly.Top = 90; + var overviewPanel = new Panel { Dock = DockStyle.Fill }; + overviewPanel.Controls.Add(_monthly); + var kpiPanel = new Panel { Dock = DockStyle.Top, Height = 84 }; + kpiPanel.Controls.Add(_kpis); + overviewPanel.Controls.Add(kpiPanel); + tabOverview.Controls.Add(overviewPanel); + + // ── Tab: Ledger ── + var tabLedger = new TabPage("Ledger"); + tabLedger.Controls.Add(_ledgerGrid); + + // ── Tab: Steuer (Platzhalter) ── + var tabTax = new TabPage("Steuer"); + tabTax.Controls.Add(new Label + { + Dock = DockStyle.Fill, Padding = new Padding(16), + Text = "Steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt).\n\n" + + "Der neutrale Ledger und die Periodenabrechnung sind davon unabhängig gültig.\n" + + "Eine konkrete Steuerschicht (z. B. DE-Kapitalertragsteuer oder US Form 8949 / Schedule D)\n" + + "wird hier später als klar dokumentierte, prüfbare Rechenschicht ergänzt.\n\n" + + "Hinweis: Dies ist keine Steuerberatung." + }); + + // ── Tab: Abrechnung / Export ── + var tabExport = new TabPage("Abrechnung / Export"); + var exportPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), FlowDirection = FlowDirection.TopDown }; + exportPanel.Controls.Add(new Label { AutoSize = true, Text = "Exportiert die aktuelle Auswahl (Zeitraum / Konto / Währung):" }); + var btnCsvLedger = new Button { Text = "Ledger als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) }; + btnCsvLedger.Click += (_, _) => ExportCsvLedger(); + var btnCsvStmt = new Button { Text = "Abrechnung als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) }; + btnCsvStmt.Click += (_, _) => ExportCsvStatement(); + var btnPdf = new Button { Text = "Abrechnung als PDF…", Width = 180, Margin = new Padding(0, 8, 0, 0) }; + btnPdf.Click += (_, _) => ExportPdf(); + exportPanel.Controls.Add(btnCsvLedger); + exportPanel.Controls.Add(btnCsvStmt); + exportPanel.Controls.Add(btnPdf); + tabExport.Controls.Add(exportPanel); + + // ── Tab: Abruf / Status ── + var tabIngest = new TabPage("Abruf / Status"); + var ingestButtons = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) }; + var btnIncr = new Button { Text = "Inkrementell abrufen", Width = 160 }; + btnIncr.Click += async (_, _) => await RunIngest(backfill: false); + var btnBackfill = new Button { Text = "Backfill (voll)", Width = 140, Margin = new Padding(8, 0, 0, 0) }; + btnBackfill.Click += async (_, _) => await RunIngest(backfill: true); + ingestButtons.Controls.Add(btnIncr); + ingestButtons.Controls.Add(btnBackfill); + var statusPanel = new Panel { Dock = DockStyle.Top, Height = 40 }; + statusPanel.Controls.Add(_status); + _status.Text = "Offline-Standard: keine Live-Quelle registriert → der Ingest bucht nichts (korrekt)."; + tabIngest.Controls.Add(_runsGrid); + tabIngest.Controls.Add(statusPanel); + tabIngest.Controls.Add(ingestButtons); + + tabs.TabPages.AddRange(new[] { tabOverview, tabLedger, tabTax, tabExport, tabIngest }); + + Controls.Add(tabs); + Controls.Add(filter); + } + + // ── Daten laden (nur auf Interaktion) ── + + private string? SelectedAccount() + { + var text = _account.Text?.Trim(); + return string.IsNullOrWhiteSpace(text) || text == "(alle)" ? null : text; + } + + private void RefreshAll() + { + try + { + LoadAccounts(); + LoadOverview(); + LoadLedger(); + LoadRuns(); + } + catch (Exception ex) + { + _logger.Error("Accounting", $"UI-Refresh fehlgeschlagen: {ex.Message}", ex); + MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void LoadAccounts() + { + var current = _account.Text; + _account.Items.Clear(); + _account.Items.Add("(alle)"); + foreach (var a in _ledger.DistinctAccounts()) _account.Items.Add(a); + _account.Text = string.IsNullOrEmpty(current) ? "(alle)" : current; + } + + private void LoadOverview() + { + DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1); + var stmt = _report.BuildStatement(SelectedAccount(), from, to); + var view = _report.GetCurrencyView(_currency.Text, to); + decimal C(decimal v) => Math.Round(v * view.Factor, 2); + + _kpis.Text = + $"Netto-Handelsergebnis: {C(stmt.NetTradingResult):N2} {view.Code} " + + $"Handelsvolumen: {C(stmt.TradeVolume):N2} Dividenden: {C(stmt.Dividends):N2} Fees: {C(stmt.Fees):N2}\n" + + $"Einzahlungen: {C(stmt.Deposits):N2} Auszahlungen: {C(stmt.Withdrawals):N2} " + + $"Endsaldo: {C(stmt.ClosingBalance):N2} Trades: {stmt.TradeCount} Buchungen: {stmt.EntryCount}\n" + + $"{view.Note}"; + + var monthly = _report.BuildMonthly(SelectedAccount(), from, to) + .Select(m => new + { + Monat = m.From.ToString("yyyy-MM"), + Anfang = C(m.OpeningBalance), + Einzahlungen = C(m.Deposits), + Auszahlungen = C(m.Withdrawals), + Volumen = C(m.TradeVolume), + Fees = C(m.Fees), + Ergebnis = C(m.NetTradingResult), + Endsaldo = C(m.ClosingBalance) + }).ToList(); + _monthly.DataSource = monthly; + } + + private void LoadLedger() + { + DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1); + var rows = _ledger.Query(SelectedAccount(), from, to, 2000) + .Select(e => new + { + Zeit = e.Timestamp, e.AccountId, Typ = e.EventType.ToString(), e.Side, e.Symbol, + e.Currency, e.Quantity, Preis = e.PriceNative, Brutto = e.GrossBase, Fee = e.FeeBase, + Netto = e.NetBase, e.TransactionId + }).ToList(); + _ledgerGrid.DataSource = rows; + } + + private void LoadRuns() + { + var rows = _runs.GetRecent(SelectedAccount(), 100) + .Select(r => new + { + r.AccountId, Start = r.StartedAt, Ende = r.FinishedAt, r.Backfill, + Neu = r.NewEntries, Duplikate = r.DuplicateEntries, r.Success, + Anker = r.BalanceAnchorBase, LedgerNetto = r.LedgerNetBase, Delta = r.BalanceDeltaBase, r.Message + }).ToList(); + _runsGrid.DataSource = rows; + } + + // ── Export ── + + private void ExportCsvLedger() + { + DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1); + var entries = _ledger.Query(SelectedAccount(), from, to, 100000); + SaveText("ledger.csv", "CSV|*.csv", CsvExporter.Ledger(entries)); + } + + private void ExportCsvStatement() + { + DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1); + var stmt = _report.BuildStatement(SelectedAccount(), from, to); + SaveText("abrechnung.csv", "CSV|*.csv", CsvExporter.Statement(stmt)); + } + + private void ExportPdf() + { + try + { + DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1); + var account = SelectedAccount(); + var stmt = _report.BuildStatement(account, from, to); + var monthly = _report.BuildMonthly(account, from, to); + var entries = _ledger.Query(account, from, to, 100000).OrderBy(e => e.Timestamp).ToList(); + var view = _report.GetCurrencyView(_currency.Text, to); + + byte[] pdf = PdfExporter.Render(stmt, monthly, entries, view.Code, view.Factor, view.Note); + + using var dlg = new SaveFileDialog { FileName = "abrechnung.pdf", Filter = "PDF|*.pdf" }; + if (dlg.ShowDialog(this) == DialogResult.OK) + { + File.WriteAllBytes(dlg.FileName, pdf); + _logger.Info("Accounting", $"PDF-Abrechnung geschrieben: {dlg.FileName}"); + } + } + catch (Exception ex) + { + _logger.Error("Accounting", $"PDF-Export fehlgeschlagen: {ex.Message}", ex); + MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void SaveText(string suggested, string filter, string content) + { + try + { + using var dlg = new SaveFileDialog { FileName = suggested, Filter = filter }; + if (dlg.ShowDialog(this) == DialogResult.OK) + { + File.WriteAllText(dlg.FileName, content); + _logger.Info("Accounting", $"Export geschrieben: {dlg.FileName}"); + } + } + catch (Exception ex) + { + _logger.Error("Accounting", $"Export fehlgeschlagen: {ex.Message}", ex); + MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + // ── Ingest ── + + private async Task RunIngest(bool backfill) + { + try + { + _status.Text = backfill ? "Backfill läuft…" : "Inkrementeller Abruf läuft…"; + await _ingest.IngestAllAsync(backfill, CancellationToken.None); + _status.Text = $"Abruf abgeschlossen ({DateTime.Now:HH:mm:ss})."; + LoadRuns(); + } + catch (Exception ex) + { + _status.Text = $"Fehler: {ex.Message}"; + _logger.Error("Accounting", $"Manueller Ingest fehlgeschlagen: {ex.Message}", ex); + } + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Agent/ArchitectureContext.cs b/src/IBKRTrader.Modules.Supervisor/Agent/ArchitectureContext.cs new file mode 100644 index 0000000..41ca4ff --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Agent/ArchitectureContext.cs @@ -0,0 +1,53 @@ +namespace IBKRTrader.Modules.Supervisor.Agent; + +/// +/// Kuratiertes Architektur-/Verhaltensdokument von IBKRTrader – der System-Kontext des Agenten +/// („so entscheidet und handelt die Software"). Bewusst destilliertes Verhalten statt Code-Dump; bei +/// Änderungen am Geld-Pfad mitpflegen. Inline gehalten (versioniert mit dem Code). +/// +public static class ArchitectureContext +{ + public static string Load() => Text; + + private const string Text = +""" +# IBKRTrader – Architektur & Verhalten (Supervisor-Kontext) + +## Grundaufbau +- Harter Core + unabhängige Strategie-Module + Launcher. Module referenzieren nur den Core, nie einander. +- Persistenz: EF Core / MariaDB. Core-Tabellen `core_*`, je Modul eigener Präfix (`ct_`, `acc_`, `sup_`). +- Generic Host; Worker/Services laufen als IHostedService. + +## Handels-Pipeline (ExecutionService) +Module übergeben ein `TradeSignal` (Symbol, Side, SourceModule, optional LimitPrice/SuggestedNotional, +SignalId). Der Core prüft in fester Reihenfolge: +1. Globaler Hauptschalter `TradingEnabled` (Default AUS) → sonst Decision=Skipped, Reason=TradingDisabled. +2. Kurs vom Broker → fehlt er, Decision=Skipped, Reason=NoQuote. +3. Konto + bestehende Exposure/Position. +4. Risikoprüfung (RiskService) mit MaxTradePercent, MaxPositionPercentPerModule, MaxSlippagePercent → + Ablehnung: Decision=Rejected, Reason=RiskRejected (Begründung im Message/ContextJson). +5. Order platzieren (Broker). Erfolg → Decision=Executed, Reason=OrderPlaced; Fehler → Decision=Failed, + Reason=OrderFailed. Order-Events (Placed/Filled/PlaceFailed) landen in core_order_events. +6. Buchung: Fill → Position/Budget/Trade-Historie; die SignalId wird durchgereicht. + +## Sicherer Standard +Broker ist standardmäßig `NullBrokerClient` (handelt nie), bis der echte IBKR-Adapter (TWS API / IB +Gateway) verifiziert ist. Ohne `TradingEnabled=true` wird nie gehandelt. + +## Datenfundament für Analyse +- `core_decision_journal`: JEDE Entscheidung (Executed/Rejected/Skipped/Failed) mit ReasonCode, strukturiert. +- `core_order_events`: Order-Lifecycle als Daten. +- `core_trade_history`: gebuchte Fills (BUY/SELL), inkl. SignalId zur Korrelation. +- JSONL-Logs `Logs/{yyyy-MM-dd}.jsonl`: eine Zeile je Event (ts, level, source, cid=SignalId, message). +- Die SignalId verbindet Signal → Entscheidung(en) → Order(s) → Trade → Log-Zeilen (= das Dossier). + +## Realisierte GuV / KPIs +Fills werden per FIFO-Lot-Matching (RealizedPnlEngine) zu realisierten Round-Trips; daraus KPIs +(NetPnl, Winrate, ProfitFactor). Long-only-Sicht. + +## Module (aktuell) +- CongressTrading (`ct_`): kopiert US-Kongress-Aktien-Trades → TradeSignal. +- Accounting (`acc_`): unabhängiger IBKR-Kontoauszug → append-only Ledger + Abrechnung (KEIN Handel). +- Supervisor (`sup_`): DU – read-only Analyse/Forensik über alle Module. +"""; +} diff --git a/src/IBKRTrader.Modules.Supervisor/Agent/ChatModels.cs b/src/IBKRTrader.Modules.Supervisor/Agent/ChatModels.cs new file mode 100644 index 0000000..e09c1a4 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Agent/ChatModels.cs @@ -0,0 +1,33 @@ +namespace IBKRTrader.Modules.Supervisor.Agent; + +/// Chat-Nachricht im OpenAI-/OpenRouter-Schema (Rollen: system/user/assistant/tool). +public sealed class ChatMessage +{ + public string Role { get; init; } = "user"; + public string? Content { get; init; } + + /// Vom Modell angeforderte Tool-Aufrufe (nur Rolle assistant). + public List? ToolCalls { get; init; } + + /// Bezug auf den beantworteten Tool-Aufruf (nur Rolle tool). + public string? ToolCallId { get; init; } + + public static ChatMessage System(string content) => new() { Role = "system", Content = content }; + public static ChatMessage User(string content) => new() { Role = "user", Content = content }; + public static ChatMessage Assistant(string? content, List? toolCalls = null) => + new() { Role = "assistant", Content = content, ToolCalls = toolCalls }; + public static ChatMessage ToolResult(string toolCallId, string content) => + new() { Role = "tool", ToolCallId = toolCallId, Content = content }; +} + +/// Ein Tool-Aufruf des Modells (Function-Calling). +public sealed record ToolCall(string Id, string Name, string ArgumentsJson); + +/// Antwort des Modells: Text ODER Tool-Aufrufe (oder beides). +public sealed class ChatResponse +{ + public string? Content { get; init; } + public List ToolCalls { get; init; } = new(); + public int PromptTokens { get; init; } + public int CompletionTokens { get; init; } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Agent/OpenRouterClient.cs b/src/IBKRTrader.Modules.Supervisor/Agent/OpenRouterClient.cs new file mode 100644 index 0000000..988a098 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Agent/OpenRouterClient.cs @@ -0,0 +1,161 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace IBKRTrader.Modules.Supervisor.Agent; + +/// Chat-Completion-Client (Function-Calling). Interface, damit der Agent testbar ist. +public interface IChatCompletionClient +{ + Task CompleteAsync(string model, IReadOnlyList messages, + IReadOnlyList tools, CancellationToken ct); +} + +/// +/// OpenRouter-Client (OpenAI-kompatibles Chat-Completions-Schema inkl. Tools). API-Key: +/// Umgebungsvariable IBKRTRADER_OPENROUTER_KEY, sonst gitignorierte Datei openrouter.key im App-Ordner – +/// GETRENNT von künftigen Trading-Keys (Supervisor-Konzept §5). +/// SICHERHEIT: OpenRouter ist ein bewusst freigegebener externer Datenempfänger; es werden ausschließlich +/// Analyse-Daten der Tools gesendet, niemals Secrets/Keys/Connection-Strings. +/// +public sealed class OpenRouterClient : IChatCompletionClient +{ + public const string Endpoint = "https://openrouter.ai/api/v1/chat/completions"; + + private readonly HttpClient _http; + private readonly Func _apiKeyProvider; + + public OpenRouterClient(HttpClient http, Func? apiKeyProvider = null) + { + _http = http; + _apiKeyProvider = apiKeyProvider ?? DefaultApiKeyProvider; + } + + /// Key aus env IBKRTRADER_OPENROUTER_KEY, sonst aus gitignorierter openrouter.key. + public static string? DefaultApiKeyProvider() + { + string? key = Environment.GetEnvironmentVariable("IBKRTRADER_OPENROUTER_KEY"); + if (!string.IsNullOrWhiteSpace(key)) return key.Trim(); + string file = Path.Combine(AppContext.BaseDirectory, "openrouter.key"); + return File.Exists(file) ? File.ReadAllText(file).Trim() : null; + } + + public async Task CompleteAsync(string model, IReadOnlyList messages, + IReadOnlyList tools, CancellationToken ct) + { + string? apiKey = _apiKeyProvider(); + if (string.IsNullOrWhiteSpace(apiKey)) + throw new InvalidOperationException( + "Kein OpenRouter-API-Key. Setze IBKRTRADER_OPENROUTER_KEY (Umgebungsvariable) oder lege die " + + "Datei 'openrouter.key' in den App-Ordner (gitignored). Separater Key für den Supervisor empfohlen."); + + string body = BuildRequestBody(model, messages, tools); + using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint); + request.Headers.Add("Authorization", $"Bearer {apiKey}"); + request.Headers.Add("X-Title", "IBKRTrader Supervisor"); + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + + using var response = await _http.SendAsync(request, ct); + string json = await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"OpenRouter-Fehler {(int)response.StatusCode}: {Truncate(json, 500)}"); + + return ParseResponse(json); + } + + // ----- pure, testbare Serialisierung ----- + + internal static string BuildRequestBody(string model, IReadOnlyList messages, IReadOnlyList tools) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("model", model); + + w.WriteStartArray("messages"); + foreach (var m in messages) + { + w.WriteStartObject(); + w.WriteString("role", m.Role); + if (m.Content != null) w.WriteString("content", m.Content); + else w.WriteNull("content"); + if (m.ToolCalls is { Count: > 0 }) + { + w.WriteStartArray("tool_calls"); + foreach (var tc in m.ToolCalls) + { + w.WriteStartObject(); + w.WriteString("id", tc.Id); + w.WriteString("type", "function"); + w.WriteStartObject("function"); + w.WriteString("name", tc.Name); + w.WriteString("arguments", tc.ArgumentsJson); + w.WriteEndObject(); + w.WriteEndObject(); + } + w.WriteEndArray(); + } + if (m.ToolCallId != null) w.WriteString("tool_call_id", m.ToolCallId); + w.WriteEndObject(); + } + w.WriteEndArray(); + + if (tools.Count > 0) + { + w.WriteStartArray("tools"); + foreach (var t in tools) + { + w.WriteStartObject(); + w.WriteString("type", "function"); + w.WriteStartObject("function"); + w.WriteString("name", t.Name); + w.WriteString("description", t.Description); + w.WritePropertyName("parameters"); + using (var doc = JsonDocument.Parse(t.ParametersJsonSchema)) + doc.RootElement.WriteTo(w); + w.WriteEndObject(); + w.WriteEndObject(); + } + w.WriteEndArray(); + } + + w.WriteEndObject(); + } + return Encoding.UTF8.GetString(ms.ToArray()); + } + + internal static ChatResponse ParseResponse(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var message = root.GetProperty("choices")[0].GetProperty("message"); + + string? content = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String + ? c.GetString() : null; + + var toolCalls = new List(); + if (message.TryGetProperty("tool_calls", out var tcs) && tcs.ValueKind == JsonValueKind.Array) + { + foreach (var tc in tcs.EnumerateArray()) + { + string id = tc.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? "" : ""; + var fn = tc.GetProperty("function"); + toolCalls.Add(new ToolCall(id, + fn.GetProperty("name").GetString() ?? "", + fn.TryGetProperty("arguments", out var a) ? a.GetString() ?? "{}" : "{}")); + } + } + + int promptTokens = 0, completionTokens = 0; + if (root.TryGetProperty("usage", out var usage)) + { + if (usage.TryGetProperty("prompt_tokens", out var pt)) promptTokens = pt.GetInt32(); + if (usage.TryGetProperty("completion_tokens", out var ctk)) completionTokens = ctk.GetInt32(); + } + + return new ChatResponse { Content = content, ToolCalls = toolCalls, PromptTokens = promptTokens, CompletionTokens = completionTokens }; + } + + private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max] + "…"; +} diff --git a/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorAgent.cs b/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorAgent.cs new file mode 100644 index 0000000..20cdc0a --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorAgent.cs @@ -0,0 +1,106 @@ +namespace IBKRTrader.Modules.Supervisor.Agent; + +/// Ergebnis einer Agenten-Anfrage inkl. transparenter Tool-Aufruf-Historie. +public sealed class AgentResult +{ + public string Answer { get; init; } = ""; + public List<(string Tool, string Arguments, string Result)> ToolInvocations { get; init; } = new(); + public int PromptTokens { get; init; } + public int CompletionTokens { get; init; } +} + +/// +/// Der Analyse-Agent: Function-Calling-Loop gegen einen mit der +/// read-only . System-Kontext = Arbeitsanweisung + Architektur- +/// Dokument + Profil-Fokus. Harte Iterationsgrenze gegen Endlosschleifen; jeder Tool-Aufruf wird +/// festgehalten (Nachvollziehbarkeit in der UI). Nicht freigegebene Tools werden nicht ausgeführt. +/// +public sealed class SupervisorAgent +{ + public const int MaxIterations = 8; + public const string DefaultModel = "openrouter/auto"; + + private readonly IChatCompletionClient _chat; + private readonly SupervisorToolRegistry _tools; + + public SupervisorAgent(IChatCompletionClient chat, SupervisorToolRegistry tools) + { + _chat = chat; + _tools = tools; + } + + private static string SystemPrompt(SupervisorProfile profile) + { + string basePrompt = + "Du bist der Supervisor von IBKRTrader: ein Analyse-Agent für automatisierten Aktienhandel über " + + "Interactive Brokers. Du bist strikt read-only – du kannst und darfst nicht handeln. Nutze die " + + "Tools, um Entscheidungsjournal, Order-Events, Trades und Logs abzufragen, BEVOR du Schlüsse " + + "ziehst. Zitiere konkrete Daten (SignalIds, Zeiten, Preise, ReasonCodes). Antworte auf Deutsch, " + + "präzise und mit klarer Schlussfolgerung."; + if (!string.IsNullOrEmpty(profile.PromptAddendum)) + basePrompt += "\n\n" + profile.PromptAddendum; + return basePrompt + "\n\n=== ARCHITEKTUR-KONTEXT ===\n" + ArchitectureContext.Load(); + } + + private IReadOnlyList ToolsFor(SupervisorProfile profile) => + profile.ToolFilter == null + ? _tools.Tools + : _tools.Tools.Where(t => Array.Exists(profile.ToolFilter, n => + string.Equals(n, t.Name, StringComparison.OrdinalIgnoreCase))).ToList(); + + /// Beantwortet eine Analyse-Frage. meldet Tool-Aufrufe live an die UI. + public async Task AskAsync(string question, string? model = null, + IProgress? progress = null, SupervisorProfile? profile = null, CancellationToken ct = default) + { + var activeProfile = profile ?? SupervisorProfiles.Allgemein; + var activeTools = ToolsFor(activeProfile); + var allowed = activeTools.Select(t => t.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + var messages = new List + { + ChatMessage.System(SystemPrompt(activeProfile)), + ChatMessage.User(question) + }; + var invocations = new List<(string, string, string)>(); + int promptTokens = 0, completionTokens = 0; + string usedModel = string.IsNullOrWhiteSpace(model) ? DefaultModel : model.Trim(); + + for (int step = 0; step < MaxIterations; step++) + { + ct.ThrowIfCancellationRequested(); + + var response = await _chat.CompleteAsync(usedModel, messages, activeTools, ct); + promptTokens += response.PromptTokens; + completionTokens += response.CompletionTokens; + + if (response.ToolCalls.Count == 0) + { + return new AgentResult + { + Answer = response.Content ?? "(keine Antwort)", + ToolInvocations = invocations, + PromptTokens = promptTokens, + CompletionTokens = completionTokens + }; + } + + messages.Add(ChatMessage.Assistant(response.Content, response.ToolCalls)); + foreach (var call in response.ToolCalls) + { + progress?.Report($"🔧 {call.Name}({call.ArgumentsJson})"); + string result = allowed.Contains(call.Name) + ? _tools.Execute(call.Name, call.ArgumentsJson) + : $"FEHLER: Tool '{call.Name}' ist für dieses Profil nicht freigegeben."; + invocations.Add((call.Name, call.ArgumentsJson, result)); + messages.Add(ChatMessage.ToolResult(call.Id, result)); + } + } + + return new AgentResult + { + Answer = "Abbruch: maximale Tool-Iterationen erreicht (Frage ggf. eingrenzen).", + ToolInvocations = invocations, + PromptTokens = promptTokens, + CompletionTokens = completionTokens + }; + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorProfiles.cs b/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorProfiles.cs new file mode 100644 index 0000000..2cb4d5f --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorProfiles.cs @@ -0,0 +1,41 @@ +namespace IBKRTrader.Modules.Supervisor.Agent; + +/// +/// Ein Supervisor-Profil: Fokus-Anweisung + optionales Tool-Subset über EINER gemeinsamen +/// Agent-Infrastruktur (bewusst KEINE Agent-zu-Agent-Orchestrierung). Modul-Wissen kommt aus dem +/// Architektur-Kontext; hier nur der Fokus. +/// +public sealed record SupervisorProfile(string Name, string PromptAddendum, string[]? ToolFilter) +{ + public override string ToString() => Name; +} + +/// Die eingebauten Profile. +public static class SupervisorProfiles +{ + public static readonly SupervisorProfile Allgemein = new( + "Allgemein", + "", + null); + + public static readonly SupervisorProfile Technik = new( + "Technik", + "FOKUS TECHNIK-SUPERVISOR: Du prüfst ausschließlich die technische Gesundheit — Fehler-/Warning-" + + "Muster in den Logs, fehlgeschlagene/stornierte Orders, Broker-Fehlerantworten, auffällige Latenzen " + + "und Lücken in den Datenketten. KEINE Strategie-Bewertung (ob ein Trade klug war, ist nicht dein " + + "Thema — nur ob die Software korrekt funktioniert hat).", + new[] { "read_logs", "query_order_events", "query_decisions", "get_dossier", "get_architecture_context" }); + + public static readonly SupervisorProfile CongressTrading = new( + "CongressTrading", + "FOKUS CONGRESSTRADING-SUPERVISOR: Du bewertest die CongressTrading-Strategie — Qualität der " + + "kopierten Signale vs. Ausführung, Reject-Muster (haben die Risk-Limits kluge oder schädliche " + + "Entscheidungen getroffen?), realisierte GuV je Symbol. Filtere Daten auf module='CT'.", + null); + + public static IReadOnlyList All { get; } = + new[] { Allgemein, Technik, CongressTrading }; + + public static SupervisorProfile ByName(string? name) => + All.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) ?? Allgemein; +} diff --git a/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorToolRegistry.cs b/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorToolRegistry.cs new file mode 100644 index 0000000..60064f4 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorToolRegistry.cs @@ -0,0 +1,58 @@ +using System.Text.Json; + +namespace IBKRTrader.Modules.Supervisor.Agent; + +/// +/// Ein read-only-Analyse-Tool des Supervisors: Name, Beschreibung, JSON-Schema der Parameter und die +/// Ausführung. Tools LESEN ausschließlich (Journal, Events, Trades, Logs, KPIs) – es gibt bewusst keinen +/// Mechanismus, der handeln, canceln oder schreiben könnte. +/// +public sealed record SupervisorTool( + string Name, + string Description, + string ParametersJsonSchema, + Func Execute); + +/// +/// Transport-agnostische Tool-Registry: vom In-Prozess-Agenten genutzt und zusätzlich über MCP-Light +/// exponierbar. Ausführung ist fehlertolerant – eine Tool-Exception wird als Fehlertext an das Modell +/// zurückgegeben, nie geworfen. +/// +public sealed class SupervisorToolRegistry +{ + private readonly Dictionary _tools = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Tools => _tools.Values.ToList(); + + public void Register(SupervisorTool tool) => _tools[tool.Name] = tool; + + public string Execute(string name, string argumentsJson) + { + if (!_tools.TryGetValue(name, out var tool)) + return $"FEHLER: Unbekanntes Tool '{name}'. Verfügbar: {string.Join(", ", _tools.Keys)}"; + + try + { + using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + return tool.Execute(doc.RootElement.Clone()); + } + catch (JsonException ex) + { + return $"FEHLER: Ungültige Tool-Argumente (kein JSON): {ex.Message}"; + } + catch (Exception ex) + { + return $"FEHLER bei Tool '{name}': {ex.Message}"; + } + } + + // ----- Argument-Helfer für Tool-Implementierungen ----- + + public static string? GetString(JsonElement args, string name) => + args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() : null; + + public static int? GetInt(JsonElement args, string name) => + args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number + ? v.GetInt32() : (int?)null; +} diff --git a/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorTools.cs b/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorTools.cs new file mode 100644 index 0000000..10e15ff --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Agent/SupervisorTools.cs @@ -0,0 +1,201 @@ +using System.Text.Json; +using IBKRTrader.Core.Analytics; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence; +using IBKRTrader.Modules.Supervisor.Persistence; +using IBKRTrader.Modules.Supervisor.Services; + +namespace IBKRTrader.Modules.Supervisor.Agent; + +/// +/// Baut die read-only Standard-Tool-Registry des Supervisors: Zugriffe auf Entscheidungsjournal, +/// Order-Events, Trade-Log, Dossiers, JSONL-Logs, KPIs, Counterfactuals und das Architektur-Dokument. +/// Alle Ergebnisse als kompakte JSON-/Markdown-Strings. KEIN Tool kann handeln oder schreiben. +/// +public static class SupervisorTools +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + public static SupervisorToolRegistry CreateRegistry( + IDecisionJournal journal, + IOrderEventLog orderEvents, + TradeLogReader trades, + DossierService dossiers, + ISupervisorCounterfactualRepository? counterfactuals = null) + { + var reg = new SupervisorToolRegistry(); + string logsDir = Path.Combine(AppContext.BaseDirectory, "Logs"); + + reg.Register(new SupervisorTool( + "query_decisions", + "Fragt das Entscheidungsjournal ab (JEDE Handelsentscheidung inkl. Ablehnungen mit Grund). " + + "Filter optional: module, symbol, reason (z.B. RiskRejected), decision (Executed/Rejected/Skipped/Failed), sinceHours.", + """{"type":"object","properties":{"module":{"type":"string"},"symbol":{"type":"string"},"reason":{"type":"string"},"decision":{"type":"string"},"sinceHours":{"type":"integer"},"limit":{"type":"integer"}}}""", + args => + { + int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 100, 1, 500); + string? module = SupervisorToolRegistry.GetString(args, "module"); + string? symbol = SupervisorToolRegistry.GetString(args, "symbol"); + string? reason = SupervisorToolRegistry.GetString(args, "reason"); + string? decision = SupervisorToolRegistry.GetString(args, "decision"); + int? sinceHours = SupervisorToolRegistry.GetInt(args, "sinceHours"); + DateTime since = sinceHours.HasValue ? DateTime.UtcNow.AddHours(-sinceHours.Value) : DateTime.MinValue; + + var rows = journal.Query(d => + (module == null || d.Module == module) && + (symbol == null || d.Symbol == symbol) && + d.Timestamp >= since, limit * 3) + .Where(d => reason == null || string.Equals(d.Reason.ToString(), reason, StringComparison.OrdinalIgnoreCase)) + .Where(d => decision == null || string.Equals(d.Decision.ToString(), decision, StringComparison.OrdinalIgnoreCase)) + .Take(limit) + .Select(d => new + { + d.SignalId, ts = d.Timestamp, d.Module, d.Symbol, d.Side, price = d.SignalPrice, + decision = d.Decision.ToString(), reason = d.Reason.ToString(), d.Message, ctx = d.ContextJson + }); + return JsonSerializer.Serialize(rows, JsonOpts); + })); + + reg.Register(new SupervisorTool( + "query_order_events", + "Fragt das Order-Lifecycle-Log ab (Platzierungen, Broker-Antworten, Fills, Cancels). " + + "Filter optional: module, symbol, signalId, sinceHours.", + """{"type":"object","properties":{"module":{"type":"string"},"symbol":{"type":"string"},"signalId":{"type":"string"},"sinceHours":{"type":"integer"},"limit":{"type":"integer"}}}""", + args => + { + int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 100, 1, 500); + string? module = SupervisorToolRegistry.GetString(args, "module"); + string? symbol = SupervisorToolRegistry.GetString(args, "symbol"); + string? signalId = SupervisorToolRegistry.GetString(args, "signalId"); + int? sinceHours = SupervisorToolRegistry.GetInt(args, "sinceHours"); + DateTime since = sinceHours.HasValue ? DateTime.UtcNow.AddHours(-sinceHours.Value) : DateTime.MinValue; + + var rows = orderEvents.Query(e => + (module == null || e.Module == module) && + (symbol == null || e.Symbol == symbol) && + (signalId == null || e.SignalId == signalId) && + e.Timestamp >= since, limit) + .Select(e => new + { + e.SignalId, ts = e.Timestamp, e.Module, e.Symbol, + eventType = e.EventType.ToString(), e.Side, e.Price, e.Quantity, e.OrderType, + e.Response, details = e.DetailsJson + }); + return JsonSerializer.Serialize(rows, JsonOpts); + })); + + reg.Register(new SupervisorTool( + "query_trades", + "Fragt gebuchte Fills aus der modulübergreifenden Trade-Historie ab. " + + "Filter optional: module, symbol, sinceDays.", + """{"type":"object","properties":{"module":{"type":"string"},"symbol":{"type":"string"},"sinceDays":{"type":"integer"},"limit":{"type":"integer"}}}""", + args => + { + int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 100, 1, 500); + string? module = SupervisorToolRegistry.GetString(args, "module"); + string? symbol = SupervisorToolRegistry.GetString(args, "symbol"); + int? sinceDays = SupervisorToolRegistry.GetInt(args, "sinceDays"); + DateTime since = sinceDays.HasValue ? DateTime.UtcNow.AddDays(-sinceDays.Value) : DateTime.MinValue; + + var rows = trades.Query(module, symbol, since, limit) + .Select(t => new + { + t.SignalId, t.Module, t.Symbol, t.Action, t.Quantity, t.Price, t.TotalValue, + t.TradedAt, t.Status + }); + return JsonSerializer.Serialize(rows, JsonOpts); + })); + + reg.Register(new SupervisorTool( + "get_dossier", + "Liefert das komplette Dossier zu einer SignalId als Markdown: Entscheidungskette, Order-Events, Trades, Log-Auszug.", + """{"type":"object","properties":{"signalId":{"type":"string"}},"required":["signalId"]}""", + args => + { + string? signalId = SupervisorToolRegistry.GetString(args, "signalId"); + if (string.IsNullOrWhiteSpace(signalId)) return "FEHLER: signalId fehlt."; + return DossierBuilder.ToMarkdown(dossiers.BuildForSignal(signalId)); + })); + + reg.Register(new SupervisorTool( + "read_logs", + "Liest die JSONL-Logdatei eines Tages (Datum yyyy-MM-dd), optional gefiltert nach level, cid (SignalId) und textFilter.", + """{"type":"object","properties":{"date":{"type":"string"},"level":{"type":"string"},"cid":{"type":"string"},"textFilter":{"type":"string"},"limit":{"type":"integer"}},"required":["date"]}""", + args => + { + string? date = SupervisorToolRegistry.GetString(args, "date"); + if (string.IsNullOrWhiteSpace(date)) return "FEHLER: date fehlt (yyyy-MM-dd)."; + string path = Path.Combine(logsDir, $"{date}.jsonl"); + if (!File.Exists(path)) return $"Keine JSONL-Datei für {date}."; + + string? level = SupervisorToolRegistry.GetString(args, "level"); + string? cid = SupervisorToolRegistry.GetString(args, "cid"); + string? text = SupervisorToolRegistry.GetString(args, "textFilter"); + int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 200, 1, 1000); + + var lines = new List(); + foreach (var line in File.ReadLines(path)) + { + var p = LogJson.ParseLine(line); + if (p == null) continue; + if (level != null && !string.Equals(p.Level, level, StringComparison.OrdinalIgnoreCase)) continue; + if (cid != null && p.Cid != cid) continue; + if (text != null && !p.Message.Contains(text, StringComparison.OrdinalIgnoreCase)) continue; + lines.Add(p); + if (lines.Count >= limit) break; + } + return JsonSerializer.Serialize(lines, JsonOpts); + })); + + reg.Register(new SupervisorTool( + "get_kpis", + "Berechnet Kennzahlen (Netto-PnL, Winrate, Ø-PnL, Profit-Faktor, Trade-Anzahl) über die " + + "Trade-Historie (FIFO-realisiert). Filter optional: module, sinceDays.", + """{"type":"object","properties":{"module":{"type":"string"},"sinceDays":{"type":"integer"}}}""", + args => + { + string? module = SupervisorToolRegistry.GetString(args, "module"); + int? sinceDays = SupervisorToolRegistry.GetInt(args, "sinceDays"); + DateTime since = sinceDays.HasValue ? DateTime.UtcNow.AddDays(-sinceDays.Value) : DateTime.MinValue; + + var fills = trades.ForKpis(module, since); + var k = TradeAnalytics.ComputeKpis(fills); + var byModule = TradeAnalytics.PnlByModule(fills); + return JsonSerializer.Serialize(new + { + k.TradeCount, k.NetPnl, k.WinRatePct, k.AvgPnlPerTrade, k.ProfitFactor, + byModule = byModule.Select(x => new { module = x.Key, x.Pnl, x.Count }) + }, JsonOpts); + })); + + reg.Register(new SupervisorTool( + "get_architecture_context", + "Liefert das kuratierte Architektur-/Verhaltensdokument von IBKRTrader (wie die Software entscheidet und handelt).", + """{"type":"object","properties":{}}""", + _ => ArchitectureContext.Load())); + + if (counterfactuals != null) + { + reg.Register(new SupervisorTool( + "query_counterfactuals", + "Was wäre aus ABGELEHNTEN BUY-Signalen geworden? Liefert nach einer Wartezeit ausgewertete " + + "Rejects (Reason, Signalpreis, späterer Kurs, hypothetischer PnL je Stück) — zeigt, ob Risk-Limits Gewinne oder Verluste verhindert haben.", + """{"type":"object","properties":{"limit":{"type":"integer"}}}""", + args => + { + int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 200, 1, 500); + var rows = counterfactuals.GetRecent(limit).Select(c => new + { + c.SignalId, c.CheckedAt, c.Module, c.Symbol, reason = c.Reason, + signalPrice = c.SignalPrice, laterPrice = c.LaterPrice, pnlPerShare = c.HypotheticalPnlPerShare + }); + return JsonSerializer.Serialize(rows, JsonOpts); + })); + } + + return reg; + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Counterfactual/CounterfactualJob.cs b/src/IBKRTrader.Modules.Supervisor/Counterfactual/CounterfactualJob.cs new file mode 100644 index 0000000..24b2298 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Counterfactual/CounterfactualJob.cs @@ -0,0 +1,106 @@ +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence; +using IBKRTrader.Core.Persistence.Entities; +using IBKRTrader.Modules.Supervisor.Persistence; +using Microsoft.Extensions.Hosting; + +namespace IBKRTrader.Modules.Supervisor.Counterfactual; + +/// +/// Liefert den späteren Kurs eines abgelehnten Symbols (für die Counterfactual-Auswertung). Interface, +/// damit der Job offline/testbar bleibt; die Live-Implementierung (Zielland) liest eine spätere +/// Kursmarke (z. B. aus core_ibkr_market_data). Offline: Null-Stub → keine Auswertung. +/// +public interface ICounterfactualResolutionSource +{ + Task GetLaterPriceAsync(string symbol, DateTime afterUtc, CancellationToken ct); +} + +/// Offline-Stub: kein späterer Kurs → der Job wertet nichts aus (bleibt korrekt leer). +public sealed class NullCounterfactualResolutionSource : ICounterfactualResolutionSource +{ + public Task GetLaterPriceAsync(string symbol, DateTime afterUtc, CancellationToken ct) + => Task.FromResult((decimal?)null); +} + +/// +/// Wertet ABGELEHNTE BUY-Signale aus: „was wäre gewesen?". Nimmt Rejects, die älter als die Wartezeit +/// sind, holt den späteren Kurs und speichert den hypothetischen GuV je Stück (einmalig je Entscheidung). +/// Mit dem Null-Stub passiert nichts. Bricht den Prozess nie (fehlertolerant). +/// +public sealed class CounterfactualJob : BackgroundService +{ + /// Wartezeit, bevor ein Reject ausgewertet wird (Marktbewegung „danach"). + internal const int EvaluationDelayDays = 7; + private static readonly TimeSpan Interval = TimeSpan.FromHours(12); + + private readonly IDecisionJournal _journal; + private readonly ICounterfactualResolutionSource _resolution; + private readonly ISupervisorCounterfactualRepository _repo; + private readonly LoggingService _logger; + + public CounterfactualJob( + IDecisionJournal journal, ICounterfactualResolutionSource resolution, + ISupervisorCounterfactualRepository repo, LoggingService logger) + { + _journal = journal; + _resolution = resolution; + _repo = repo; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); } + catch (OperationCanceledException) { return; } + + while (!stoppingToken.IsCancellationRequested) + { + try { await EvaluateAsync(stoppingToken); } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _logger.Error("Supervisor", $"Counterfactual-Job Fehler: {ex.Message}", ex); } + + try { await Task.Delay(Interval, stoppingToken); } + catch (OperationCanceledException) { break; } + } + } + + /// Testbarer Kern: bewertet fällige, noch nicht ausgewertete BUY-Rejects. + public async Task EvaluateAsync(CancellationToken ct) + { + DateTime cutoff = DateTime.UtcNow.AddDays(-EvaluationDelayDays); + var candidates = _journal.Query(d => + d.Decision == TradeDecision.Rejected && d.Side == "BUY" && + d.Timestamp <= cutoff && d.SignalPrice > 0, 500); + if (candidates.Count == 0) return 0; + + var already = _repo.ExistingDecisionIds(candidates.Select(c => c.Id)); + int written = 0; + + foreach (var d in candidates) + { + if (ct.IsCancellationRequested) break; + if (already.Contains(d.Id)) continue; + + var later = await _resolution.GetLaterPriceAsync(d.Symbol, d.Timestamp, ct); + if (later is null) continue; // kein Kurs → später erneut versuchen + + _repo.Insert(new CounterfactualRecord + { + DecisionId = d.Id, + SignalId = d.SignalId, + Module = d.Module, + Symbol = d.Symbol, + Reason = d.Reason.ToString(), + Side = d.Side, + SignalPrice = d.SignalPrice, + LaterPrice = later.Value, + HypotheticalPnlPerShare = later.Value - d.SignalPrice + }); + written++; + } + + if (written > 0) _logger.Info("Supervisor", $"Counterfactual: {written} abgelehnte BUY-Signale ausgewertet."); + return written; + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Counterfactual/CounterfactualRecord.cs b/src/IBKRTrader.Modules.Supervisor/Counterfactual/CounterfactualRecord.cs new file mode 100644 index 0000000..8b152e2 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Counterfactual/CounterfactualRecord.cs @@ -0,0 +1,24 @@ +namespace IBKRTrader.Modules.Supervisor.Counterfactual; + +/// +/// Auswertung eines ABGELEHNTEN BUY-Signals (Tabelle sup_counterfactuals): „Was wäre gewesen?". Für +/// Aktien = der spätere Kurs des abgelehnten Symbols vs. dem Signalpreis → hypothetischer GuV je Stück. +/// Zeigt, ob ein Risk-Limit einen Gewinn oder einen Verlust verhindert hat. Ein Ergebnis je Entscheidung. +/// +public class CounterfactualRecord +{ + public long Id { get; set; } + public long DecisionId { get; set; } // Bezug auf core_decision_journal.Id (unique) + public DateTime CheckedAt { get; set; } = DateTime.UtcNow; + + public string SignalId { get; set; } = ""; + public string Module { get; set; } = ""; + public string Symbol { get; set; } = ""; + public string Reason { get; set; } = ""; // ReasonCode der Ablehnung + public string Side { get; set; } = ""; + + public decimal SignalPrice { get; set; } + public decimal LaterPrice { get; set; } + /// LaterPrice − SignalPrice (positiv = die Ablehnung hat Gewinn verhindert). + public decimal HypotheticalPnlPerShare { get; set; } +} diff --git a/src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj b/src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj new file mode 100644 index 0000000..a4e1177 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj @@ -0,0 +1,31 @@ + + + + net10.0-windows + enable + enable + + true + en + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + <_Parameter1>IBKRTrader.Tests + + + + diff --git a/src/IBKRTrader.Modules.Supervisor/Mcp/McpJsonRpc.cs b/src/IBKRTrader.Modules.Supervisor/Mcp/McpJsonRpc.cs new file mode 100644 index 0000000..53d6864 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Mcp/McpJsonRpc.cs @@ -0,0 +1,144 @@ +using System.Text; +using System.Text.Json; +using IBKRTrader.Modules.Supervisor.Agent; + +namespace IBKRTrader.Modules.Supervisor.Mcp; + +/// +/// MCP-Light: purer JSON-RPC-2.0-Handler für das Model Context Protocol über die read-only +/// . Externe KI-Clients (z. B. Claude Code) erhalten damit dieselben +/// Analyse-Tools wie der In-App-Agent — KEIN Modell-Zugang, nur die Daten-Tür. Unterstützt: initialize, +/// ping, tools/list, tools/call. Pur und seiteneffektfrei → unit-getestet. +/// +public static class McpJsonRpc +{ + public const string ProtocolVersion = "2025-03-26"; + public const string ServerName = "ibkrtrader-supervisor"; + public const string ServerVersion = "1.0"; + + /// + /// Verarbeitet eine JSON-RPC-Nachricht. Liefert die Antwort als JSON-String — oder null für + /// Notifications (kein id) und unparsbare Eingaben ohne id. + /// + public static string? Handle(string requestJson, SupervisorToolRegistry registry) + { + JsonDocument doc; + try { doc = JsonDocument.Parse(requestJson); } + catch (JsonException) { return Error(null, -32700, "Parse error"); } + + using (doc) + { + var root = doc.RootElement; + JsonElement? id = root.TryGetProperty("id", out var idProp) ? idProp.Clone() : (JsonElement?)null; + string method = root.TryGetProperty("method", out var m) ? m.GetString() ?? "" : ""; + + if (id == null) return null; // Notifications werden nicht beantwortet + + try + { + return method switch + { + "initialize" => Result(id.Value, w => + { + w.WriteString("protocolVersion", ProtocolVersion); + w.WriteStartObject("capabilities"); + w.WriteStartObject("tools"); + w.WriteEndObject(); + w.WriteEndObject(); + w.WriteStartObject("serverInfo"); + w.WriteString("name", ServerName); + w.WriteString("version", ServerVersion); + w.WriteEndObject(); + }), + + "ping" => Result(id.Value, _ => { }), + + "tools/list" => Result(id.Value, w => + { + w.WriteStartArray("tools"); + foreach (var tool in registry.Tools) + { + w.WriteStartObject(); + w.WriteString("name", tool.Name); + w.WriteString("description", tool.Description); + w.WritePropertyName("inputSchema"); + using (var schema = JsonDocument.Parse(tool.ParametersJsonSchema)) + schema.RootElement.WriteTo(w); + w.WriteEndObject(); + } + w.WriteEndArray(); + }), + + "tools/call" => HandleToolCall(id.Value, root, registry), + + _ => Error(id, -32601, $"Method not found: {method}") + }; + } + catch (Exception ex) + { + return Error(id, -32603, $"Internal error: {ex.Message}"); + } + } + } + + private static string HandleToolCall(JsonElement id, JsonElement root, SupervisorToolRegistry registry) + { + if (!root.TryGetProperty("params", out var p) || p.ValueKind != JsonValueKind.Object) + return Error(id, -32602, "Invalid params"); + + string name = p.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; + string argsJson = p.TryGetProperty("arguments", out var a) && a.ValueKind == JsonValueKind.Object + ? a.GetRawText() : "{}"; + + string toolResult = registry.Execute(name, argsJson); + bool isError = toolResult.StartsWith("FEHLER", StringComparison.OrdinalIgnoreCase); + + return Result(id, w => + { + w.WriteStartArray("content"); + w.WriteStartObject(); + w.WriteString("type", "text"); + w.WriteString("text", toolResult); + w.WriteEndObject(); + w.WriteEndArray(); + w.WriteBoolean("isError", isError); + }); + } + + // ----- JSON-RPC-Hüllen ----- + + private static string Result(JsonElement id, Action writeResult) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("jsonrpc", "2.0"); + w.WritePropertyName("id"); + id.WriteTo(w); + w.WriteStartObject("result"); + writeResult(w); + w.WriteEndObject(); + w.WriteEndObject(); + } + return Encoding.UTF8.GetString(ms.ToArray()); + } + + private static string Error(JsonElement? id, int code, string message) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("jsonrpc", "2.0"); + w.WritePropertyName("id"); + if (id.HasValue) id.Value.WriteTo(w); else w.WriteNullValue(); + w.WriteStartObject("error"); + w.WriteNumber("code", code); + w.WriteString("message", message); + w.WriteEndObject(); + w.WriteEndObject(); + } + return Encoding.UTF8.GetString(ms.ToArray()); + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Mcp/McpLightServer.cs b/src/IBKRTrader.Modules.Supervisor/Mcp/McpLightServer.cs new file mode 100644 index 0000000..c0b1ef0 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Mcp/McpLightServer.cs @@ -0,0 +1,103 @@ +using System.Net; +using System.Text; +using IBKRTrader.Core.Logging; +using IBKRTrader.Modules.Supervisor.Agent; +using Microsoft.Extensions.Hosting; + +namespace IBKRTrader.Modules.Supervisor.Mcp; + +/// +/// MCP-Light-Host: lokaler HTTP-Endpoint (nur POST-JSON), der die read-only Tool-Registry per Model +/// Context Protocol exponiert. Externe Clients wie Claude Code verbinden sich mit +/// claude mcp add --transport http ibkrtrader http://127.0.0.1:PORT/mcp. +/// +/// SICHERHEIT: bewusst OPT-IN (startet nur, wenn IBKRTRADER_MCP_PORT gesetzt ist) und bindet +/// ausschließlich an 127.0.0.1 (kein Netzwerkzugriff). Die Tools sind read-only – es existiert kein +/// Mechanismus zum Handeln/Schreiben. Kein Modell-Zugang: MCP ist nur die Daten-Tür. +/// +public sealed class McpLightServer : BackgroundService +{ + private readonly SupervisorToolRegistry _registry; + private readonly LoggingService _logger; + + public McpLightServer(SupervisorToolRegistry registry, LoggingService logger) + { + _registry = registry; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + string? portRaw = Environment.GetEnvironmentVariable("IBKRTRADER_MCP_PORT"); + if (string.IsNullOrWhiteSpace(portRaw)) + { + _logger.Info("Supervisor", "MCP-Light: deaktiviert (IBKRTRADER_MCP_PORT nicht gesetzt)."); + return; + } + if (!int.TryParse(portRaw, out int port) || port is < 1024 or > 65535) + { + _logger.Warn("Supervisor", $"MCP-Light: ungültiger Port '{portRaw}' – Server startet nicht."); + return; + } + + using var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/mcp/"); + try { listener.Start(); } + catch (Exception ex) + { + _logger.Error("Supervisor", $"MCP-Light: Start auf Port {port} fehlgeschlagen: {ex.Message}", ex); + return; + } + + _logger.Info("Supervisor", $"🔌 MCP-Light aktiv: http://127.0.0.1:{port}/mcp (read-only, {_registry.Tools.Count} Tools). " + + $"Claude Code: claude mcp add --transport http ibkrtrader http://127.0.0.1:{port}/mcp"); + + using var reg = stoppingToken.Register(() => { try { listener.Stop(); } catch { } }); + while (!stoppingToken.IsCancellationRequested) + { + HttpListenerContext ctx; + try { ctx = await listener.GetContextAsync(); } + catch when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) { _logger.Warn("Supervisor", $"MCP-Light: Listener-Fehler: {ex.Message}"); continue; } + + _ = Task.Run(() => HandleRequestAsync(ctx), stoppingToken); + } + } + + private async Task HandleRequestAsync(HttpListenerContext ctx) + { + try + { + if (ctx.Request.HttpMethod != "POST") + { + ctx.Response.StatusCode = 405; + ctx.Response.Close(); + return; + } + + string body; + using (var reader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding)) + body = await reader.ReadToEndAsync(); + + string? response = McpJsonRpc.Handle(body, _registry); + if (response == null) + { + ctx.Response.StatusCode = 202; // Notification: angenommen, keine Antwort + ctx.Response.Close(); + return; + } + + byte[] bytes = Encoding.UTF8.GetBytes(response); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = bytes.Length; + await ctx.Response.OutputStream.WriteAsync(bytes); + ctx.Response.Close(); + } + catch (Exception ex) + { + _logger.Warn("Supervisor", $"MCP-Light: Request-Fehler: {ex.Message}"); + try { ctx.Response.StatusCode = 500; ctx.Response.Close(); } catch { } + } + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Migrations/20260730170542_InitialSupervisor.Designer.cs b/src/IBKRTrader.Modules.Supervisor/Migrations/20260730170542_InitialSupervisor.Designer.cs new file mode 100644 index 0000000..0b27983 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Migrations/20260730170542_InitialSupervisor.Designer.cs @@ -0,0 +1,143 @@ +// +using System; +using IBKRTrader.Modules.Supervisor.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IBKRTrader.Modules.Supervisor.Migrations +{ + [DbContext(typeof(SupervisorDbContext))] + [Migration("20260730170542_InitialSupervisor")] + partial class InitialSupervisor + { + /// + 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("IBKRTrader.Modules.Supervisor.Counterfactual.CounterfactualRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CheckedAt") + .HasColumnType("datetime(6)"); + + b.Property("DecisionId") + .HasColumnType("bigint"); + + b.Property("HypotheticalPnlPerShare") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("LaterPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SignalPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("CheckedAt"); + + b.HasIndex("DecisionId") + .IsUnique(); + + b.HasIndex("Reason"); + + b.ToTable("sup_counterfactuals", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Modules.Supervisor.Persistence.SupervisorReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompletionTokens") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PromptTokens") + .HasColumnType("int"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ToolCallCount") + .HasColumnType("int"); + + b.Property("ToolCallsJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("sup_reports", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Migrations/20260730170542_InitialSupervisor.cs b/src/IBKRTrader.Modules.Supervisor/Migrations/20260730170542_InitialSupervisor.cs new file mode 100644 index 0000000..2541d99 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Migrations/20260730170542_InitialSupervisor.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IBKRTrader.Modules.Supervisor.Migrations +{ + /// + public partial class InitialSupervisor : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "sup_counterfactuals", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + DecisionId = table.Column(type: "bigint", nullable: false), + CheckedAt = table.Column(type: "datetime(6)", nullable: false), + SignalId = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Module = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Symbol = table.Column(type: "varchar(20)", maxLength: 20, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Reason = table.Column(type: "varchar(40)", maxLength: 40, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Side = table.Column(type: "varchar(10)", maxLength: 10, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SignalPrice = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + LaterPrice = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + HypotheticalPnlPerShare = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sup_counterfactuals", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "sup_reports", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + Profile = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Model = table.Column(type: "varchar(120)", maxLength: 120, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Question = table.Column(type: "varchar(4000)", maxLength: 4000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Answer = table.Column(type: "text", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ToolCallsJson = table.Column(type: "text", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ToolCallCount = table.Column(type: "int", nullable: false), + PromptTokens = table.Column(type: "int", nullable: false), + CompletionTokens = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sup_reports", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_sup_counterfactuals_CheckedAt", + table: "sup_counterfactuals", + column: "CheckedAt"); + + migrationBuilder.CreateIndex( + name: "IX_sup_counterfactuals_DecisionId", + table: "sup_counterfactuals", + column: "DecisionId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_sup_counterfactuals_Reason", + table: "sup_counterfactuals", + column: "Reason"); + + migrationBuilder.CreateIndex( + name: "IX_sup_reports_CreatedAt", + table: "sup_reports", + column: "CreatedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "sup_counterfactuals"); + + migrationBuilder.DropTable( + name: "sup_reports"); + } + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Migrations/SupervisorDbContextModelSnapshot.cs b/src/IBKRTrader.Modules.Supervisor/Migrations/SupervisorDbContextModelSnapshot.cs new file mode 100644 index 0000000..7a78e17 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Migrations/SupervisorDbContextModelSnapshot.cs @@ -0,0 +1,140 @@ +// +using System; +using IBKRTrader.Modules.Supervisor.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IBKRTrader.Modules.Supervisor.Migrations +{ + [DbContext(typeof(SupervisorDbContext))] + partial class SupervisorDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("IBKRTrader.Modules.Supervisor.Counterfactual.CounterfactualRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CheckedAt") + .HasColumnType("datetime(6)"); + + b.Property("DecisionId") + .HasColumnType("bigint"); + + b.Property("HypotheticalPnlPerShare") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("LaterPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("SignalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SignalPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("CheckedAt"); + + b.HasIndex("DecisionId") + .IsUnique(); + + b.HasIndex("Reason"); + + b.ToTable("sup_counterfactuals", (string)null); + }); + + modelBuilder.Entity("IBKRTrader.Modules.Supervisor.Persistence.SupervisorReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompletionTokens") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PromptTokens") + .HasColumnType("int"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ToolCallCount") + .HasColumnType("int"); + + b.Property("ToolCallsJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("sup_reports", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Persistence/SupervisorDbContext.cs b/src/IBKRTrader.Modules.Supervisor/Persistence/SupervisorDbContext.cs new file mode 100644 index 0000000..5a69641 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Persistence/SupervisorDbContext.cs @@ -0,0 +1,155 @@ +using IBKRTrader.Core.Configuration; +using IBKRTrader.Core.Logging; +using IBKRTrader.Modules.Supervisor.Counterfactual; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace IBKRTrader.Modules.Supervisor.Persistence; + +/// EF-Kontext des Supervisor-Moduls (gleiche MariaDB, Tabellen mit Präfix sup_). +public class SupervisorDbContext : DbContext +{ + public SupervisorDbContext(DbContextOptions options) : base(options) { } + + public DbSet Reports => Set(); + public DbSet Counterfactuals => Set(); + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("sup_reports"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedOnAdd(); + e.Property(x => x.Profile).HasMaxLength(50); + e.Property(x => x.Model).HasMaxLength(120); + e.Property(x => x.Question).HasMaxLength(4000); + e.Property(x => x.Answer).HasColumnType("text"); + e.Property(x => x.ToolCallsJson).HasColumnType("text"); + e.HasIndex(x => x.CreatedAt); + }); + + b.Entity(e => + { + e.ToTable("sup_counterfactuals"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedOnAdd(); + e.Property(x => x.SignalId).HasMaxLength(64); + e.Property(x => x.Module).HasMaxLength(50); + e.Property(x => x.Symbol).HasMaxLength(20); + e.Property(x => x.Reason).HasMaxLength(40); + e.Property(x => x.Side).HasMaxLength(10); + e.Property(x => x.SignalPrice).HasPrecision(18, 4); + e.Property(x => x.LaterPrice).HasPrecision(18, 4); + e.Property(x => x.HypotheticalPnlPerShare).HasPrecision(18, 4); + e.HasIndex(x => x.DecisionId).IsUnique(); // ein Ergebnis je Entscheidung + e.HasIndex(x => x.CheckedAt); + e.HasIndex(x => x.Reason); + }); + } +} + +/// Design-Time-Factory für EF-Tooling (dotnet ef). Connection aus env IBKRTRADER_MYSQL. +public class SupervisorDbContextFactory : IDesignTimeDbContextFactory +{ + public SupervisorDbContext CreateDbContext(string[] args) + { + var conn = Environment.GetEnvironmentVariable("IBKRTRADER_MYSQL") + ?? "Server=localhost;Port=3306;Database=ibkrtrader;User ID=root;Password=;"; + + var options = new DbContextOptionsBuilder() + .UseMySql(conn, DatabaseServerVersion.Value) + .Options; + + return new SupervisorDbContext(options); + } +} + +/// Bericht-Ablage. Write fehlertolerant (Analyse darf nie an der Persistenz scheitern). +public interface ISupervisorReportRepository +{ + void Insert(SupervisorReport report); + List GetRecent(int limit); +} + +/// Counterfactual-Ablage. Write fehlertolerant. +public interface ISupervisorCounterfactualRepository +{ + HashSet ExistingDecisionIds(IEnumerable decisionIds); + void Insert(CounterfactualRecord record); + List GetRecent(int limit); +} + +public sealed class EfSupervisorReportRepository : ISupervisorReportRepository +{ + private readonly IDbContextFactory _dbf; + private readonly LoggingService _logger; + + public EfSupervisorReportRepository(IDbContextFactory dbf, LoggingService logger) + { + _dbf = dbf; + _logger = logger; + } + + public void Insert(SupervisorReport report) + { + try + { + using var db = _dbf.CreateDbContext(); + db.Reports.Add(report); + db.SaveChanges(); + } + catch (Exception ex) + { + _logger.Warn("Supervisor", $"Report-Write fehlgeschlagen (ignoriert): {ex.Message}"); + } + } + + public List GetRecent(int limit) + { + using var db = _dbf.CreateDbContext(); + return db.Reports.AsNoTracking().OrderByDescending(r => r.CreatedAt).Take(limit).ToList(); + } +} + +public sealed class EfSupervisorCounterfactualRepository : ISupervisorCounterfactualRepository +{ + private readonly IDbContextFactory _dbf; + private readonly LoggingService _logger; + + public EfSupervisorCounterfactualRepository(IDbContextFactory dbf, LoggingService logger) + { + _dbf = dbf; + _logger = logger; + } + + public HashSet ExistingDecisionIds(IEnumerable decisionIds) + { + var ids = decisionIds.ToList(); + using var db = _dbf.CreateDbContext(); + return db.Counterfactuals.AsNoTracking() + .Where(c => ids.Contains(c.DecisionId)) + .Select(c => c.DecisionId) + .ToHashSet(); + } + + public void Insert(CounterfactualRecord record) + { + try + { + using var db = _dbf.CreateDbContext(); + db.Counterfactuals.Add(record); + db.SaveChanges(); + } + catch (Exception ex) + { + _logger.Warn("Supervisor", $"Counterfactual-Write fehlgeschlagen (ignoriert): {ex.Message}"); + } + } + + public List GetRecent(int limit) + { + using var db = _dbf.CreateDbContext(); + return db.Counterfactuals.AsNoTracking().OrderByDescending(c => c.CheckedAt).Take(limit).ToList(); + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Persistence/SupervisorReport.cs b/src/IBKRTrader.Modules.Supervisor/Persistence/SupervisorReport.cs new file mode 100644 index 0000000..58c58bf --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Persistence/SupervisorReport.cs @@ -0,0 +1,23 @@ +namespace IBKRTrader.Modules.Supervisor.Persistence; + +/// +/// Gespeicherte Analyse (Tabelle sup_reports): Frage, Antwort, Profil/Modell und die Tool-Aufruf-Historie +/// – macht den Supervisor selbst auditierbar und füttert später Tagesberichte. +/// +public class SupervisorReport +{ + public long Id { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public string Profile { get; set; } = ""; + public string Model { get; set; } = ""; + public string Question { get; set; } = ""; + public string Answer { get; set; } = ""; + + /// Tool-Aufrufe als JSON [{tool,args}] (Ergebnisse sind reproduzierbar, daher nicht gespeichert). + public string ToolCallsJson { get; set; } = ""; + + public int ToolCallCount { get; set; } + public int PromptTokens { get; set; } + public int CompletionTokens { get; set; } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Services/DailyReportService.cs b/src/IBKRTrader.Modules.Supervisor/Services/DailyReportService.cs new file mode 100644 index 0000000..eff6e55 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Services/DailyReportService.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using IBKRTrader.Core.Logging; +using IBKRTrader.Modules.Supervisor.Agent; +using IBKRTrader.Modules.Supervisor.Persistence; +using Microsoft.Extensions.Hosting; + +namespace IBKRTrader.Modules.Supervisor.Services; + +/// +/// Täglicher Supervisor-Bericht (OPT-IN via env IBKRTRADER_SUPERVISOR_DAILY = Stunde 0–23). Lässt den +/// Agenten einmal je Tag eine Standard-Analyse fahren und legt sie in sup_reports ab. Ohne gesetzte +/// Variable oder ohne OpenRouter-Key passiert nichts (deaktiviert bzw. sauber übersprungen). Kein +/// externer Versand (Threema o. ä.) in dieser Ausbaustufe. +/// +public sealed class DailyReportService : BackgroundService +{ + private const string StandardQuestion = + "Fasse die letzten 24 Stunden zusammen: auffällige Ablehnungen/Fehler, ausgeführte Trades und " + + "eine kurze Einschätzung der technischen Gesundheit. Nutze die Tools."; + + private readonly SupervisorAgent _agent; + private readonly ISupervisorReportRepository _reports; + private readonly LoggingService _logger; + + public DailyReportService(SupervisorAgent agent, ISupervisorReportRepository reports, LoggingService logger) + { + _agent = agent; + _reports = reports; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + string? raw = Environment.GetEnvironmentVariable("IBKRTRADER_SUPERVISOR_DAILY"); + if (!int.TryParse(raw, out int hour) || hour is < 0 or > 23) + { + _logger.Info("Supervisor", "Tagesbericht deaktiviert (IBKRTRADER_SUPERVISOR_DAILY nicht gesetzt)."); + return; + } + + _logger.Info("Supervisor", $"Tagesbericht aktiv: täglich um {hour:00}:00 Uhr."); + while (!stoppingToken.IsCancellationRequested) + { + var delay = NextRun(DateTime.Now, hour) - DateTime.Now; + try { await Task.Delay(delay, stoppingToken); } + catch (OperationCanceledException) { break; } + + try { await RunOnceAsync(stoppingToken); } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _logger.Warn("Supervisor", $"Tagesbericht fehlgeschlagen: {ex.Message}"); } + } + } + + internal static DateTime NextRun(DateTime now, int hour) + { + var candidate = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0, DateTimeKind.Local); + return candidate <= now ? candidate.AddDays(1) : candidate; + } + + private async Task RunOnceAsync(CancellationToken ct) + { + var result = await _agent.AskAsync(StandardQuestion, profile: SupervisorProfiles.Technik, ct: ct); + _reports.Insert(new SupervisorReport + { + Profile = SupervisorProfiles.Technik.Name, + Model = SupervisorAgent.DefaultModel, + Question = StandardQuestion, + Answer = result.Answer, + ToolCallsJson = JsonSerializer.Serialize(result.ToolInvocations.Select(i => new { i.Tool, i.Arguments })), + ToolCallCount = result.ToolInvocations.Count, + PromptTokens = result.PromptTokens, + CompletionTokens = result.CompletionTokens + }); + _logger.Info("Supervisor", "Täglicher Supervisor-Bericht erstellt."); + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Services/DossierService.cs b/src/IBKRTrader.Modules.Supervisor/Services/DossierService.cs new file mode 100644 index 0000000..5a4a810 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Services/DossierService.cs @@ -0,0 +1,89 @@ +using IBKRTrader.Core.Analytics; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence; + +namespace IBKRTrader.Modules.Supervisor.Services; + +/// Kopfzeile eines Signals für die Übersichtsliste des Dossier-Browsers. +public sealed record SignalSummary( + string SignalId, DateTime FirstSeen, string Module, string Symbol, + string Side, string LastDecision, string LastReason, int DecisionCount); + +/// +/// Beschafft die Daten für Trade-Dossiers: Entscheidungsjournal + Order-Events + Trade-Log + +/// JSONL-Log-Zeilen (per CorrelationId), Zusammenbau/Rendering pur im +/// (Core). Read-only — der Supervisor ist Beobachter. +/// +public sealed class DossierService +{ + private readonly IDecisionJournal _journal; + private readonly IOrderEventLog _orderEvents; + private readonly TradeLogReader _trades; + private readonly string _logsDirectory; + + public DossierService(IDecisionJournal journal, IOrderEventLog orderEvents, TradeLogReader trades) + { + _journal = journal; + _orderEvents = orderEvents; + _trades = trades; + _logsDirectory = Path.Combine(AppContext.BaseDirectory, "Logs"); + } + + /// Jüngste Signale (gruppiert über das Entscheidungsjournal), neueste zuerst. + public List RecentSignals(int limit = 200) + { + var decisions = _journal.Query(d => d.SignalId != "", limit * 5); + return decisions + .GroupBy(d => d.SignalId) + .Select(g => + { + var ordered = g.OrderBy(d => d.Timestamp).ToList(); + var first = ordered[0]; + var last = ordered[^1]; + return new SignalSummary(g.Key, first.Timestamp, first.Module, first.Symbol, + first.Side, last.Decision.ToString(), last.Reason.ToString(), ordered.Count); + }) + .OrderByDescending(s => s.FirstSeen) + .Take(limit) + .ToList(); + } + + /// Baut das komplette Dossier zu einer SignalId (inkl. Log-Zeilen aus den JSONL-Tagesdateien). + public TradeDossier BuildForSignal(string signalId) + { + var decisions = _journal.Query(d => d.SignalId == signalId); + var events = _orderEvents.Query(e => e.SignalId == signalId); + var trades = _trades.BySignal(signalId); + var logLines = ReadLogLines(signalId, decisions.Select(d => d.Timestamp).Concat(events.Select(e => e.Timestamp))); + return DossierBuilder.Build(signalId, decisions, events, trades, logLines); + } + + /// + /// Liest JSONL-Zeilen mit passender CorrelationId — nur aus den Tagesdateien im Zeitfenster der + /// bekannten Ereignisse (±1 Tag), statt alle Logs zu scannen. Fehlertolerant (fehlende Dateien = leer). + /// + private List ReadLogLines(string signalId, IEnumerable eventTimes) + { + var result = new List(); + var times = eventTimes.ToList(); + if (times.Count == 0 || string.IsNullOrEmpty(signalId)) return result; + + try + { + var from = times.Min().Date.AddDays(-1); + var to = times.Max().Date.AddDays(1); + for (var day = from; day <= to; day = day.AddDays(1)) + { + string path = Path.Combine(_logsDirectory, $"{day:yyyy-MM-dd}.jsonl"); + if (!File.Exists(path)) continue; + foreach (var line in File.ReadLines(path)) + { + var p = LogJson.ParseLine(line); + if (p != null && p.Cid == signalId) result.Add(p); + } + } + } + catch { /* Log-Auszug ist Beiwerk – Dossier bleibt auch ohne nutzbar */ } + return result; + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/Services/TradeLogReader.cs b/src/IBKRTrader.Modules.Supervisor/Services/TradeLogReader.cs new file mode 100644 index 0000000..28199c3 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Services/TradeLogReader.cs @@ -0,0 +1,42 @@ +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Modules.Supervisor.Services; + +/// +/// Read-only-Zugriff auf die modulübergreifende Trade-Historie (core_trade_history). Kapselt die +/// CoreDbContext-Queries für die Supervisor-Tools und den Dossier-Aufbau. Rein lesend. +/// +public sealed class TradeLogReader +{ + private readonly IDbContextFactory _dbf; + public TradeLogReader(IDbContextFactory dbf) => _dbf = dbf; + + public List Query(string? module, string? symbol, DateTime since, int limit) + { + using var db = _dbf.CreateDbContext(); + var q = db.TradeHistory.AsNoTracking().Where(t => t.TradedAt >= since); + if (!string.IsNullOrEmpty(module)) q = q.Where(t => t.Module == module); + if (!string.IsNullOrEmpty(symbol)) q = q.Where(t => t.Symbol == symbol); + return q.OrderByDescending(t => t.TradedAt).Take(limit).ToList(); + } + + /// Alle Fills eines Moduls/Zeitraums (für die realisierte KPI-Berechnung, chronologisch). + public List ForKpis(string? module, DateTime since) + { + using var db = _dbf.CreateDbContext(); + var q = db.TradeHistory.AsNoTracking().Where(t => t.TradedAt >= since); + if (!string.IsNullOrEmpty(module)) q = q.Where(t => t.Module == module); + return q.OrderBy(t => t.TradedAt).ToList(); + } + + public List BySignal(string signalId) + { + using var db = _dbf.CreateDbContext(); + return db.TradeHistory.AsNoTracking() + .Where(t => t.SignalId == signalId) + .OrderBy(t => t.TradedAt) + .ToList(); + } +} diff --git a/src/IBKRTrader.Modules.Supervisor/SupervisorModule.cs b/src/IBKRTrader.Modules.Supervisor/SupervisorModule.cs new file mode 100644 index 0000000..ccaf6e4 --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/SupervisorModule.cs @@ -0,0 +1,79 @@ +using System.Net.Http; +using IBKRTrader.Core.Configuration; +using IBKRTrader.Core.DependencyInjection; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Modularity; +using IBKRTrader.Core.Persistence; +using IBKRTrader.Modules.Supervisor.Agent; +using IBKRTrader.Modules.Supervisor.Counterfactual; +using IBKRTrader.Modules.Supervisor.Persistence; +using IBKRTrader.Modules.Supervisor.Services; +using IBKRTrader.Modules.Supervisor.Ui; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.Modules.Supervisor; + +/// +/// Supervisor-Modul: KI-gestützte Analyse/Forensik über ALLE Module — strikt read-only (kein Handel). +/// Dossier-Browser über Entscheidungsjournal/Order-Events/Trade-Log/JSONL-Logs, OpenRouter-Agent mit +/// read-only Tool-Registry, optional Counterfactual-Auswertung, Tagesbericht und MCP-Light. Konzept: +/// docs/konzepte/KONZEPT-Modul-Supervisor.md. +/// +public sealed class SupervisorModule : IModule +{ + public string Name => "Supervisor"; + public string DbPrefix => "sup_"; + + public void RegisterServices(IServiceCollection services, IConfiguration configuration) + { + // sup_-Persistenz (gespeicherte Analysen/Berichte + Counterfactuals). + var conn = ServiceCollectionExtensions.EffectiveConnectionString(configuration["Database:MySqlConnectionString"]); + services.AddDbContextFactory(o => o.UseMySql(conn, DatabaseServerVersion.Value)); + services.AddSingleton(); + services.AddSingleton(); + + // Dossier-Beschaffung + Trade-Log-Reader (read-only auf Core-Daten). + services.AddSingleton(); + services.AddSingleton(); + + // read-only Tool-Registry + OpenRouter-Agent (Key getrennt vom Trading, siehe Konzept §5). + services.AddSingleton(sp => SupervisorTools.CreateRegistry( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + services.AddSingleton(_ => + new OpenRouterClient(new HttpClient { Timeout = TimeSpan.FromMinutes(3) })); + services.AddSingleton(); + + // Counterfactual-Auswertung (abgelehnte BUYs vs. späterer Kurs) – Live-Quelle als Null-Stub. + services.AddSingleton(); + services.AddHostedService(); + + // Tagesbericht (opt-in via IBKRTRADER_SUPERVISOR_DAILY) + MCP-Light (opt-in via IBKRTRADER_MCP_PORT). + services.AddHostedService(); + services.AddHostedService(); + } + + public void RegisterUi(IModuleUiHost host, IServiceProvider services) + { + host.RegisterView(new ModuleView + { + Id = "supervisor.main", + Title = "Supervisor", + Group = Name, + Order = 300, + CreateForm = () => new SupervisorMainForm( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()) + }); + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/IBKRTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs b/src/IBKRTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs new file mode 100644 index 0000000..39f4e0d --- /dev/null +++ b/src/IBKRTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs @@ -0,0 +1,188 @@ +using System.Text; +using System.Text.Json; +using IBKRTrader.Core.Analytics; +using IBKRTrader.Core.Logging; +using IBKRTrader.Modules.Supervisor.Agent; +using IBKRTrader.Modules.Supervisor.Persistence; +using IBKRTrader.Modules.Supervisor.Services; + +namespace IBKRTrader.Modules.Supervisor.Ui; + +/// +/// Fenster des Supervisor-Moduls: Analyse (Chat mit dem Agenten, Tool-Aufrufe sichtbar), Dossier-Browser, +/// Berichte, Settings. Read-only. DB-/Agent-Zugriffe laufen NUR auf Nutzer-Interaktion (Smoke-UI-sicher). +/// +public sealed class SupervisorMainForm : Form +{ + private readonly SupervisorAgent _agent; + private readonly DossierService _dossiers; + private readonly ISupervisorReportRepository _reports; + private readonly LoggingService _logger; + + private readonly ComboBox _profile = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 160 }; + private readonly TextBox _question = new() { Dock = DockStyle.Fill, Multiline = true, Height = 60 }; + private readonly RichTextBox _answer = new() { Dock = DockStyle.Fill, ReadOnly = true, Font = new Font("Consolas", 9f) }; + private readonly Button _ask = new() { Text = "Fragen", Width = 100 }; + + private readonly DataGridView _signals = new() { Dock = DockStyle.Left, Width = 360, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill }; + private readonly RichTextBox _dossier = new() { Dock = DockStyle.Fill, ReadOnly = true, Font = new Font("Consolas", 9f) }; + private readonly DataGridView _reportsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill }; + + public SupervisorMainForm( + SupervisorAgent agent, DossierService dossiers, ISupervisorReportRepository reports, LoggingService logger) + { + _agent = agent; + _dossiers = dossiers; + _reports = reports; + _logger = logger; + + Text = "Supervisor"; + Width = 1080; + Height = 720; + StartPosition = FormStartPosition.CenterScreen; + MinimumSize = new Size(800, 520); + + foreach (var p in SupervisorProfiles.All) _profile.Items.Add(p.Name); + _profile.SelectedIndex = 0; + + BuildLayout(); + } + + private void BuildLayout() + { + var tabs = new TabControl { Dock = DockStyle.Fill }; + + // ── Tab: Analyse ── + var tabChat = new TabPage("Analyse"); + var top = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 36, Padding = new Padding(8, 6, 8, 0) }; + top.Controls.Add(new Label { Text = "Profil", AutoSize = true, Margin = new Padding(0, 8, 4, 0) }); + top.Controls.Add(_profile); + _ask.Click += async (_, _) => await AskAsync(); + var qPanel = new Panel { Dock = DockStyle.Top, Height = 70, Padding = new Padding(8, 2, 8, 4) }; + qPanel.Controls.Add(_question); + var askPanel = new Panel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(8, 0, 8, 0) }; + askPanel.Controls.Add(_ask); + var answerPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8) }; + answerPanel.Controls.Add(_answer); + tabChat.Controls.Add(answerPanel); + tabChat.Controls.Add(askPanel); + tabChat.Controls.Add(qPanel); + tabChat.Controls.Add(top); + + // ── Tab: Dossier-Browser ── + var tabDossier = new TabPage("Dossier-Browser"); + _signals.SelectionChanged += (_, _) => ShowSelectedDossier(); + var refreshSignals = new Button { Text = "Signale laden", Dock = DockStyle.Top, Height = 28 }; + refreshSignals.Click += (_, _) => LoadSignals(); + var left = new Panel { Dock = DockStyle.Left, Width = 360 }; + left.Controls.Add(_signals); + left.Controls.Add(refreshSignals); + var dossierPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8) }; + dossierPanel.Controls.Add(_dossier); + tabDossier.Controls.Add(dossierPanel); + tabDossier.Controls.Add(left); + + // ── Tab: Berichte ── + var tabReports = new TabPage("Berichte"); + var refreshReports = new Button { Text = "Berichte laden", Dock = DockStyle.Top, Height = 28 }; + refreshReports.Click += (_, _) => LoadReports(); + tabReports.Controls.Add(_reportsGrid); + tabReports.Controls.Add(refreshReports); + + // ── Tab: Settings (Info) ── + var tabSettings = new TabPage("Settings"); + tabSettings.Controls.Add(new Label + { + Dock = DockStyle.Fill, Padding = new Padding(16), + Text = + "Supervisor – read-only Analyse/Forensik über alle Module.\n\n" + + "OpenRouter-Key: env IBKRTRADER_OPENROUTER_KEY oder Datei 'openrouter.key' (gitignored).\n" + + $" Status: {(string.IsNullOrEmpty(OpenRouterClient.DefaultApiKeyProvider()) ? "NICHT gesetzt – Chat nicht verfügbar" : "gesetzt")}\n\n" + + "Tagesbericht (opt-in): env IBKRTRADER_SUPERVISOR_DAILY = Stunde 0–23.\n" + + "MCP-Light (opt-in): env IBKRTRADER_MCP_PORT = Port (bindet nur 127.0.0.1).\n\n" + + "Sicherheit: OpenRouter ist ein bewusst freigegebener externer Datenempfänger. Es werden nur\n" + + "Analyse-Daten der Tools gesendet, niemals Secrets. Kein Tool kann handeln oder schreiben." + }); + + tabs.TabPages.AddRange(new[] { tabChat, tabDossier, tabReports, tabSettings }); + Controls.Add(tabs); + } + + // ── Analyse ── + + private async Task AskAsync() + { + var question = _question.Text.Trim(); + if (string.IsNullOrEmpty(question)) return; + + _ask.Enabled = false; + _answer.Clear(); + var profile = SupervisorProfiles.ByName(_profile.Text); + var progress = new Progress(s => AppendLine(s)); + + try + { + var result = await _agent.AskAsync(question, profile: profile, progress: progress); + AppendLine(""); + AppendLine("─── Antwort ───"); + AppendLine(result.Answer); + + _reports.Insert(new SupervisorReport + { + Profile = profile.Name, + Model = SupervisorAgent.DefaultModel, + Question = question, + Answer = result.Answer, + ToolCallsJson = JsonSerializer.Serialize(result.ToolInvocations.Select(i => new { i.Tool, i.Arguments })), + ToolCallCount = result.ToolInvocations.Count, + PromptTokens = result.PromptTokens, + CompletionTokens = result.CompletionTokens + }); + } + catch (Exception ex) + { + AppendLine(""); + AppendLine($"FEHLER: {ex.Message}"); + _logger.Warn("Supervisor", $"Analyse fehlgeschlagen: {ex.Message}"); + } + finally + { + _ask.Enabled = true; + } + } + + private void AppendLine(string text) + { + if (_answer.InvokeRequired) { _answer.BeginInvoke(() => AppendLine(text)); return; } + _answer.AppendText(text + "\n"); + _answer.ScrollToCaret(); + } + + // ── Dossier ── + + private void LoadSignals() + { + try { _signals.DataSource = _dossiers.RecentSignals(200); } + catch (Exception ex) { _logger.Warn("Supervisor", $"Signale laden fehlgeschlagen: {ex.Message}"); } + } + + private void ShowSelectedDossier() + { + if (_signals.CurrentRow?.DataBoundItem is not SignalSummary s) return; + try { _dossier.Text = DossierBuilder.ToMarkdown(_dossiers.BuildForSignal(s.SignalId)); } + catch (Exception ex) { _dossier.Text = $"FEHLER: {ex.Message}"; } + } + + // ── Berichte ── + + private void LoadReports() + { + try + { + _reportsGrid.DataSource = _reports.GetRecent(100) + .Select(r => new { r.CreatedAt, r.Profile, r.Model, r.Question, r.ToolCallCount, r.PromptTokens, r.CompletionTokens }) + .ToList(); + } + catch (Exception ex) { _logger.Warn("Supervisor", $"Berichte laden fehlgeschlagen: {ex.Message}"); } + } +} diff --git a/tests/IBKRTrader.Tests/Analytics/DossierBuilderTests.cs b/tests/IBKRTrader.Tests/Analytics/DossierBuilderTests.cs new file mode 100644 index 0000000..44bbd59 --- /dev/null +++ b/tests/IBKRTrader.Tests/Analytics/DossierBuilderTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using IBKRTrader.Core.Analytics; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Entities; + +namespace IBKRTrader.Tests.Analytics; + +[Trait("cat", "unit")] +public class DossierBuilderTests +{ + [Fact] + public void Build_OrdersEverythingChronologically() + { + var decisions = new[] + { + new CoreDecisionRecord { SignalId = "s", Timestamp = new DateTime(2026,1,1,0,2,0,DateTimeKind.Utc), Decision = TradeDecision.Executed }, + new CoreDecisionRecord { SignalId = "s", Timestamp = new DateTime(2026,1,1,0,1,0,DateTimeKind.Utc), Decision = TradeDecision.Skipped } + }; + + var dossier = DossierBuilder.Build("s", decisions, + Array.Empty(), Array.Empty(), Array.Empty()); + + dossier.Decisions[0].Decision.Should().Be(TradeDecision.Skipped); // frühester zuerst + dossier.Decisions[1].Decision.Should().Be(TradeDecision.Executed); + } + + [Fact] + public void ToMarkdown_And_ToJson_ContainSignalId_AndData() + { + var decisions = new[] + { + new CoreDecisionRecord { SignalId = "sig-9", Module = "CT", Symbol = "AAPL", Side = "BUY", + Decision = TradeDecision.Rejected, Reason = DecisionReason.RiskRejected, Message = "Limit überschritten" } + }; + var dossier = DossierBuilder.Build("sig-9", decisions, + Array.Empty(), Array.Empty(), Array.Empty()); + + var md = DossierBuilder.ToMarkdown(dossier); + md.Should().Contain("sig-9").And.Contain("RiskRejected").And.Contain("Limit überschritten"); + + var json = DossierBuilder.ToJson(dossier); + json.Should().Contain("sig-9").And.Contain("RiskRejected"); + } +} diff --git a/tests/IBKRTrader.Tests/Analytics/RealizedPnlEngineTests.cs b/tests/IBKRTrader.Tests/Analytics/RealizedPnlEngineTests.cs new file mode 100644 index 0000000..7514d0c --- /dev/null +++ b/tests/IBKRTrader.Tests/Analytics/RealizedPnlEngineTests.cs @@ -0,0 +1,85 @@ +using FluentAssertions; +using IBKRTrader.Core.Analytics; +using IBKRTrader.Core.Persistence.Entities; + +namespace IBKRTrader.Tests.Analytics; + +[Trait("cat", "unit")] +public class RealizedPnlEngineTests +{ + private static CoreTrade Fill(string action, decimal qty, decimal price, int minute, string symbol = "AAPL") => + new() + { + Module = "CT", Symbol = symbol, Action = action, + Quantity = qty, Price = price, TotalValue = qty * price, + TradedAt = new DateTime(2026, 1, 1, 0, minute, 0, DateTimeKind.Utc) + }; + + [Fact] + public void BuyThenSellAll_RealizesFullPnl() + { + var fills = new[] { Fill("BUY", 10, 100m, 0), Fill("SELL", 10, 130m, 1) }; + + var realized = RealizedPnlEngine.Match(fills); + + realized.Should().HaveCount(1); + realized[0].RealizedPnl.Should().Be(300m); // (130-100)*10 + } + + [Fact] + public void Sell_MatchesOldestLotsFirst_Fifo() + { + var fills = new[] + { + Fill("BUY", 10, 100m, 0), + Fill("BUY", 10, 120m, 1), + Fill("SELL", 15, 130m, 2) // 10 gegen 100er-Lot, 5 gegen 120er-Lot + }; + + var realized = RealizedPnlEngine.Match(fills); + + realized.Should().HaveCount(2); + realized[0].RealizedPnl.Should().Be((130m - 100m) * 10m); // 300 + realized[1].RealizedPnl.Should().Be((130m - 120m) * 5m); // 50 + RealizedPnlEngine.TotalRealized(fills).Should().Be(350m); + } + + [Fact] + public void PartialSell_LeavesRemainderOpen() + { + var fills = new[] { Fill("BUY", 10, 100m, 0), Fill("SELL", 4, 130m, 1) }; + + var realized = RealizedPnlEngine.Match(fills); + + realized.Should().HaveCount(1); + realized[0].Quantity.Should().Be(4m); + realized[0].RealizedPnl.Should().Be(120m); + } + + [Fact] + public void SellExceedingHoldings_IgnoresSurplus_NoShort() + { + var fills = new[] { Fill("BUY", 5, 100m, 0), Fill("SELL", 8, 130m, 1) }; + + var realized = RealizedPnlEngine.Match(fills); + + realized.Should().HaveCount(1); + realized[0].Quantity.Should().Be(5m); // nur die gehaltenen 5 realisiert + } + + [Fact] + public void SeparatesBySymbol() + { + var fills = new[] + { + Fill("BUY", 10, 100m, 0, "AAPL"), + Fill("BUY", 10, 50m, 1, "MSFT"), + Fill("SELL", 10, 130m, 2, "AAPL") + }; + + var realized = RealizedPnlEngine.Match(fills); + + realized.Should().HaveCount(1); + realized[0].Symbol.Should().Be("AAPL"); + } +} diff --git a/tests/IBKRTrader.Tests/Analytics/TradeAnalyticsTests.cs b/tests/IBKRTrader.Tests/Analytics/TradeAnalyticsTests.cs new file mode 100644 index 0000000..f52ef89 --- /dev/null +++ b/tests/IBKRTrader.Tests/Analytics/TradeAnalyticsTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using IBKRTrader.Core.Analytics; +using IBKRTrader.Core.Persistence.Entities; + +namespace IBKRTrader.Tests.Analytics; + +[Trait("cat", "unit")] +public class TradeAnalyticsTests +{ + private static CoreTrade Fill(string module, string action, decimal qty, decimal price, int minute, string symbol = "AAPL") => + new() + { + Module = module, Symbol = symbol, Action = action, + Quantity = qty, Price = price, TotalValue = qty * price, + TradedAt = new DateTime(2026, 1, 1, 0, minute, 0, DateTimeKind.Utc) + }; + + [Fact] + public void EmptyInput_YieldsZeroKpis() + { + var k = TradeAnalytics.ComputeKpis(Array.Empty()); + + k.TradeCount.Should().Be(0); + k.NetPnl.Should().Be(0m); + k.WinRatePct.Should().Be(0d); + } + + [Fact] + public void ComputesWinRateAndProfitFactor() + { + var fills = new[] + { + Fill("CT", "BUY", 10, 100m, 0), + Fill("CT", "SELL", 10, 130m, 1), // +300 Gewinner + Fill("CT", "BUY", 10, 100m, 2, "MSFT"), + Fill("CT", "SELL", 10, 90m, 3, "MSFT") // -100 Verlierer + }; + + var k = TradeAnalytics.ComputeKpis(fills); + + k.TradeCount.Should().Be(2); + k.NetPnl.Should().Be(200m); + k.WinRatePct.Should().Be(50d); + k.ProfitFactor.Should().Be(3d); // 300 / 100 + } + + [Fact] + public void PnlByModule_GroupsAndSorts() + { + var fills = new[] + { + Fill("A", "BUY", 10, 100m, 0), + Fill("A", "SELL", 10, 130m, 1), // +300 + Fill("B", "BUY", 10, 100m, 2, "MSFT"), + Fill("B", "SELL", 10, 90m, 3, "MSFT") // -100 + }; + + var buckets = TradeAnalytics.PnlByModule(fills); + + buckets.Should().HaveCount(2); + buckets[0].Key.Should().Be("A"); + buckets[0].Pnl.Should().Be(300m); + buckets[1].Key.Should().Be("B"); + } +} diff --git a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj index 791807c..0cac877 100644 --- a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj +++ b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj @@ -24,6 +24,8 @@ + + diff --git a/tests/IBKRTrader.Tests/Logging/LogJsonTests.cs b/tests/IBKRTrader.Tests/Logging/LogJsonTests.cs new file mode 100644 index 0000000..eb47be6 --- /dev/null +++ b/tests/IBKRTrader.Tests/Logging/LogJsonTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.Tests.Logging; + +[Trait("cat", "unit")] +public class LogJsonTests +{ + [Fact] + public void RoundTrip_PreservesFields() + { + var ts = new DateTime(2026, 7, 30, 12, 34, 56, DateTimeKind.Utc); + var line = LogJson.WriteLine(ts, AppLogLevel.Warn, "CT", "Kurs fehlt für AAPL", "sig-123"); + + var parsed = LogJson.ParseLine(line); + + parsed.Should().NotBeNull(); + parsed!.Ts.Should().Be(ts); + parsed.Level.Should().Be("Warn"); + parsed.Source.Should().Be("CT"); + parsed.Cid.Should().Be("sig-123"); + parsed.Message.Should().Be("Kurs fehlt für AAPL"); + } + + [Fact] + public void WriteLine_OmitsCid_WhenNull() + { + var line = LogJson.WriteLine(DateTime.UtcNow, AppLogLevel.Info, "Core", "hello", null); + + line.Should().NotContain("cid"); + LogJson.ParseLine(line)!.Cid.Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("nicht json")] + [InlineData("{ kaputt")] + public void ParseLine_ReturnsNull_OnGarbage(string input) + { + LogJson.ParseLine(input).Should().BeNull(); + } +} diff --git a/tests/IBKRTrader.Tests/Modules/Accounting/AccountingClassifierTests.cs b/tests/IBKRTrader.Tests/Modules/Accounting/AccountingClassifierTests.cs new file mode 100644 index 0000000..c9d8167 --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/Accounting/AccountingClassifierTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using IBKRTrader.Modules.Accounting.Logic; +using IBKRTrader.Modules.Accounting.Models; + +namespace IBKRTrader.Tests.Modules.Accounting; + +[Trait("cat", "unit")] +public class AccountingClassifierTests +{ + [Fact] + public void ClassifyExecution_Buy_CostsGrossPlusFee() + { + var e = new RawExecution { AccountId = "U1", TradeId = "T1", Side = "BUY", GrossBase = 1000m, FeeBase = 1m, Quantity = 10, Currency = "USD" }; + + var entry = AccountingClassifier.ClassifyExecution(e, 5); + + entry.EventType.Should().Be(LedgerEventType.TradeBuy); + entry.NetBase.Should().Be(-1001m); + entry.IdempotencyKey.Should().Be("TRD|TradeBuy|T1"); + entry.IngestBatchId.Should().Be(5); + } + + [Fact] + public void ClassifyExecution_Sell_BringsGrossMinusFee() + { + var e = new RawExecution { AccountId = "U1", TradeId = "T2", Side = "SELL", GrossBase = 1300m, FeeBase = 1m }; + + var entry = AccountingClassifier.ClassifyExecution(e, 1); + + entry.EventType.Should().Be(LedgerEventType.TradeSell); + entry.NetBase.Should().Be(1299m); + } + + [Theory] + [InlineData("Dividends", LedgerEventType.Dividend)] + [InlineData("Withholding Tax", LedgerEventType.TaxWithholding)] + [InlineData("Broker Interest Received", LedgerEventType.Interest)] + [InlineData("Deposit", LedgerEventType.Deposit)] + [InlineData("Withdrawal", LedgerEventType.Withdrawal)] + public void MapCashType_MapsKnownTypes(string ibkrType, LedgerEventType expected) + { + AccountingClassifier.MapCashType(ibkrType).Should().Be(expected); + } + + [Fact] + public void ClassifyCashTransaction_KeepsReportedSign() + { + var div = new RawCashTransaction { AccountId = "U1", TransactionId = "C1", Type = "Dividends", AmountBase = 50m }; + var tax = new RawCashTransaction { AccountId = "U1", TransactionId = "C2", Type = "Withholding Tax", AmountBase = -7.5m }; + + AccountingClassifier.ClassifyCashTransaction(div, 1).NetBase.Should().Be(50m); + var t = AccountingClassifier.ClassifyCashTransaction(tax, 1); + t.EventType.Should().Be(LedgerEventType.TaxWithholding); + t.NetBase.Should().Be(-7.5m); + t.GrossBase.Should().Be(7.5m); + t.IdempotencyKey.Should().Be("CASH|TaxWithholding|C2"); + } +} diff --git a/tests/IBKRTrader.Tests/Modules/Accounting/AccountingEngineTests.cs b/tests/IBKRTrader.Tests/Modules/Accounting/AccountingEngineTests.cs new file mode 100644 index 0000000..955c865 --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/Accounting/AccountingEngineTests.cs @@ -0,0 +1,75 @@ +using FluentAssertions; +using IBKRTrader.Modules.Accounting.Logic; +using IBKRTrader.Modules.Accounting.Models; + +namespace IBKRTrader.Tests.Modules.Accounting; + +[Trait("cat", "unit")] +public class AccountingEngineTests +{ + private static LedgerEntry E(LedgerEventType type, decimal net, decimal gross, int day, decimal fee = 0m) => + new() + { + AccountId = "U1", EventType = type, NetBase = net, GrossBase = gross, FeeBase = fee, + Timestamp = new DateTime(2026, 3, day, 12, 0, 0, DateTimeKind.Utc) + }; + + [Fact] + public void Statement_SatisfiesBalanceInvariant() + { + var entries = new[] + { + E(LedgerEventType.Deposit, 1000m, 1000m, 1), + E(LedgerEventType.TradeBuy, -500m, 499m, 2, fee: 1m), + E(LedgerEventType.TradeSell, 650m, 651m, 3, fee: 1m), + E(LedgerEventType.Dividend, 20m, 20m, 4), + E(LedgerEventType.Withdrawal, -200m, 200m, 5) + }; + + var s = AccountingEngine.BuildStatement(entries, new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 3, 31, 23, 59, 59, DateTimeKind.Utc), "U1"); + + // Invariante: Endsaldo − Anfang = Ergebnis + Einzahlungen − Auszahlungen + s.BalanceChange.Should().Be(s.NetTradingResult + s.Deposits - s.Withdrawals); + s.Deposits.Should().Be(1000m); + s.Withdrawals.Should().Be(200m); + s.Dividends.Should().Be(20m); + s.Fees.Should().Be(2m); + s.TradeCount.Should().Be(2); + s.ClosingBalance.Should().Be(970m); // 1000 -500 +650 +20 -200 + } + + [Fact] + public void OpeningBalance_AccumulatesEntriesBeforeFrom() + { + var entries = new[] + { + E(LedgerEventType.Deposit, 500m, 500m, 1), // vor dem Zeitraum + E(LedgerEventType.Dividend, 30m, 30m, 20) // im Zeitraum + }; + + var s = AccountingEngine.BuildStatement(entries, new DateTime(2026, 3, 10, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 3, 31, 0, 0, 0, DateTimeKind.Utc), "U1"); + + s.OpeningBalance.Should().Be(500m); + s.ClosingBalance.Should().Be(530m); + } + + [Fact] + public void MonthlyBreakdown_ChainsOpeningBalances() + { + var entries = new[] + { + E(LedgerEventType.Deposit, 100m, 100m, 1), // März + E(LedgerEventType.Dividend, 10m, 10m, 5) + }; + + var monthly = AccountingEngine.BuildMonthlyBreakdown(entries, + new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc), "U1"); + + monthly.Should().HaveCount(2); + monthly[0].From.Month.Should().Be(3); + monthly[1].OpeningBalance.Should().Be(monthly[0].ClosingBalance); // April startet mit März-Endsaldo + } +} diff --git a/tests/IBKRTrader.Tests/Modules/Accounting/AccountingIngestServiceTests.cs b/tests/IBKRTrader.Tests/Modules/Accounting/AccountingIngestServiceTests.cs new file mode 100644 index 0000000..01cf4e7 --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/Accounting/AccountingIngestServiceTests.cs @@ -0,0 +1,101 @@ +using FluentAssertions; +using IBKRTrader.Core.Logging; +using IBKRTrader.Modules.Accounting.Models; +using IBKRTrader.Modules.Accounting.Persistence; +using IBKRTrader.Modules.Accounting.Services; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Tests.Modules.Accounting; + +/// Ingest-Kern gegen EF-InMemory: Idempotenz (Doppel-Ingest bucht nicht doppelt) + Balance-Anker. +[Trait("cat", "unit")] +public class AccountingIngestServiceTests +{ + private sealed class Factory(DbContextOptions options) : IDbContextFactory + { + public AccountingDbContext CreateDbContext() => new(options); + } + + private sealed class FakeStatement : IStatementSource + { + public IReadOnlyList Executions = Array.Empty(); + public IReadOnlyList Cash = Array.Empty(); + public Task> GetExecutionsAsync(string a, DateTime? s, CancellationToken ct) => Task.FromResult(Executions); + public Task> GetCashTransactionsAsync(string a, DateTime? s, CancellationToken ct) => Task.FromResult(Cash); + } + + private sealed class FakeBalance(decimal? v) : IBalanceAnchorSource + { + public Task GetBalanceAsync(string a, CancellationToken ct) => Task.FromResult(v); + } + + private static (AccountingIngestService svc, ILedgerRepository ledger) Build(FakeStatement stmt, decimal? anchor = null) + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + var dbf = new Factory(opts); + var ledger = new EfLedgerRepository(dbf); + var svc = new AccountingIngestService( + new NullAccountSource(), ledger, new EfIngestRunRepository(dbf), new EfRawSnapshotRepository(dbf), + stmt, new FakeBalance(anchor), new LoggingService()); + return (svc, ledger); + } + + [Fact] + public async Task DoubleIngest_IsIdempotent() + { + var stmt = new FakeStatement + { + Executions = new[] + { + new RawExecution { AccountId = "U1", TradeId = "T1", Side = "BUY", GrossBase = 1000m, FeeBase = 1m, + Timestamp = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc) } + }, + Cash = new[] + { + new RawCashTransaction { AccountId = "U1", TransactionId = "C1", Type = "Dividends", AmountBase = 20m, + Timestamp = new DateTime(2026, 1, 2, 10, 0, 0, DateTimeKind.Utc) } + } + }; + var (svc, ledger) = Build(stmt); + + var run1 = await svc.IngestAccountAsync("U1", backfill: true, CancellationToken.None); + var run2 = await svc.IngestAccountAsync("U1", backfill: true, CancellationToken.None); + + run1.NewEntries.Should().Be(2); + run1.DuplicateEntries.Should().Be(0); + run2.NewEntries.Should().Be(0); // zweiter Lauf bucht nichts neu + run2.DuplicateEntries.Should().Be(2); + ledger.Count("U1").Should().Be(2); + } + + [Fact] + public async Task ComputesBalanceDelta_AgainstAnchor() + { + var stmt = new FakeStatement + { + Cash = new[] + { + new RawCashTransaction { AccountId = "U1", TransactionId = "D1", Type = "Deposit", AmountBase = 1000m, + Timestamp = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc) } + } + }; + var (svc, _) = Build(stmt, anchor: 1000m); + + var run = await svc.IngestAccountAsync("U1", backfill: true, CancellationToken.None); + + run.LedgerNetBase.Should().Be(1000m); + run.BalanceAnchorBase.Should().Be(1000m); + run.BalanceDeltaBase.Should().Be(0m); // vollständig + } + + [Fact] + public async Task NoAccounts_IngestAll_DoesNothing() + { + var (svc, ledger) = Build(new FakeStatement()); + + await svc.IngestAllAsync(backfill: false, CancellationToken.None); + + ledger.DistinctAccounts().Should().BeEmpty(); + } +} diff --git a/tests/IBKRTrader.Tests/Modules/Accounting/CsvExporterTests.cs b/tests/IBKRTrader.Tests/Modules/Accounting/CsvExporterTests.cs new file mode 100644 index 0000000..d6c5c81 --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/Accounting/CsvExporterTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; +using IBKRTrader.Modules.Accounting.Logic; +using IBKRTrader.Modules.Accounting.Models; + +namespace IBKRTrader.Tests.Modules.Accounting; + +[Trait("cat", "unit")] +public class CsvExporterTests +{ + [Fact] + public void Ledger_HasHeader_AndInvariantFormatting() + { + var entries = new[] + { + new LedgerEntry + { + AccountId = "U1", EventType = LedgerEventType.TradeSell, Side = "SELL", Symbol = "AAPL", + Currency = "USD", Quantity = 10m, PriceNative = 130.5m, GrossBase = 1305m, FeeBase = 1m, + NetBase = 1304m, TransactionId = "T1", Source = "ibkr-flex", + Timestamp = new DateTime(2026, 1, 2, 15, 4, 5, DateTimeKind.Utc) + } + }; + + var csv = CsvExporter.Ledger(entries); + + csv.Should().StartWith("Timestamp,AccountId,EventType,Side,Symbol"); + csv.Should().Contain("2026-01-02 15:04:05"); + csv.Should().Contain("130.5"); // Punkt-Dezimal, kulturinvariant + csv.Should().Contain("TradeSell"); + } + + [Fact] + public void Quote_EscapesCommasAndQuotes() + { + var entries = new[] + { + new LedgerEntry { AccountId = "U1", Symbol = "A,B\"C", EventType = LedgerEventType.Other, TransactionId = "X" } + }; + + var csv = CsvExporter.Ledger(entries); + + csv.Should().Contain("\"A,B\"\"C\""); + } + + [Fact] + public void Statement_ListsKeyMetrics() + { + var s = new PeriodStatement("U1", DateTime.UtcNow.AddDays(-30), DateTime.UtcNow, + OpeningBalance: 100m, ClosingBalance: 150m, Deposits: 50m, Withdrawals: 0m, + TradeVolume: 200m, Dividends: 5m, Interest: 0m, Fees: 2m, TaxWithheld: 1m, + NetTradingResult: 0m, TradeCount: 3, EntryCount: 6); + + var csv = CsvExporter.Statement(s, "USD"); + + csv.Should().Contain("Kennzahl,USD"); + csv.Should().Contain("Anfangssaldo,100"); + csv.Should().Contain("Endsaldo,150"); + } +} diff --git a/tests/IBKRTrader.Tests/Modules/Accounting/FxConverterTests.cs b/tests/IBKRTrader.Tests/Modules/Accounting/FxConverterTests.cs new file mode 100644 index 0000000..c13739c --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/Accounting/FxConverterTests.cs @@ -0,0 +1,39 @@ +using FluentAssertions; +using IBKRTrader.Modules.Accounting.Logic; +using IBKRTrader.Modules.Accounting.Models; + +namespace IBKRTrader.Tests.Modules.Accounting; + +[Trait("cat", "unit")] +public class FxConverterTests +{ + private static FxRate R(int day, decimal rate) => + new() { Date = new DateTime(2026, 5, day), UsdToEur = rate, Source = "ECB" }; + + [Fact] + public void UsesNearestRateOnOrBefore() + { + var conv = new FxConverter(new[] { R(1, 0.90m), R(10, 0.92m) }); + + conv.UsdToEurOn(new DateTime(2026, 5, 5)).Should().Be(0.90m); // zwischen 1. und 10. → 0.90 + conv.UsdToEurOn(new DateTime(2026, 5, 10)).Should().Be(0.92m); // exakt + conv.UsdToEurOn(new DateTime(2026, 5, 20)).Should().Be(0.92m); // nach letztem → letzter + } + + [Fact] + public void ReturnsNull_WhenNoRateBeforeDate() + { + var conv = new FxConverter(new[] { R(10, 0.92m) }); + + conv.UsdToEurOn(new DateTime(2026, 5, 1)).Should().BeNull(); + conv.UsdToEur(100m, new DateTime(2026, 5, 1)).Should().BeNull(); + } + + [Fact] + public void ConvertsAndRounds() + { + var conv = new FxConverter(new[] { R(1, 0.9123m) }); + + conv.UsdToEur(100m, new DateTime(2026, 5, 2)).Should().Be(91.23m); + } +} diff --git a/tests/IBKRTrader.Tests/Modules/Supervisor/McpJsonRpcTests.cs b/tests/IBKRTrader.Tests/Modules/Supervisor/McpJsonRpcTests.cs new file mode 100644 index 0000000..58ab626 --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/Supervisor/McpJsonRpcTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; +using IBKRTrader.Modules.Supervisor.Agent; +using IBKRTrader.Modules.Supervisor.Mcp; + +namespace IBKRTrader.Tests.Modules.Supervisor; + +[Trait("cat", "unit")] +public class McpJsonRpcTests +{ + private static SupervisorToolRegistry Registry() + { + var reg = new SupervisorToolRegistry(); + reg.Register(new SupervisorTool("echo", "Echo", """{"type":"object","properties":{"x":{"type":"string"}}}""", + args => SupervisorToolRegistry.GetString(args, "x") ?? "")); + return reg; + } + + [Fact] + public void Initialize_ReturnsServerInfo() + { + var res = McpJsonRpc.Handle("""{"jsonrpc":"2.0","id":1,"method":"initialize"}""", Registry()); + res.Should().Contain("ibkrtrader-supervisor").And.Contain("protocolVersion"); + } + + [Fact] + public void ToolsList_ListsTools() + { + var res = McpJsonRpc.Handle("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}""", Registry()); + res.Should().Contain("echo").And.Contain("inputSchema"); + } + + [Fact] + public void ToolsCall_ExecutesAndWrapsResult() + { + var res = McpJsonRpc.Handle( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"x":"hi"}}}""", + Registry()); + res.Should().Contain("\"text\":\"hi\"").And.Contain("\"isError\":false"); + } + + [Fact] + public void UnknownMethod_ReturnsMethodNotFound() + { + var res = McpJsonRpc.Handle("""{"jsonrpc":"2.0","id":4,"method":"nope"}""", Registry()); + res.Should().Contain("-32601"); + } + + [Fact] + public void ParseError_ReturnsMinus32700() + { + McpJsonRpc.Handle("{ kaputt", Registry()).Should().Contain("-32700"); + } + + [Fact] + public void Notification_WithoutId_ReturnsNull() + { + McpJsonRpc.Handle("""{"jsonrpc":"2.0","method":"ping"}""", Registry()).Should().BeNull(); + } +} diff --git a/tests/IBKRTrader.Tests/Modules/Supervisor/OpenRouterClientTests.cs b/tests/IBKRTrader.Tests/Modules/Supervisor/OpenRouterClientTests.cs new file mode 100644 index 0000000..5c9c2aa --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/Supervisor/OpenRouterClientTests.cs @@ -0,0 +1,55 @@ +using FluentAssertions; +using IBKRTrader.Modules.Supervisor.Agent; + +namespace IBKRTrader.Tests.Modules.Supervisor; + +[Trait("cat", "unit")] +public class OpenRouterClientTests +{ + private static SupervisorTool Tool() => new( + "get_kpis", "KPIs", """{"type":"object","properties":{"module":{"type":"string"}}}""", _ => "{}"); + + [Fact] + public void BuildRequestBody_IncludesModelMessagesAndTools() + { + var messages = new[] { ChatMessage.System("sys"), ChatMessage.User("frage?") }; + var body = OpenRouterClient.BuildRequestBody("openrouter/auto", messages, new[] { Tool() }); + + body.Should().Contain("\"model\":\"openrouter/auto\""); + body.Should().Contain("\"role\":\"system\""); + body.Should().Contain("frage?"); + body.Should().Contain("\"name\":\"get_kpis\""); + body.Should().Contain("\"parameters\""); + } + + [Fact] + public void ParseResponse_ExtractsContentAndUsage() + { + const string json = """ + {"choices":[{"message":{"content":"Antwort","role":"assistant"}}], + "usage":{"prompt_tokens":12,"completion_tokens":3}} + """; + + var r = OpenRouterClient.ParseResponse(json); + + r.Content.Should().Be("Antwort"); + r.ToolCalls.Should().BeEmpty(); + r.PromptTokens.Should().Be(12); + r.CompletionTokens.Should().Be(3); + } + + [Fact] + public void ParseResponse_ExtractsToolCalls() + { + const string json = """ + {"choices":[{"message":{"role":"assistant","content":null, + "tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_kpis","arguments":"{\"module\":\"CT\"}"}}]}}]} + """; + + var r = OpenRouterClient.ParseResponse(json); + + r.ToolCalls.Should().HaveCount(1); + r.ToolCalls[0].Name.Should().Be("get_kpis"); + r.ToolCalls[0].ArgumentsJson.Should().Contain("CT"); + } +} diff --git a/tests/IBKRTrader.Tests/Modules/Supervisor/SupervisorAgentTests.cs b/tests/IBKRTrader.Tests/Modules/Supervisor/SupervisorAgentTests.cs new file mode 100644 index 0000000..bac839a --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/Supervisor/SupervisorAgentTests.cs @@ -0,0 +1,75 @@ +using FluentAssertions; +using IBKRTrader.Modules.Supervisor.Agent; + +namespace IBKRTrader.Tests.Modules.Supervisor; + +[Trait("cat", "unit")] +public class SupervisorAgentTests +{ + /// Fake-Client: gibt vorab definierte Antworten der Reihe nach zurück. + private sealed class FakeChat : IChatCompletionClient + { + private readonly Queue _responses; + public List SeenToolResults { get; } = new(); + public FakeChat(params ChatResponse[] responses) => _responses = new(responses); + + public Task CompleteAsync(string model, IReadOnlyList messages, + IReadOnlyList tools, CancellationToken ct) + { + foreach (var m in messages) + if (m.Role == "tool" && m.Content != null) SeenToolResults.Add(m.Content); + return Task.FromResult(_responses.Dequeue()); + } + } + + private static SupervisorToolRegistry EchoRegistry() + { + var reg = new SupervisorToolRegistry(); + reg.Register(new SupervisorTool("get_kpis", "KPIs", """{"type":"object"}""", _ => "{\"NetPnl\":42}")); + return reg; + } + + [Fact] + public async Task RunsToolThenReturnsFinalAnswer() + { + var chat = new FakeChat( + new ChatResponse { ToolCalls = { new ToolCall("c1", "get_kpis", "{}") } }, + new ChatResponse { Content = "Netto-PnL ist 42." }); + var agent = new SupervisorAgent(chat, EchoRegistry()); + + var result = await agent.AskAsync("Wie ist die Performance?"); + + result.Answer.Should().Be("Netto-PnL ist 42."); + result.ToolInvocations.Should().ContainSingle(); + result.ToolInvocations[0].Tool.Should().Be("get_kpis"); + chat.SeenToolResults.Should().Contain(s => s.Contains("42")); // Tool-Ergebnis ging ans Modell zurück + } + + [Fact] + public async Task ProfileFilter_DeniesUnlistedTool() + { + var chat = new FakeChat( + new ChatResponse { ToolCalls = { new ToolCall("c1", "get_kpis", "{}") } }, + new ChatResponse { Content = "fertig" }); + // Technik-Profil listet get_kpis NICHT → Ausführung verweigert. + var agent = new SupervisorAgent(chat, EchoRegistry()); + + var result = await agent.AskAsync("test", profile: SupervisorProfiles.Technik); + + result.ToolInvocations[0].Result.Should().Contain("nicht freigegeben"); + } + + [Fact] + public async Task StopsAfterMaxIterations() + { + // Modell fordert IMMER ein Tool an → harte Iterationsgrenze greift. + var always = Enumerable.Range(0, SupervisorAgent.MaxIterations + 2) + .Select(_ => new ChatResponse { ToolCalls = { new ToolCall("c", "get_kpis", "{}") } }) + .ToArray(); + var agent = new SupervisorAgent(new FakeChat(always), EchoRegistry()); + + var result = await agent.AskAsync("Endlosschleife?"); + + result.Answer.Should().Contain("maximale Tool-Iterationen"); + } +} diff --git a/tests/IBKRTrader.Tests/Modules/Supervisor/SupervisorToolRegistryTests.cs b/tests/IBKRTrader.Tests/Modules/Supervisor/SupervisorToolRegistryTests.cs new file mode 100644 index 0000000..91be426 --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/Supervisor/SupervisorToolRegistryTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; +using IBKRTrader.Modules.Supervisor.Agent; + +namespace IBKRTrader.Tests.Modules.Supervisor; + +[Trait("cat", "unit")] +public class SupervisorToolRegistryTests +{ + private static SupervisorToolRegistry WithEcho() + { + var reg = new SupervisorToolRegistry(); + reg.Register(new SupervisorTool("echo", "Echo", """{"type":"object","properties":{"x":{"type":"string"}}}""", + args => SupervisorToolRegistry.GetString(args, "x") ?? "(leer)")); + return reg; + } + + [Fact] + public void UnknownTool_ReturnsErrorText_DoesNotThrow() + { + var reg = WithEcho(); + reg.Execute("nope", "{}").Should().StartWith("FEHLER: Unbekanntes Tool"); + } + + [Fact] + public void InvalidJsonArgs_ReturnsErrorText() + { + var reg = WithEcho(); + reg.Execute("echo", "{ kaputt").Should().StartWith("FEHLER: Ungültige Tool-Argumente"); + } + + [Fact] + public void ExecutesRegisteredTool() + { + var reg = WithEcho(); + reg.Execute("echo", """{"x":"hallo"}""").Should().Be("hallo"); + } + + [Fact] + public void ToolException_IsCaught_AsErrorText() + { + var reg = new SupervisorToolRegistry(); + reg.Register(new SupervisorTool("boom", "Boom", """{"type":"object"}""", + _ => throw new InvalidOperationException("geplatzt"))); + + reg.Execute("boom", "{}").Should().Contain("geplatzt"); + } +} diff --git a/tests/IBKRTrader.Tests/Persistence/AnalysisJournalsTests.cs b/tests/IBKRTrader.Tests/Persistence/AnalysisJournalsTests.cs new file mode 100644 index 0000000..7fb1da1 --- /dev/null +++ b/tests/IBKRTrader.Tests/Persistence/AnalysisJournalsTests.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Tests.Persistence; + +/// Entscheidungsjournal + Order-Event-Log gegen EF-InMemory, inkl. Robustheits-Garantie. +[Trait("cat", "unit")] +public class AnalysisJournalsTests +{ + private sealed class Factory(DbContextOptions options) : IDbContextFactory where T : DbContext + { + public T CreateDbContext() => (T)Activator.CreateInstance(typeof(T), options)!; + } + + /// Factory, die immer wirft – simuliert einen DB-Ausfall. + private sealed class ThrowingFactory : IDbContextFactory + { + public CoreDbContext CreateDbContext() => throw new InvalidOperationException("DB weg"); + } + + private static Factory InMemory() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + + [Fact] + public void DecisionJournal_WritesAndQueriesBack() + { + var journal = new EfDecisionJournal(InMemory(), new LoggingService()); + + journal.Write(new CoreDecisionRecord + { + SignalId = "sig-1", Module = "CT", Symbol = "AAPL", Side = "BUY", + Decision = TradeDecision.Rejected, Reason = DecisionReason.RiskRejected, Message = "Limit" + }); + + var rows = journal.Query(d => d.SignalId == "sig-1"); + rows.Should().HaveCount(1); + rows[0].Reason.Should().Be(DecisionReason.RiskRejected); + } + + [Fact] + public void OrderEventLog_WritesAndQueriesBack() + { + var log = new EfOrderEventLog(InMemory(), new LoggingService()); + + log.Write(new CoreOrderEvent + { + SignalId = "sig-2", Module = "CT", Symbol = "AAPL", + EventType = OrderEventType.Filled, Side = "BUY", Quantity = 5, Price = 100m, Response = "OK" + }); + + var rows = log.Query(e => e.SignalId == "sig-2"); + rows.Should().HaveCount(1); + rows[0].EventType.Should().Be(OrderEventType.Filled); + } + + [Fact] + public void Write_NeverThrows_OnDbFailure() + { + var journal = new EfDecisionJournal(new ThrowingFactory(), new LoggingService()); + var log = new EfOrderEventLog(new ThrowingFactory(), new LoggingService()); + + var writeJournal = () => journal.Write(new CoreDecisionRecord { SignalId = "x" }); + var writeEvent = () => log.Write(new CoreOrderEvent { SignalId = "x" }); + + writeJournal.Should().NotThrow(); + writeEvent.Should().NotThrow(); + } +} diff --git a/tests/IBKRTrader.Tests/Trading/ExecutionServiceTests.cs b/tests/IBKRTrader.Tests/Trading/ExecutionServiceTests.cs index 657361c..0642971 100644 --- a/tests/IBKRTrader.Tests/Trading/ExecutionServiceTests.cs +++ b/tests/IBKRTrader.Tests/Trading/ExecutionServiceTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Trading; using NSubstitute; @@ -13,9 +14,11 @@ public class ExecutionServiceTests private readonly IRiskService _risk = Substitute.For(); private readonly IPortfolioService _portfolio = Substitute.For(); private readonly SettingsService _settings = new(); + private readonly IDecisionJournal _journal = Substitute.For(); + private readonly IOrderEventLog _orderLog = Substitute.For(); private ExecutionService CreateSut() => - new(_broker, _risk, _portfolio, _settings, new LoggingService()); + new(_broker, _risk, _portfolio, _settings, new LoggingService(), _journal, _orderLog); private static readonly TradeSignal BuySignal = new() { @@ -92,7 +95,7 @@ public class ExecutionServiceTests result.Executed.Should().BeTrue(); result.Order!.OrderId.Should().Be("O1"); await _portfolio.Received(1).RecordFillAsync( - "CT", "AAPL", TradeSide.Buy, 5, 100m, "O1", Arg.Any()); + "CT", "AAPL", TradeSide.Buy, 5, 100m, "O1", Arg.Any(), Arg.Any()); } [Fact] @@ -107,6 +110,34 @@ public class ExecutionServiceTests Arg.Any()); } + [Fact] + public async Task TradingDisabled_WritesSkippedDecision() + { + await CreateSut().ExecuteAsync(BuySignal); + + _journal.Received().Write(Arg.Is( + d => d.Decision == IBKRTrader.Core.Persistence.Entities.TradeDecision.Skipped && + d.Reason == IBKRTrader.Core.Persistence.Entities.DecisionReason.TradingDisabled)); + } + + [Fact] + public async Task HappyPath_PropagatesSignalId_AndJournalsExecuted() + { + ArrangeHappyPath(); + var signal = new TradeSignal { Symbol = "AAPL", Side = TradeSide.Buy, SourceModule = "CT", SignalId = "sig-abc" }; + + await CreateSut().ExecuteAsync(signal); + + await _portfolio.Received(1).RecordFillAsync( + "CT", "AAPL", TradeSide.Buy, 5, 100m, "O1", "sig-abc", Arg.Any()); + _journal.Received().Write(Arg.Is( + d => d.SignalId == "sig-abc" && + d.Decision == IBKRTrader.Core.Persistence.Entities.TradeDecision.Executed)); + _orderLog.Received().Write(Arg.Is( + e => e.SignalId == "sig-abc" && + e.EventType == IBKRTrader.Core.Persistence.Entities.OrderEventType.Filled)); + } + [Fact] public async Task OrderFails_ReturnsError_AndDoesNotBook() { @@ -120,6 +151,6 @@ public class ExecutionServiceTests result.Reason.Should().Contain("Broker abgelehnt"); await _portfolio.DidNotReceive().RecordFillAsync( Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } } diff --git a/tests/IBKRTrader.Tests/UiConstructionTests.cs b/tests/IBKRTrader.Tests/UiConstructionTests.cs new file mode 100644 index 0000000..16593d8 --- /dev/null +++ b/tests/IBKRTrader.Tests/UiConstructionTests.cs @@ -0,0 +1,87 @@ +using FluentAssertions; +using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Persistence; +using IBKRTrader.Core.Persistence.Ef; +using IBKRTrader.Modules.Accounting.Persistence; +using IBKRTrader.Modules.Accounting.Services; +using IBKRTrader.Modules.Accounting.Ui; +using IBKRTrader.Modules.Supervisor.Agent; +using IBKRTrader.Modules.Supervisor.Persistence; +using IBKRTrader.Modules.Supervisor.Services; +using IBKRTrader.Modules.Supervisor.Ui; +using Microsoft.EntityFrameworkCore; + +namespace IBKRTrader.Tests; + +/// +/// Konstruiert die neuen Modul-Fenster mit In-Memory-/Stub-Abhängigkeiten – gleichwertig zum +/// Headless-Smoke-UI-Check (`--smoke-ui`), aber ohne die laufende App/DB. Forms bauen im Konstruktor +/// nur Controls (DB-Zugriff erst auf Interaktion), daher genügt Instanziierbarkeit der Services. +/// +[Trait("cat", "unit")] +public class UiConstructionTests +{ + private sealed class Factory(DbContextOptions options) : IDbContextFactory where T : DbContext + { + public T CreateDbContext() => (T)Activator.CreateInstance(typeof(T), options)!; + } + + private static Factory InMemory() where T : DbContext => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + + private sealed class NoChat : IChatCompletionClient + { + public Task CompleteAsync(string m, IReadOnlyList msgs, + IReadOnlyList tools, CancellationToken ct) => Task.FromResult(new ChatResponse()); + } + + private static Exception? ConstructOnSta(Action action) + { + Exception? captured = null; + var t = new Thread(() => { try { action(); } catch (Exception ex) { captured = ex; } }); + t.SetApartmentState(ApartmentState.STA); + t.Start(); + t.Join(); + return captured; + } + + [Fact] + public void AccountingMainForm_Constructs() + { + var ex = ConstructOnSta(() => + { + var logger = new LoggingService(); + var accDbf = InMemory(); + var ledger = new EfLedgerRepository(accDbf); + var runs = new EfIngestRunRepository(accDbf); + var report = new AccountingReportService(ledger, new EfFxRateRepository(accDbf)); + var ingest = new AccountingIngestService( + new NullAccountSource(), ledger, runs, new EfRawSnapshotRepository(accDbf), + new NullStatementSource(), new NullBalanceAnchorSource(), logger); + + using var form = new AccountingMainForm(ledger, runs, report, ingest, logger); + form.Text.Should().Be("Accounting"); + }); + ex.Should().BeNull(); + } + + [Fact] + public void SupervisorMainForm_Constructs() + { + var ex = ConstructOnSta(() => + { + var logger = new LoggingService(); + var coreDbf = InMemory(); + var supDbf = InMemory(); + IDecisionJournal journal = new EfDecisionJournal(coreDbf, logger); + IOrderEventLog orderLog = new EfOrderEventLog(coreDbf, logger); + var dossiers = new DossierService(journal, orderLog, new TradeLogReader(coreDbf)); + var agent = new SupervisorAgent(new NoChat(), new SupervisorToolRegistry()); + var reports = new EfSupervisorReportRepository(supDbf, logger); + + using var form = new SupervisorMainForm(agent, dossiers, reports, logger); + form.Text.Should().Be("Supervisor"); + }); + ex.Should().BeNull(); + } +}