R8: Accounting- + Supervisor-Modul + Core-Datenfundament (S-0)

Portierung der beiden fehlenden Grundbausteine aus PolytraderSharp (voller Ausbau).

Core S-0 (Datenfundament fuer Analyse/Forensik):
- core_decision_journal + core_order_events (+ ReasonCode/Decision/OrderEvent-Enums),
  IDecisionJournal/IOrderEventLog mit fehlertoleranten EF-Impls (Handel bricht nie).
- SignalId-Durchreichung TradeSignal -> ExecutionService -> core_trade_history;
  ExecutionService schreibt an jeder Verzweigung Journal/Order-Events.
- JSONL-Log-Sink (LogJson + Dual-Sink), pure Analytik: RealizedPnlEngine (FIFO),
  TradeAnalytics, DossierBuilder. Migration AddAnalysisFoundation.

Accounting-Modul (acc_): unabhaengiger IBKR-Kontoauszug (Activity Flex Query) hinter
Interfaces mit Offline-Null-Stubs -> append-only Ledger + Periodenabrechnung/BWA + FX
(USD/EUR) + CSV/PDF (PDFsharp/MigraDoc). Steuerschicht bewusst offen (Platzhalter-Tab).
Kein Handel. Migration InitialAccounting.

Supervisor-Modul (sup_): read-only OpenRouter-Agent (Function-Calling-Loop) + read-only
Tool-Registry (8 Tools) + Profile + Dossier-Browser + Counterfactual-Job (Stub) +
Tagesbericht/MCP-Light (opt-in). Migration InitialSupervisor.

Verdrahtung: Program.cs (beide Module + Icons), slnx/App/Tests-Referenzen,
provision-db.ps1, AppSettings-Sektionen, docs/konzepte, README.

Tests: 79 -> 117 gruen (FIFO/KPIs/Dossier/JSONL, Classifier/Engine/FX/Idempotenz,
OpenRouter/Registry/Agent/MCP, STA-Konstruktion beider neuen Fenster).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-31 09:25:18 +02:00
co-authored by Claude Opus 4.8
parent cbbedb2e0e
commit 2a312ca035
86 changed files with 6880 additions and 22 deletions
+2
View File
@@ -43,6 +43,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="src\IBKRTrader.Core\IBKRTrader.Core.csproj" /> <ProjectReference Include="src\IBKRTrader.Core\IBKRTrader.Core.csproj" />
<ProjectReference Include="src\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" /> <ProjectReference Include="src\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
<ProjectReference Include="src\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
<ProjectReference Include="src\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+2
View File
@@ -2,5 +2,7 @@
<Project Path="IBKRTrader.App.csproj" /> <Project Path="IBKRTrader.App.csproj" />
<Project Path="src/IBKRTrader.Core/IBKRTrader.Core.csproj" /> <Project Path="src/IBKRTrader.Core/IBKRTrader.Core.csproj" />
<Project Path="src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj" /> <Project Path="src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj" />
<Project Path="src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj" />
<Project Path="src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj" />
<Project Path="tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj" /> <Project Path="tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj" />
</Solution> </Solution>
+12 -2
View File
@@ -5,12 +5,16 @@ using IBKRTrader.Core.DependencyInjection;
using IBKRTrader.Core.IBKR; using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Persistence;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Security; using IBKRTrader.Core.Security;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading; using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers;
using IBKRTrader.Core.Workers.BuiltIn; using IBKRTrader.Core.Workers.BuiltIn;
using IBKRTrader.Modules.Accounting;
using IBKRTrader.Modules.CongressTrading; using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.Supervisor;
using IBKRTrader.UI; using IBKRTrader.UI;
using IBKRTrader.UI.Views; using IBKRTrader.UI.Views;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@@ -42,7 +46,7 @@ internal static class Program
ApplicationConfiguration.Initialize(); ApplicationConfiguration.Initialize();
var modules = new List<IModule> { new CongressTradingModule() }; var modules = new List<IModule> { new CongressTradingModule(), new AccountingModule(), new SupervisorModule() };
AppHost = Host.CreateDefaultBuilder() AppHost = Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory) .UseContentRoot(AppContext.BaseDirectory)
@@ -110,6 +114,10 @@ internal static class Program
services.AddSingleton<TradeHistoryService>(); services.AddSingleton<TradeHistoryService>();
services.AddSingleton<AIModelService>(); services.AddSingleton<AIModelService>();
// Datenfundament für Analyse/Forensik (Supervisor): Entscheidungsjournal + Order-Events.
services.AddSingleton<IDecisionJournal, EfDecisionJournal>();
services.AddSingleton<IOrderEventLog, EfOrderEventLog>();
// Trading-Kern // Trading-Kern
services.AddSingleton<DashboardService>(); services.AddSingleton<DashboardService>();
services.AddSingleton<IRiskService, RiskService>(); services.AddSingleton<IRiskService, RiskService>();
@@ -156,6 +164,8 @@ internal static class Program
["core.logs"] = Properties.Resources.error_log, ["core.logs"] = Properties.Resources.error_log,
["core.settings"] = Properties.Resources.setting_tools, ["core.settings"] = Properties.Resources.setting_tools,
["congresstrading.main"] = Properties.Resources.cross_reference, ["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) foreach (var view in uiHost.Views)
if (view.Icon is null && map.TryGetValue(view.Id, out var img)) if (view.Icon is null && map.TryGetValue(view.Id, out var img))
@@ -253,7 +263,7 @@ internal static class Program
{ {
ApplicationConfiguration.Initialize(); ApplicationConfiguration.Initialize();
var modules = new List<IModule> { new CongressTradingModule() }; var modules = new List<IModule> { new CongressTradingModule(), new AccountingModule(), new SupervisorModule() };
using var host = Host.CreateDefaultBuilder() using var host = Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory) .UseContentRoot(AppContext.BaseDirectory)
+15
View File
@@ -16,6 +16,8 @@ tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory)
- **Persistenz**: EF Core (Pomelo/MariaDB), Migrationen **extern** angewendet (nicht zur Laufzeit). - **Persistenz**: EF Core (Pomelo/MariaDB), Migrationen **extern** angewendet (nicht zur Laufzeit).
- **Trading-Kern**: `IExecutionService` (Signal→Risiko→Order→Buchung), `IRiskService`, `IPortfolioService`, - **Trading-Kern**: `IExecutionService` (Signal→Risiko→Order→Buchung), `IRiskService`, `IPortfolioService`,
Broker hinter `IBrokerClient` (Default: `NullBrokerClient` handelt nie, bis IBKR angebunden). 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). - Details: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Build & Test ## 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`). - `appsettings.Local.json` (gitignored) hält den DB-Connection-String (`Database:MySqlConnectionString`).
- `settings.json` (gitignored) App-Settings (IBKR-Ports, Logging, Worker, Trading). - `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). - 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 023) und `IBKRTRADER_MCP_PORT` (MCP-Light,
nur 127.0.0.1).
## Datenbank aufsetzen ## Datenbank aufsetzen
Schema wird per EF-Migrationen extern angewendet siehe [scripts/README.md](scripts/README.md): 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 ## Module
- **CongressTrading** kopiert US-Kongress-Trades (capitoltrades.com) → `TradeSignal` → ExecutionService. - **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 ## Status / Nächstes
- Kurskorrektur auf das PolytraderSharp-Konzept (R1R7) abgeschlossen. - Kurskorrektur auf das PolytraderSharp-Konzept (R1R7) 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) - **Nächster Meilenstein:** IBKR-Broker über die **TWS API / IB Gateway** (blockiert bis Paper-Zugang)
Plan: [docs/IBKR-Integration.md](docs/IBKR-Integration.md). Plan: [docs/IBKR-Integration.md](docs/IBKR-Integration.md).
- **Sicherheit:** DB-Passwort rotieren (liegt in der Git-Historie, Commit `ebeb035`). - **Sicherheit:** DB-Passwort rotieren (liegt in der Git-Historie, Commit `ebeb035`).
+69
View File
@@ -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: `<Trade>` (Käufe/Verkäufe: Preis, Menge, Kommission,
Währung, FX-Rate zur Basiswährung, tradeID) und `<CashTransaction>` (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|<Typ>|<tradeID>` bzw. `CASH|<Typ>|<transactionID>`.
- **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: EndsaldoAnfang = 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.
+63
View File
@@ -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 023).
## 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.
+8
View File
@@ -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 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 } 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 Write-Host "DB-Provisioning abgeschlossen." -ForegroundColor Green
@@ -0,0 +1,95 @@
using System.Text;
using System.Text.Json;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Entities;
namespace IBKRTrader.Core.Analytics;
/// <summary>
/// Das komplette, rekonstruierte Bild zu einer <c>SignalId</c>: Entscheidungskette, Order-Events,
/// gebuchte Trades und der zugehörige JSONL-Log-Auszug. Read-only zusammengesetzt.
/// </summary>
public sealed record TradeDossier(
string SignalId,
IReadOnlyList<CoreDecisionRecord> Decisions,
IReadOnlyList<CoreOrderEvent> OrderEvents,
IReadOnlyList<CoreTrade> Trades,
IReadOnlyList<LogJson.ParsedLogLine> LogLines);
/// <summary>
/// Reiner Zusammenbau + Rendering eines Dossiers (JSON fürs Modell, Markdown für Menschen). Keine I/O
/// die Beschaffung (DB-Queries, JSONL-Lesen) liegt im <c>DossierService</c> des Supervisor-Moduls.
/// </summary>
public static class DossierBuilder
{
public static TradeDossier Build(
string signalId,
IEnumerable<CoreDecisionRecord> decisions,
IEnumerable<CoreOrderEvent> orderEvents,
IEnumerable<CoreTrade> trades,
IEnumerable<LogJson.ParsedLogLine> 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
});
}
}
@@ -0,0 +1,81 @@
using IBKRTrader.Core.Persistence.Entities;
namespace IBKRTrader.Core.Analytics;
/// <summary>
/// Ein geschlossener (realisierter) Teil-Trade: eine SELL-Menge, gegen ihre FIFO-gematchten BUY-Lots
/// abgerechnet. Aus den Fills der <c>core_trade_history</c> rein rechnerisch abgeleitet.
/// </summary>
public sealed record RealizedTrade(
string Module,
string Symbol,
decimal Quantity,
decimal BuyPrice,
decimal SellPrice,
DateTime OpenedAt,
DateTime ClosedAt)
{
/// <summary>Realisierte GuV dieser Menge (Erlös Einstand); Fees sind hier nicht berücksichtigt.</summary>
public decimal RealizedPnl => (SellPrice - BuyPrice) * Quantity;
}
/// <summary>
/// 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
/// (<see cref="TradeAnalytics"/>) 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).
/// </summary>
public static class RealizedPnlEngine
{
private sealed class Lot
{
public decimal Quantity;
public decimal Price;
public DateTime OpenedAt;
}
/// <summary>Matcht alle Fills zu realisierten Teil-Trades (chronologisch, FIFO je Modul+Symbol).</summary>
public static IReadOnlyList<RealizedTrade> Match(IEnumerable<CoreTrade> fills)
{
var result = new List<RealizedTrade>();
var groups = fills
.GroupBy(f => (f.Module, f.Symbol));
foreach (var g in groups)
{
var open = new Queue<Lot>();
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;
}
/// <summary>Summe der realisierten GuV über alle gematchten Teil-Trades.</summary>
public static decimal TotalRealized(IEnumerable<CoreTrade> fills) =>
Match(fills).Sum(t => t.RealizedPnl);
}
@@ -0,0 +1,47 @@
using IBKRTrader.Core.Persistence.Entities;
namespace IBKRTrader.Core.Analytics;
/// <summary>Kennzahlen über realisierte Trades.</summary>
public sealed record Kpis(
int TradeCount,
decimal NetPnl,
double WinRatePct,
decimal AvgPnlPerTrade,
double ProfitFactor);
/// <summary>GuV-Aufteilung nach einem Schlüssel (z. B. Modul).</summary>
public sealed record PnlBucket(string Key, decimal Pnl, int Count);
/// <summary>
/// Reine KPI-Berechnung über die Fills der <c>core_trade_history</c>: erst FIFO-Realisierung
/// (<see cref="RealizedPnlEngine"/>), dann Aggregat. Genutzt von den Supervisor-Tools (get_kpis).
/// </summary>
public static class TradeAnalytics
{
public static Kpis ComputeKpis(IEnumerable<CoreTrade> 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);
}
/// <summary>Realisierte GuV je Modul (absteigend nach GuV).</summary>
public static IReadOnlyList<PnlBucket> PnlByModule(IEnumerable<CoreTrade> 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();
}
+59
View File
@@ -0,0 +1,59 @@
using System.Text;
using System.Text.Json;
namespace IBKRTrader.Core.Logging;
/// <summary>
/// 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 <see cref="LoggingService"/>; hier nur das Format, damit es unit-getestet werden
/// kann (Round-Trip). Feldnamen bewusst kurz: ts, level, source, cid, message.
/// </summary>
public static class LogJson
{
private static readonly JsonSerializerOptions Opts = new()
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>Eine geparste JSONL-Zeile (Beiwerk fehlende/kaputte Zeilen liefern null).</summary>
public sealed record ParsedLogLine(DateTime Ts, string Level, string Source, string? Cid, string Message);
/// <summary>Serialisiert ein Log-Event zu genau einer JSON-Zeile (ohne Zeilenumbruch).</summary>
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());
}
/// <summary>Parst eine JSONL-Zeile. Gibt null zurück, wenn die Zeile leer oder kein gültiges JSON ist.</summary>
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;
}
}
}
+35 -1
View File
@@ -12,6 +12,7 @@ public class LoggingService
private RichTextBox? _rtb; private RichTextBox? _rtb;
private AppLogLevel _minLevel = AppLogLevel.Info; 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 = private static readonly string LogBaseDir =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
@@ -33,11 +34,23 @@ public class LoggingService
public void Error(string module, string message, Exception? ex = null) public void Error(string module, string message, Exception? ex = null)
=> Write(AppLogLevel.Error, module, message, ex); => 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; if (level < _minLevel) return;
var entry = new LogEntry(DateTime.Now, level, module, message, ex); var entry = new LogEntry(DateTime.Now, level, module, message, ex);
WriteToFile(entry); WriteToFile(entry);
WriteToJsonl(entry, cid);
WriteToRtb(entry); WriteToRtb(entry);
} }
@@ -61,6 +74,27 @@ public class LoggingService
catch { /* Logging darf niemals abstürzen */ } catch { /* Logging darf niemals abstürzen */ }
} }
// ─── JSONL (KI-freundlicher Zweit-Sink) ─────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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 ────────────────────────────────────────────────────────── // ─── RichTextBox ──────────────────────────────────────────────────────────
private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150); private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150);
@@ -18,6 +18,10 @@ public class CoreDbContext : DbContext
public DbSet<CoreWorkerLog> WorkerLog => Set<CoreWorkerLog>(); public DbSet<CoreWorkerLog> WorkerLog => Set<CoreWorkerLog>();
public DbSet<CoreSetting> Settings => Set<CoreSetting>(); public DbSet<CoreSetting> Settings => Set<CoreSetting>();
// Datenfundament für Analyse/Forensik (Supervisor)
public DbSet<CoreDecisionRecord> DecisionJournal => Set<CoreDecisionRecord>();
public DbSet<CoreOrderEvent> OrderEvents => Set<CoreOrderEvent>();
// IBKR-Marktdaten // IBKR-Marktdaten
public DbSet<IBKRInstrument> Instruments => Set<IBKRInstrument>(); public DbSet<IBKRInstrument> Instruments => Set<IBKRInstrument>();
public DbSet<IBKRMarketBar> MarketBars => Set<IBKRMarketBar>(); public DbSet<IBKRMarketBar> MarketBars => Set<IBKRMarketBar>();
@@ -41,6 +45,7 @@ public class CoreDbContext : DbContext
e.Property(x => x.Module).HasMaxLength(50); e.Property(x => x.Module).HasMaxLength(50);
e.Property(x => x.Symbol).HasMaxLength(20); e.Property(x => x.Symbol).HasMaxLength(20);
e.Property(x => x.Action).HasMaxLength(10); 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.IbkrOrderId).HasMaxLength(100);
e.Property(x => x.Status).HasMaxLength(50); e.Property(x => x.Status).HasMaxLength(50);
e.Property(x => x.Quantity).HasPrecision(18, 4); 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.Property(x => x.TotalValue).HasPrecision(18, 4);
e.HasIndex(x => x.Symbol); e.HasIndex(x => x.Symbol);
e.HasIndex(x => x.Module); e.HasIndex(x => x.Module);
e.HasIndex(x => x.SignalId);
}); });
b.Entity<CoreBudget>(e => b.Entity<CoreBudget>(e =>
@@ -119,5 +125,39 @@ public class CoreDbContext : DbContext
e.Property(x => x.Source).HasMaxLength(50); e.Property(x => x.Source).HasMaxLength(50);
e.Property(x => x.Ticker).HasMaxLength(50); e.Property(x => x.Ticker).HasMaxLength(50);
}); });
b.Entity<CoreDecisionRecord>(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<string>().HasMaxLength(20);
e.Property(x => x.Reason).HasConversion<string>().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<CoreOrderEvent>(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<string>().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 });
});
} }
} }
@@ -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;
/// <summary>
/// EF-Implementierung des Entscheidungsjournals. <see cref="Write"/> 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).
/// </summary>
public sealed class EfDecisionJournal : IDecisionJournal
{
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly LoggingService _logger;
public EfDecisionJournal(IDbContextFactory<CoreDbContext> 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<CoreDecisionRecord> Query(Expression<Func<CoreDecisionRecord, bool>> predicate, int limit = 1000)
{
using var db = _dbf.CreateDbContext();
return db.DecisionJournal.AsNoTracking()
.Where(predicate)
.OrderByDescending(r => r.Timestamp)
.Take(limit)
.ToList();
}
}
/// <summary>EF-Implementierung des Order-Lifecycle-Logs (gleiche Robustheits-Garantie).</summary>
public sealed class EfOrderEventLog : IOrderEventLog
{
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly LoggingService _logger;
public EfOrderEventLog(IDbContextFactory<CoreDbContext> 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<CoreOrderEvent> Query(Expression<Func<CoreOrderEvent, bool>> predicate, int limit = 1000)
{
using var db = _dbf.CreateDbContext();
return db.OrderEvents.AsNoTracking()
.Where(predicate)
.OrderByDescending(r => r.Timestamp)
.Take(limit)
.ToList();
}
}
@@ -0,0 +1,484 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRExternalIdentifier", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<string>("CompanyName")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Exchange")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<long>("IbkrConid")
.HasColumnType("bigint");
b.Property<string>("Industry")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Isin")
.HasMaxLength(12)
.HasColumnType("varchar(12)");
b.Property<DateTime?>("LastFetched")
.HasColumnType("datetime(6)");
b.Property<string>("PrimaryExchange")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("SecType")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Sector")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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<long>("InstrumentId")
.HasColumnType("bigint");
b.Property<string>("BarSize")
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<int?>("BarCount")
.HasColumnType("int");
b.Property<decimal>("Close")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("High")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Low")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<decimal>("Open")
.HasPrecision(12, 6)
.HasColumnType("decimal(12,6)");
b.Property<long>("Volume")
.HasColumnType("bigint");
b.Property<decimal?>("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<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<decimal>("MaxPerTrade")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TotalBudget")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContextJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Response")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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<string>("Module")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Symbol")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("AvgPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Module", "Symbol");
b.ToTable("core_position", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("core_settings", (string)null);
});
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreTrade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("IbkrOrderId")
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("SignalId")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("TotalValue")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("FinishedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Message")
.HasColumnType("longtext");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("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
}
}
}
@@ -0,0 +1,130 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IBKRTrader.Core.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class AddAnalysisFoundation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Timestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false),
SignalId = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Module = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Symbol = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Side = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SignalPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Decision = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ContextJson = table.Column<string>(type: "text", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Message = table.Column<string>(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<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Timestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false),
SignalId = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Module = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Symbol = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
EventType = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Side = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Price = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false),
OrderType = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Response = table.Column<string>(type: "text", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DetailsJson = table.Column<string>(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" });
}
/// <inheritdoc />
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");
}
}
}
@@ -201,6 +201,133 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
b.ToTable("core_budget", (string)null); b.ToTable("core_budget", (string)null);
}); });
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContextJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Decision")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("DetailsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("Price")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Response")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("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 => modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b =>
{ {
b.Property<string>("Module") b.Property<string>("Module")
@@ -279,6 +406,10 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
b.Property<string>("SignalId")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Status") b.Property<string>("Status")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("varchar(50)"); .HasColumnType("varchar(50)");
@@ -299,6 +430,8 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
b.HasIndex("Module"); b.HasIndex("Module");
b.HasIndex("SignalId");
b.HasIndex("Symbol"); b.HasIndex("Symbol");
b.ToTable("core_trade_history", (string)null); b.ToTable("core_trade_history", (string)null);
@@ -0,0 +1,100 @@
namespace IBKRTrader.Core.Persistence.Entities;
/// <summary>Ausgang einer Handelsentscheidung im Entscheidungsjournal.</summary>
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)
}
/// <summary>
/// Strukturierter Grund einer Entscheidung (statt Freitext). Als STRING persistiert neue Werte können
/// gefahrlos ergänzt werden. Deckt die heutigen Verzweigungen im <c>ExecutionService</c> ab und lässt
/// Raum für künftige Modul-/Risiko-Regeln. Vorbild: PolytraderSharp <c>DecisionReason</c>.
/// </summary>
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
}
/// <summary>Art eines Order-Lifecycle-Ereignisses (als String persistiert erweiterbar).</summary>
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)
}
/// <summary>
/// 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.
/// </summary>
public class CoreDecisionRecord
{
public long Id { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
/// <summary>Korrelation: verbindet Signal → Entscheidung(en) → Order(s) → Trade.</summary>
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; }
/// <summary>Kompakte Kontext-Zahlen als JSON (Limitwerte, Budgets, berechnete Größen …).</summary>
public string ContextJson { get; set; } = "";
/// <summary>Menschlicher Begründungstext (wie bisher im Log).</summary>
public string Message { get; set; } = "";
}
/// <summary>
/// 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).
/// </summary>
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
/// <summary>Broker-Antwort ("OK" oder Fehlertext) bzw. Ergebnis der Aktion.</summary>
public string Response { get; set; } = "";
/// <summary>Zusatzkontext als kompaktes JSON (z. B. Fill-Preis, Teilmenge, Grund).</summary>
public string DetailsJson { get; set; } = "";
}
@@ -21,6 +21,8 @@ public class CoreTrade
public decimal Price { get; set; } public decimal Price { get; set; }
public decimal TotalValue { get; set; } public decimal TotalValue { get; set; }
public DateTime TradedAt { get; set; } public DateTime TradedAt { get; set; }
/// <summary>Korrelation zu Entscheidungsjournal/Order-Events (leer für Trades ohne Signal-Kette).</summary>
public string? SignalId { get; set; }
public string? IbkrOrderId { get; set; } public string? IbkrOrderId { get; set; }
public string? Status { get; set; } public string? Status { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
@@ -0,0 +1,21 @@
using System.Linq.Expressions;
using IBKRTrader.Core.Persistence.Entities;
namespace IBKRTrader.Core.Persistence;
/// <summary>
/// Entscheidungsjournal (core_decision_journal). <see cref="Write"/> darf den Trading-Pfad NIEMALS
/// brechen Implementierungen fangen Persistenzfehler ab (Log statt Exception).
/// </summary>
public interface IDecisionJournal
{
void Write(CoreDecisionRecord record);
List<CoreDecisionRecord> Query(Expression<Func<CoreDecisionRecord, bool>> predicate, int limit = 1000);
}
/// <summary>Order-Lifecycle-Log (core_order_events). Gleiche Robustheits-Garantie wie das Journal.</summary>
public interface IOrderEventLog
{
void Write(CoreOrderEvent record);
List<CoreOrderEvent> Query(Expression<Func<CoreOrderEvent, bool>> predicate, int limit = 1000);
}
@@ -214,6 +214,53 @@ public class TradingSettings
public override string ToString() => $"{Mode} {(TradingEnabled ? "aktiv" : "inaktiv")}"; 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 ──────────────────────────────────────────────────────────────────── // ─── Root ────────────────────────────────────────────────────────────────────
public class AppSettings public class AppSettings
@@ -252,4 +299,14 @@ public class AppSettings
[DisplayName("Trading")] [DisplayName("Trading")]
[Description("Handelsmodus und Risiko-Parameter")] [Description("Handelsmodus und Risiko-Parameter")]
public TradingSettings Trading { get; set; } = new(); 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();
} }
@@ -1,4 +1,7 @@
using System.Text.Json;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence;
using IBKRTrader.Core.Persistence.Entities;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Trading; namespace IBKRTrader.Core.Trading;
@@ -6,6 +9,10 @@ namespace IBKRTrader.Core.Trading;
/// <summary> /// <summary>
/// Führt Modul-Signale aus: globaler Schalter → Kurs → Konto → Risiko → Order → Buchung. /// Führt Modul-Signale aus: globaler Schalter → Kurs → Konto → Risiko → Order → Buchung.
/// Kennt kein Modul Module rufen nur <see cref="ExecuteAsync"/> mit ihrem Signal auf. /// Kennt kein Modul Module rufen nur <see cref="ExecuteAsync"/> 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 <c>SignalId</c> bis in die
/// Trade-Historie durch Grundlage für die Supervisor-Forensik. Journal-Fehler brechen den Handel nie.
/// </summary> /// </summary>
public sealed class ExecutionService : IExecutionService public sealed class ExecutionService : IExecutionService
{ {
@@ -14,34 +21,47 @@ public sealed class ExecutionService : IExecutionService
private readonly IPortfolioService _portfolio; private readonly IPortfolioService _portfolio;
private readonly SettingsService _settings; private readonly SettingsService _settings;
private readonly LoggingService _logger; private readonly LoggingService _logger;
private readonly IDecisionJournal _journal;
private readonly IOrderEventLog _orderEvents;
public ExecutionService( public ExecutionService(
IBrokerClient broker, IBrokerClient broker,
IRiskService risk, IRiskService risk,
IPortfolioService portfolio, IPortfolioService portfolio,
SettingsService settings, SettingsService settings,
LoggingService logger) LoggingService logger,
IDecisionJournal journal,
IOrderEventLog orderEvents)
{ {
_broker = broker; _broker = broker;
_risk = risk; _risk = risk;
_portfolio = portfolio; _portfolio = portfolio;
_settings = settings; _settings = settings;
_logger = logger; _logger = logger;
_journal = journal;
_orderEvents = orderEvents;
} }
public async Task<ExecutionResult> ExecuteAsync(TradeSignal signal, CancellationToken ct = default) public async Task<ExecutionResult> ExecuteAsync(TradeSignal signal, CancellationToken ct = default)
{ {
var trading = _settings.Settings.Trading; var trading = _settings.Settings.Trading;
var module = signal.SourceModule; var module = signal.SourceModule;
var sideStr = signal.Side == TradeSide.Buy ? "BUY" : "SELL";
// 1. Globaler Hauptschalter // 1. Globaler Hauptschalter
if (!trading.TradingEnabled) if (!trading.TradingEnabled)
{
Journal(signal, TradeDecision.Skipped, DecisionReason.TradingDisabled, "Trading global deaktiviert.");
return Log(module, ExecutionResult.Skip("Trading global deaktiviert.")); return Log(module, ExecutionResult.Skip("Trading global deaktiviert."));
}
// 2. Kurs // 2. Kurs
var quote = await _broker.GetQuoteAsync(signal.Symbol, ct); var quote = await _broker.GetQuoteAsync(signal.Symbol, ct);
if (quote is null || quote.Last <= 0) 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.")); return Log(module, ExecutionResult.Skip($"Kein Kurs für {signal.Symbol} verfügbar."));
}
// 3. Konto + 4. bestehende Exposure/Position // 3. Konto + 4. bestehende Exposure/Position
var account = await _broker.GetAccountStateAsync(ct); var account = await _broker.GetAccountStateAsync(ct);
@@ -62,7 +82,11 @@ public sealed class ExecutionService : IExecutionService
(decimal)trading.MaxSlippagePercent); (decimal)trading.MaxSlippagePercent);
var decision = _risk.Evaluate(signal, context, riskParams); var decision = _risk.Evaluate(signal, context, riskParams);
if (!decision.Approved) 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)); return Log(module, ExecutionResult.Skip(decision.Reason));
}
// 6. Order platzieren // 6. Order platzieren
var order = new OrderRequest var order = new OrderRequest
@@ -76,16 +100,62 @@ public sealed class ExecutionService : IExecutionService
var result = await _broker.PlaceOrderAsync(order, ct); var result = await _broker.PlaceOrderAsync(order, ct);
if (!result.Success) 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)); 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 // 7. Buchung
await _portfolio.RecordFillAsync( await _portfolio.RecordFillAsync(
module, signal.Symbol, signal.Side, 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)); 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) private ExecutionResult Log(string module, ExecutionResult result)
{ {
var text = $"[{result.Action}] {result.Reason}"; var text = $"[{result.Action}] {result.Reason}";
@@ -15,7 +15,8 @@ public interface IPortfolioService
/// <summary>Verbucht einen Fill: aktualisiert Position, Budget und Trade-Historie.</summary> /// <summary>Verbucht einen Fill: aktualisiert Position, Budget und Trade-Historie.</summary>
Task RecordFillAsync( Task RecordFillAsync(
string module, string symbol, TradeSide side, 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);
/// <summary>Alle offenen Positionen eines Moduls.</summary> /// <summary>Alle offenen Positionen eines Moduls.</summary>
Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default); Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default);
@@ -54,12 +54,13 @@ public sealed class PortfolioService : IPortfolioService
public async Task RecordFillAsync( public async Task RecordFillAsync(
string module, string symbol, TradeSide side, 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; if (quantity <= 0) return;
var action = side == TradeSide.Buy ? "BUY" : "SELL"; 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)) await using (var db = await _dbf.CreateDbContextAsync(ct))
{ {
@@ -19,7 +19,8 @@ public class TradeHistoryService
public async Task RecordTradeAsync( public async Task RecordTradeAsync(
string module, string symbol, string action, 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(); await using var db = await _dbf.CreateDbContextAsync();
db.TradeHistory.Add(new CoreTrade db.TradeHistory.Add(new CoreTrade
@@ -31,6 +32,7 @@ public class TradeHistoryService
Price = price, Price = price,
TotalValue = quantity * price, TotalValue = quantity * price,
TradedAt = DateTime.UtcNow, TradedAt = DateTime.UtcNow,
SignalId = signalId,
IbkrOrderId = ibkrOrderId, IbkrOrderId = ibkrOrderId,
Status = "Executed", Status = "Executed",
Notes = notes, Notes = notes,
@@ -15,6 +15,12 @@ public enum TradingMode { Paper, Live }
/// </summary> /// </summary>
public sealed record TradeSignal public sealed record TradeSignal
{ {
/// <summary>
/// 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.
/// </summary>
public string SignalId { get; init; } = Guid.NewGuid().ToString("N");
/// <summary>Ticker-Symbol (z. B. "AAPL").</summary> /// <summary>Ticker-Symbol (z. B. "AAPL").</summary>
public required string Symbol { get; init; } public required string Symbol { get; init; }
@@ -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;
/// <summary>
/// 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).
/// </summary>
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<AccountingDbContext>(o => o.UseMySql(conn, DatabaseServerVersion.Value));
services.AddSingleton<ILedgerRepository, EfLedgerRepository>();
services.AddSingleton<IIngestRunRepository, EfIngestRunRepository>();
services.AddSingleton<IRawSnapshotRepository, EfRawSnapshotRepository>();
services.AddSingleton<IFxRateRepository, EfFxRateRepository>();
services.AddSingleton<AccountingReportService>();
// Ingest-Quellen: Offline-Null-Stubs. Im Zielland werden die echten IBKR-Flex-Quellen registriert.
services.AddSingleton<IStatementSource, NullStatementSource>();
services.AddSingleton<IBalanceAnchorSource, NullBalanceAnchorSource>();
services.AddSingleton<IAccountingAccountSource, NullAccountSource>();
services.AddSingleton<AccountingIngestService>();
services.AddHostedService(sp => sp.GetRequiredService<AccountingIngestService>());
}
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<ILedgerRepository>(),
services.GetRequiredService<IIngestRunRepository>(),
services.GetRequiredService<AccountingReportService>(),
services.GetRequiredService<AccountingIngestService>(),
services.GetRequiredService<LoggingService>())
});
}
// 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;
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Modul trägt eigene WinForms-UI (Modul-Fenster) bei. -->
<UseWindowsForms>true</UseWindowsForms>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- PDF-Export: PDFsharp/MigraDoc ist echte MIT-Lizenz ohne Umsatzschwelle (bewusst statt QuestPDF). -->
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IBKRTrader.Core\IBKRTrader.Core.csproj" />
</ItemGroup>
<!-- Erlaubt dem Testprojekt, interne Service-Methoden zu testen. -->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>IBKRTrader.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -0,0 +1,88 @@
using IBKRTrader.Modules.Accounting.Models;
namespace IBKRTrader.Modules.Accounting.Logic;
/// <summary>
/// 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 <see cref="LedgerEntry.NetBase"/>: Cash-Wirkung aufs Konto (+ Zufluss / Abfluss).
/// Annahme (im Export dokumentiert): BUY kostet Brutto+Kommission, SELL bringt BruttoKommission.
/// </summary>
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()}"
};
}
/// <summary>Σ NetBase die Buchhaltungs-Sicht des Kontosaldos (für den Balance-Anker-Abgleich).</summary>
public static decimal SumNet(IEnumerable<LedgerEntry> 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;
}
}
@@ -0,0 +1,89 @@
using IBKRTrader.Modules.Accounting.Models;
namespace IBKRTrader.Modules.Accounting.Logic;
/// <summary>
/// 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
/// <see cref="FxConverter"/>. Vorbild: PolytraderSharp <c>AccountingEngine</c>.
/// </summary>
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)
{
/// <summary>Invariante: Endsaldo Anfangssaldo = Ergebnis + Einzahlungen Auszahlungen.</summary>
public decimal BalanceChange => ClosingBalance - OpeningBalance;
}
public static class AccountingEngine
{
private static bool IsCashflowType(LedgerEventType t) =>
t is LedgerEventType.Deposit or LedgerEventType.Withdrawal;
/// <summary>
/// Baut die Abrechnung für [<paramref name="from"/>, <paramref name="to"/>]. <paramref name="allUpToTo"/>
/// enthält ALLE Ledger-Sätze des Scopes bis <paramref name="to"/> (für den Anfangssaldo werden die
/// Sätze vor <paramref name="from"/> kumuliert). Grenzen inklusive.
/// </summary>
public static PeriodStatement BuildStatement(
IEnumerable<LedgerEntry> allUpToTo, DateTime from, DateTime to, 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<LedgerEntry, bool> pred, Func<LedgerEntry, decimal> 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);
}
/// <summary>
/// Zerlegt den Zeitraum in Kalendermonate und liefert je Monat eine Abrechnung (für den
/// Perioden-/Monatsvergleich). Anfangssaldo jedes Monats = Endsaldo des Vormonats.
/// </summary>
public static List<PeriodStatement> BuildMonthlyBreakdown(
IEnumerable<LedgerEntry> allUpToTo, DateTime from, DateTime to, string? accountId)
{
var list = allUpToTo.ToList();
var result = new List<PeriodStatement>();
var monthStart = new DateTime(from.Year, from.Month, 1, 0, 0, 0, DateTimeKind.Utc);
while (monthStart <= to)
{
DateTime monthEnd = monthStart.AddMonths(1).AddTicks(-1);
DateTime windowFrom = monthStart < from ? from : monthStart;
DateTime windowTo = monthEnd > to ? to : monthEnd;
result.Add(BuildStatement(list, windowFrom, windowTo, accountId));
monthStart = monthStart.AddMonths(1);
}
return result;
}
}
@@ -0,0 +1,72 @@
using System.Globalization;
using System.Text;
using IBKRTrader.Modules.Accounting.Models;
namespace IBKRTrader.Modules.Accounting.Logic;
/// <summary>
/// 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.
/// </summary>
public static class CsvExporter
{
private static string F(decimal d) => d.ToString("0.######", CultureInfo.InvariantCulture);
private static string T(DateTime d) => d.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
private static string Q(string? s)
{
s ??= string.Empty;
bool needsQuote = s.Contains(',') || s.Contains('"') || s.Contains('\n') || s.Contains('\r');
if (needsQuote) s = "\"" + s.Replace("\"", "\"\"") + "\"";
return s;
}
/// <summary>Vollständiger Ledger-Export (eine Zeile je Buchungssatz, inkl. Nachweisspalten).</summary>
public static string Ledger(IEnumerable<LedgerEntry> entries)
{
var sb = new StringBuilder();
sb.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();
}
/// <summary>Aggregat-Export einer Abrechnung (Kennzahl,Wert) prüfbare Zusammenfassung.</summary>
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();
}
}
@@ -0,0 +1,39 @@
using IBKRTrader.Modules.Accounting.Models;
namespace IBKRTrader.Modules.Accounting.Logic;
/// <summary>
/// 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).
/// </summary>
public sealed class FxConverter
{
// aufsteigend nach Datum sortierte Kurse
private readonly List<FxRate> _rates;
public FxConverter(IEnumerable<FxRate> rates)
{
_rates = rates.OrderBy(r => r.Date.Date).ToList();
}
/// <summary>USD→EUR-Kurs, der an oder vor <paramref name="date"/> gültig war (null, wenn keiner existiert).</summary>
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;
}
/// <summary>Rechnet einen USD-Betrag zum Kurs des Datums in EUR um (null, wenn kein Kurs vorliegt).</summary>
public decimal? UsdToEur(decimal usdAmount, DateTime date)
{
var rate = UsdToEurOn(date);
return rate.HasValue ? decimal.Round(usdAmount * rate.Value, 2, MidpointRounding.AwayFromZero) : null;
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public static class PdfExporter
{
public static byte[] Render(
PeriodStatement statement,
IReadOnlyList<PeriodStatement> monthly,
IReadOnlyList<LedgerEntry> 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<LedgerEntry> entries)
{
string material = CsvExporter.Statement(s) + CsvExporter.Ledger(entries);
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(material));
return Convert.ToHexString(hash)[..16].ToLowerInvariant();
}
}
@@ -0,0 +1,236 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("IBKRTrader.Modules.Accounting.Models.FxRate", b =>
{
b.Property<DateTime>("Date")
.HasColumnType("date");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("UsdToEur")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.HasKey("Date");
b.ToTable("acc_fx_rates", (string)null);
});
modelBuilder.Entity("IBKRTrader.Modules.Accounting.Models.IngestRun", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<bool>("Backfill")
.HasColumnType("tinyint(1)");
b.Property<decimal?>("BalanceAnchorBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<decimal?>("BalanceDeltaBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<int>("DuplicateEntries")
.HasColumnType("int");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("FromTimestamp")
.HasColumnType("datetime(6)");
b.Property<decimal?>("LedgerNetBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<int>("NewEntries")
.HasColumnType("int");
b.Property<DateTime>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("Success")
.HasColumnType("tinyint(1)");
b.HasKey("Id");
b.HasIndex("AccountId", "StartedAt");
b.ToTable("acc_ingest_runs", (string)null);
});
modelBuilder.Entity("IBKRTrader.Modules.Accounting.Models.LedgerEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("AssetClass")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("FeeBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<decimal>("GrossBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<long>("IngestBatchId")
.HasColumnType("bigint");
b.Property<DateTime>("IngestedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("NetBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<decimal>("PriceNative")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("Quantity")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("CapturedAt")
.HasColumnType("datetime(6)");
b.Property<long>("IngestRunId")
.HasColumnType("bigint");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("SourceKind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.HasKey("Id");
b.HasIndex("IngestRunId");
b.ToTable("acc_raw", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,163 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IBKRTrader.Modules.Accounting.Migrations
{
/// <inheritdoc />
public partial class InitialAccounting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "acc_fx_rates",
columns: table => new
{
Date = table.Column<DateTime>(type: "date", nullable: false),
UsdToEur = table.Column<decimal>(type: "decimal(18,8)", precision: 18, scale: 8, nullable: false),
Source = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_acc_fx_rates", x => x.Date);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "acc_ingest_runs",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
AccountId = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Backfill = table.Column<bool>(type: "tinyint(1)", nullable: false),
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
FinishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
FromTimestamp = table.Column<DateTime>(type: "datetime(6)", nullable: true),
NewEntries = table.Column<int>(type: "int", nullable: false),
DuplicateEntries = table.Column<int>(type: "int", nullable: false),
Success = table.Column<bool>(type: "tinyint(1)", nullable: false),
Message = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
BalanceAnchorBase = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: true),
LedgerNetBase = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: true),
BalanceDeltaBase = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_acc_ingest_runs", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "acc_ledger",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
AccountId = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
EventType = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Timestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Symbol = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AssetClass = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Currency = table.Column<string>(type: "varchar(5)", maxLength: 5, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Side = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Quantity = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false),
PriceNative = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
GrossBase = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false),
FeeBase = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false),
NetBase = table.Column<decimal>(type: "decimal(28,8)", precision: 28, scale: 8, nullable: false),
TransactionId = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Source = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IngestBatchId = table.Column<long>(type: "bigint", nullable: false),
IngestedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IdempotencyKey = table.Column<string>(type: "varchar(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<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
IngestRunId = table.Column<long>(type: "bigint", nullable: false),
AccountId = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SourceKind = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Json = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CapturedAt = table.Column<DateTime>(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");
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,233 @@
// <auto-generated />
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<DateTime>("Date")
.HasColumnType("date");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<decimal>("UsdToEur")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.HasKey("Date");
b.ToTable("acc_fx_rates", (string)null);
});
modelBuilder.Entity("IBKRTrader.Modules.Accounting.Models.IngestRun", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<bool>("Backfill")
.HasColumnType("tinyint(1)");
b.Property<decimal?>("BalanceAnchorBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<decimal?>("BalanceDeltaBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<int>("DuplicateEntries")
.HasColumnType("int");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("FromTimestamp")
.HasColumnType("datetime(6)");
b.Property<decimal?>("LedgerNetBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<int>("NewEntries")
.HasColumnType("int");
b.Property<DateTime>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("Success")
.HasColumnType("tinyint(1)");
b.HasKey("Id");
b.HasIndex("AccountId", "StartedAt");
b.ToTable("acc_ingest_runs", (string)null);
});
modelBuilder.Entity("IBKRTrader.Modules.Accounting.Models.LedgerEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<string>("AssetClass")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("varchar(5)");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<decimal>("FeeBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<decimal>("GrossBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<long>("IngestBatchId")
.HasColumnType("bigint");
b.Property<DateTime>("IngestedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("NetBase")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<decimal>("PriceNative")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("Quantity")
.HasPrecision(28, 8)
.HasColumnType("decimal(28,8)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AccountId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("CapturedAt")
.HasColumnType("datetime(6)");
b.Property<long>("IngestRunId")
.HasColumnType("bigint");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("SourceKind")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.HasKey("Id");
b.HasIndex("IngestRunId");
b.ToTable("acc_raw", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,13 @@
namespace IBKRTrader.Modules.Accounting.Models;
/// <summary>
/// Amtlicher FX-Tageskurs zur EUR-Ansicht (Tabelle acc_fx_rates). <see cref="UsdToEur"/> = wie viele
/// EUR ein USD am <see cref="Date"/> wert war (EZB-Referenzkurs, Zielland-Ingest). Basiswährung ist
/// USD; die EUR-Umrechnung liegt im <see cref="Logic.FxConverter"/> (Nearest-on-or-before).
/// </summary>
public class FxRate
{
public DateTime Date { get; set; } // nur Datum (Tag)
public decimal UsdToEur { get; set; }
public string Source { get; set; } = ""; // z. B. "ECB"
}
@@ -0,0 +1,28 @@
namespace IBKRTrader.Modules.Accounting.Models;
/// <summary>
/// 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).
/// </summary>
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; } = "";
/// <summary>Vom Broker gemeldeter Kontosaldo (Basiswährung), falls verfügbar.</summary>
public decimal? BalanceAnchorBase { get; set; }
/// <summary>Σ NetBase des Ledgers (Buchhaltungs-Saldo).</summary>
public decimal? LedgerNetBase { get; set; }
/// <summary>Anker Ledger (≈ 0 = vollständig).</summary>
public decimal? BalanceDeltaBase { get; set; }
}
@@ -0,0 +1,56 @@
namespace IBKRTrader.Modules.Accounting.Models;
/// <summary>
/// Buchungssatz-Typ im neutralen Ledger (als String persistiert erweiterbar). Deckt die
/// IBKR-Aktienwelt ab: Trades, Dividenden, Zinsen, Gebühren, Quellensteuer und Ein-/Auszahlungen.
/// </summary>
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
}
/// <summary>
/// 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 <see cref="TransactionId"/> und den <see cref="IdempotencyKey"/> auf einen
/// prüfbaren Nachweis zurück; überlappende Abrufe buchen dank des Unique-Keys nicht doppelt.
///
/// Geldbeträge liegen in der <b>Kontobasiswährung</b> (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 <see cref="NetBase"/>: Cash-Wirkung aufs Konto (+ Zufluss / Abfluss).
/// </summary>
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;
/// <summary>Stabiler Idempotenz-Schlüssel (unique). Gleiches Ereignis ⇒ gleicher Schlüssel.</summary>
public string IdempotencyKey { get; set; } = "";
}
@@ -0,0 +1,43 @@
namespace IBKRTrader.Modules.Accounting.Models;
/// <summary>
/// Roh-Ausführung aus dem IBKR-Kontoauszug (Activity Flex Query, &lt;Trade&gt;). 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
/// <see cref="Logic.AccountingClassifier"/> übernimmt daraus Typ, Vorzeichen und Idempotenz-Key.
/// </summary>
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)
/// <summary>Rohzeile (JSON/XML) für den Nachweis-Snapshot.</summary>
public string RawJson { get; init; } = "";
}
/// <summary>
/// Roh-Kassenbewegung aus dem IBKR-Kontoauszug (&lt;CashTransaction&gt;): Dividenden, Quellensteuer,
/// Zinsen, Ein-/Auszahlungen, Gebühren. <see cref="AmountBase"/> ist der signierte Betrag in
/// Kontobasiswährung, wie im Auszug ausgewiesen (+ Zufluss / Abfluss).
/// </summary>
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; } = "";
}
@@ -0,0 +1,15 @@
namespace IBKRTrader.Modules.Accounting.Models;
/// <summary>
/// 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.
/// </summary>
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;
}
@@ -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;
/// <summary>
/// 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).
/// </summary>
public class AccountingDbContext : DbContext
{
public AccountingDbContext(DbContextOptions<AccountingDbContext> options) : base(options) { }
public DbSet<LedgerEntry> Ledger => Set<LedgerEntry>();
public DbSet<IngestRun> IngestRuns => Set<IngestRun>();
public DbSet<RawSnapshot> RawSnapshots => Set<RawSnapshot>();
public DbSet<FxRate> FxRates => Set<FxRate>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<LedgerEntry>(e =>
{
e.ToTable("acc_ledger");
e.HasKey(x => x.Id);
e.Property(x => x.Id).ValueGeneratedOnAdd();
e.Property(x => x.AccountId).HasMaxLength(30);
e.Property(x => x.EventType).HasConversion<string>().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<IngestRun>(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<RawSnapshot>(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<FxRate>(e =>
{
e.ToTable("acc_fx_rates");
e.HasKey(x => x.Date);
e.Property(x => x.Date).HasColumnType("date");
e.Property(x => x.UsdToEur).HasPrecision(18, 8);
e.Property(x => x.Source).HasMaxLength(40);
});
}
}
/// <summary>Design-Time-Factory für EF-Tooling (dotnet ef). Connection aus env IBKRTRADER_MYSQL.</summary>
public class AccountingDbContextFactory : IDesignTimeDbContextFactory<AccountingDbContext>
{
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<AccountingDbContext>()
.UseMySql(conn, DatabaseServerVersion.Value)
.Options;
return new AccountingDbContext(options);
}
}
@@ -0,0 +1,163 @@
using IBKRTrader.Modules.Accounting.Models;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Modules.Accounting.Persistence;
/// <summary>Append-only Ledger-Zugriff mit idempotentem Upsert (Doppel-Buchen ausgeschlossen).</summary>
public interface ILedgerRepository
{
/// <summary>Fügt den Satz ein, falls sein IdempotencyKey neu ist. true = neu gebucht, false = Duplikat.</summary>
bool Upsert(LedgerEntry entry);
DateTime? LatestTimestamp(string accountId);
decimal SumNet(string accountId);
int Count(string accountId);
List<string> DistinctAccounts();
List<LedgerEntry> Query(string? accountId, DateTime? from, DateTime? to, int limit);
/// <summary>ALLE Sätze des Scopes bis <paramref name="to"/> (für die Abrechnung inkl. Anfangssaldo).</summary>
List<LedgerEntry> GetUpTo(string? accountId, DateTime to);
}
public interface IIngestRunRepository
{
void Insert(IngestRun run); // setzt Id
void Update(IngestRun run);
List<IngestRun> GetRecent(string? accountId, int limit);
}
public interface IRawSnapshotRepository
{
void Insert(RawSnapshot snapshot);
}
/// <summary>Amtliche FX-Tageskurse (USD→EUR), versioniert. Upsert je Datum.</summary>
public interface IFxRateRepository
{
void Upsert(FxRate rate);
List<FxRate> GetAll();
}
// ---------------- EF-Implementierungen ----------------
public sealed class EfLedgerRepository : ILedgerRepository
{
private readonly IDbContextFactory<AccountingDbContext> _dbf;
public EfLedgerRepository(IDbContextFactory<AccountingDbContext> 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<string> DistinctAccounts()
{
using var db = _dbf.CreateDbContext();
return db.Ledger.AsNoTracking().Select(x => x.AccountId).Distinct().OrderBy(x => x).ToList();
}
public List<LedgerEntry> 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<LedgerEntry> 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<AccountingDbContext> _dbf;
public EfIngestRunRepository(IDbContextFactory<AccountingDbContext> 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<IngestRun> 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<AccountingDbContext> _dbf;
public EfRawSnapshotRepository(IDbContextFactory<AccountingDbContext> 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<AccountingDbContext> _dbf;
public EfFxRateRepository(IDbContextFactory<AccountingDbContext> 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<FxRate> GetAll()
{
using var db = _dbf.CreateDbContext();
return db.FxRates.AsNoTracking().OrderBy(x => x.Date).ToList();
}
}
@@ -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;
/// <summary>
/// Ingest-Orchestrierung: erhebt je Konto die unabhängige Buchungsgrundlage aus dem IBKR-Kontoauszug
/// (Flex Query), klassifiziert sie pur (<see cref="AccountingClassifier"/>) und bucht sie idempotent in
/// den append-only Ledger. Protokolliert jeden Lauf (acc_ingest_runs) inkl. Balance-Anker (Soll-Ist).
/// Rein LESEND keine Orders. Der Abruf liegt hinter Interfaces; mit den Null-Quellen läuft das Modul
/// offline (bucht korrekt nichts). Testbarer Kern: <see cref="IngestAccountAsync"/>.
/// </summary>
public sealed class AccountingIngestService : BackgroundService
{
/// <summary>Sicherheits-Überlappung gegen Auszugs-Lag beim inkrementellen Abruf.</summary>
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; }
}
}
/// <summary>Ein Durchlauf über alle bekannten Konten (offline: keine → nichts zu tun).</summary>
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})" : ""));
}
}
/// <summary>
/// Testbarer Kern: erhebt + bucht einen Account, protokolliert den Lauf inkl. Balance-Anker.
/// Fehler brechen den Gesamt-Ingest nicht (im Run vermerkt).
/// </summary>
public async Task<IngestRun> IngestAccountAsync(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<string> rawItems)
{
var items = rawItems.Where(s => !string.IsNullOrEmpty(s)).ToList();
return items.Count == 0 ? "[]" : "[" + string.Join(",", items) + "]";
}
}
@@ -0,0 +1,45 @@
using IBKRTrader.Modules.Accounting.Logic;
using IBKRTrader.Modules.Accounting.Persistence;
namespace IBKRTrader.Modules.Accounting.Services;
/// <summary>Anzeige-Währung einer Abrechnung: Umrechnungsfaktor (aus Basiswährung) + dokumentierter Hinweis.</summary>
public sealed record CurrencyView(string Code, decimal Factor, string Note);
/// <summary>
/// Baut die neutrale Periodenabrechnung + Monatsvergleich (via <see cref="AccountingEngine"/>) 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 <see cref="FxConverter"/> auf Transaktionsebene).
/// </summary>
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<PeriodStatement> BuildMonthly(string? accountId, DateTime from, DateTime to) =>
AccountingEngine.BuildMonthlyBreakdown(_ledger.GetUpTo(accountId, to), from, to, accountId);
/// <summary>Währungs-View für die übergebene Anzeige-Währung (USD/EUR), bezogen auf das Periodenende.</summary>
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");
}
}
@@ -0,0 +1,53 @@
using IBKRTrader.Modules.Accounting.Models;
namespace IBKRTrader.Modules.Accounting.Services;
/// <summary>
/// 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 <see cref="RawExecution"/>/<see cref="RawCashTransaction"/>
/// und speichert den Rohschnappschuss. Der Flex-Abruf braucht KEINE laufende TWS-Socket-Verbindung.
/// </summary>
public interface IStatementSource
{
/// <summary>Ausführungen ab <paramref name="since"/> (null = volle Historie/Backfill).</summary>
Task<IReadOnlyList<RawExecution>> GetExecutionsAsync(string accountId, DateTime? since, CancellationToken ct);
/// <summary>Kassenbewegungen (Dividenden, Steuer, Zinsen, Ein-/Auszahlungen) ab <paramref name="since"/>.</summary>
Task<IReadOnlyList<RawCashTransaction>> GetCashTransactionsAsync(string accountId, DateTime? since, CancellationToken ct);
}
/// <summary>Kontosaldo (Basiswährung) als Balance-Anker (Soll-Ist). Live-Impl über Flex/TWS (Zielland).</summary>
public interface IBalanceAnchorSource
{
Task<decimal?> GetBalanceAsync(string accountId, CancellationToken ct);
}
/// <summary>Liefert die zu erfassenden IBKR-Kontocodes. Offline leer → der Ingest bucht nichts.</summary>
public interface IAccountingAccountSource
{
Task<IReadOnlyList<string>> GetAccountIdsAsync(CancellationToken ct);
}
// ---------------- Offline-Null-Stubs (Muster wie NullBrokerClient) ----------------
/// <summary>Das Modul läuft ohne Live-Anbindung vollständig; der Ingest bucht dann korrekt nichts.</summary>
public sealed class NullStatementSource : IStatementSource
{
public Task<IReadOnlyList<RawExecution>> GetExecutionsAsync(string accountId, DateTime? since, CancellationToken ct)
=> Task.FromResult((IReadOnlyList<RawExecution>)Array.Empty<RawExecution>());
public Task<IReadOnlyList<RawCashTransaction>> GetCashTransactionsAsync(string accountId, DateTime? since, CancellationToken ct)
=> Task.FromResult((IReadOnlyList<RawCashTransaction>)Array.Empty<RawCashTransaction>());
}
public sealed class NullBalanceAnchorSource : IBalanceAnchorSource
{
public Task<decimal?> GetBalanceAsync(string accountId, CancellationToken ct) => Task.FromResult((decimal?)null);
}
public sealed class NullAccountSource : IAccountingAccountSource
{
public Task<IReadOnlyList<string>> GetAccountIdsAsync(CancellationToken ct)
=> Task.FromResult((IReadOnlyList<string>)Array.Empty<string>());
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
@@ -0,0 +1,53 @@
namespace IBKRTrader.Modules.Supervisor.Agent;
/// <summary>
/// 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).
/// </summary>
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.
""";
}
@@ -0,0 +1,33 @@
namespace IBKRTrader.Modules.Supervisor.Agent;
/// <summary>Chat-Nachricht im OpenAI-/OpenRouter-Schema (Rollen: system/user/assistant/tool).</summary>
public sealed class ChatMessage
{
public string Role { get; init; } = "user";
public string? Content { get; init; }
/// <summary>Vom Modell angeforderte Tool-Aufrufe (nur Rolle assistant).</summary>
public List<ToolCall>? ToolCalls { get; init; }
/// <summary>Bezug auf den beantworteten Tool-Aufruf (nur Rolle tool).</summary>
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<ToolCall>? toolCalls = null) =>
new() { Role = "assistant", Content = content, ToolCalls = toolCalls };
public static ChatMessage ToolResult(string toolCallId, string content) =>
new() { Role = "tool", ToolCallId = toolCallId, Content = content };
}
/// <summary>Ein Tool-Aufruf des Modells (Function-Calling).</summary>
public sealed record ToolCall(string Id, string Name, string ArgumentsJson);
/// <summary>Antwort des Modells: Text ODER Tool-Aufrufe (oder beides).</summary>
public sealed class ChatResponse
{
public string? Content { get; init; }
public List<ToolCall> ToolCalls { get; init; } = new();
public int PromptTokens { get; init; }
public int CompletionTokens { get; init; }
}
@@ -0,0 +1,161 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
namespace IBKRTrader.Modules.Supervisor.Agent;
/// <summary>Chat-Completion-Client (Function-Calling). Interface, damit der Agent testbar ist.</summary>
public interface IChatCompletionClient
{
Task<ChatResponse> CompleteAsync(string model, IReadOnlyList<ChatMessage> messages,
IReadOnlyList<SupervisorTool> tools, CancellationToken ct);
}
/// <summary>
/// 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.
/// </summary>
public sealed class OpenRouterClient : IChatCompletionClient
{
public const string Endpoint = "https://openrouter.ai/api/v1/chat/completions";
private readonly HttpClient _http;
private readonly Func<string?> _apiKeyProvider;
public OpenRouterClient(HttpClient http, Func<string?>? apiKeyProvider = null)
{
_http = http;
_apiKeyProvider = apiKeyProvider ?? DefaultApiKeyProvider;
}
/// <summary>Key aus env IBKRTRADER_OPENROUTER_KEY, sonst aus gitignorierter openrouter.key.</summary>
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<ChatResponse> CompleteAsync(string model, IReadOnlyList<ChatMessage> messages,
IReadOnlyList<SupervisorTool> 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<ChatMessage> messages, IReadOnlyList<SupervisorTool> 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<ToolCall>();
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] + "…";
}
@@ -0,0 +1,106 @@
namespace IBKRTrader.Modules.Supervisor.Agent;
/// <summary>Ergebnis einer Agenten-Anfrage inkl. transparenter Tool-Aufruf-Historie.</summary>
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; }
}
/// <summary>
/// Der Analyse-Agent: Function-Calling-Loop gegen einen <see cref="IChatCompletionClient"/> mit der
/// read-only <see cref="SupervisorToolRegistry"/>. 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.
/// </summary>
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<SupervisorTool> 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();
/// <summary>Beantwortet eine Analyse-Frage. <paramref name="progress"/> meldet Tool-Aufrufe live an die UI.</summary>
public async Task<AgentResult> AskAsync(string question, string? model = null,
IProgress<string>? 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>
{
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
};
}
}
@@ -0,0 +1,41 @@
namespace IBKRTrader.Modules.Supervisor.Agent;
/// <summary>
/// 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.
/// </summary>
public sealed record SupervisorProfile(string Name, string PromptAddendum, string[]? ToolFilter)
{
public override string ToString() => Name;
}
/// <summary>Die eingebauten Profile.</summary>
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<SupervisorProfile> All { get; } =
new[] { Allgemein, Technik, CongressTrading };
public static SupervisorProfile ByName(string? name) =>
All.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) ?? Allgemein;
}
@@ -0,0 +1,58 @@
using System.Text.Json;
namespace IBKRTrader.Modules.Supervisor.Agent;
/// <summary>
/// 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.
/// </summary>
public sealed record SupervisorTool(
string Name,
string Description,
string ParametersJsonSchema,
Func<JsonElement, string> Execute);
/// <summary>
/// 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.
/// </summary>
public sealed class SupervisorToolRegistry
{
private readonly Dictionary<string, SupervisorTool> _tools = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<SupervisorTool> 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;
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<LogJson.ParsedLogLine>();
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;
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public interface ICounterfactualResolutionSource
{
Task<decimal?> GetLaterPriceAsync(string symbol, DateTime afterUtc, CancellationToken ct);
}
/// <summary>Offline-Stub: kein späterer Kurs → der Job wertet nichts aus (bleibt korrekt leer).</summary>
public sealed class NullCounterfactualResolutionSource : ICounterfactualResolutionSource
{
public Task<decimal?> GetLaterPriceAsync(string symbol, DateTime afterUtc, CancellationToken ct)
=> Task.FromResult((decimal?)null);
}
/// <summary>
/// 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).
/// </summary>
public sealed class CounterfactualJob : BackgroundService
{
/// <summary>Wartezeit, bevor ein Reject ausgewertet wird (Marktbewegung „danach").</summary>
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; }
}
}
/// <summary>Testbarer Kern: bewertet fällige, noch nicht ausgewertete BUY-Rejects.</summary>
public async Task<int> 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;
}
}
@@ -0,0 +1,24 @@
namespace IBKRTrader.Modules.Supervisor.Counterfactual;
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>LaterPrice SignalPrice (positiv = die Ablehnung hat Gewinn verhindert).</summary>
public decimal HypotheticalPnlPerShare { get; set; }
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Modul trägt eigene WinForms-UI (Modul-Fenster) bei. -->
<UseWindowsForms>true</UseWindowsForms>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IBKRTrader.Core\IBKRTrader.Core.csproj" />
</ItemGroup>
<!-- Erlaubt dem Testprojekt, interne Service-Methoden zu testen. -->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>IBKRTrader.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -0,0 +1,144 @@
using System.Text;
using System.Text.Json;
using IBKRTrader.Modules.Supervisor.Agent;
namespace IBKRTrader.Modules.Supervisor.Mcp;
/// <summary>
/// MCP-Light: purer JSON-RPC-2.0-Handler für das Model Context Protocol über die read-only
/// <see cref="SupervisorToolRegistry"/>. 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.
/// </summary>
public static class McpJsonRpc
{
public const string ProtocolVersion = "2025-03-26";
public const string ServerName = "ibkrtrader-supervisor";
public const string ServerVersion = "1.0";
/// <summary>
/// Verarbeitet eine JSON-RPC-Nachricht. Liefert die Antwort als JSON-String — oder null für
/// Notifications (kein id) und unparsbare Eingaben ohne id.
/// </summary>
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<Utf8JsonWriter> 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());
}
}
@@ -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;
/// <summary>
/// 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
/// <c>claude mcp add --transport http ibkrtrader http://127.0.0.1:PORT/mcp</c>.
///
/// 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.
/// </summary>
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 { }
}
}
}
@@ -0,0 +1,143 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("IBKRTrader.Modules.Supervisor.Counterfactual.CounterfactualRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CheckedAt")
.HasColumnType("datetime(6)");
b.Property<long>("DecisionId")
.HasColumnType("bigint");
b.Property<decimal>("HypotheticalPnlPerShare")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("LaterPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Answer")
.IsRequired()
.HasColumnType("text");
b.Property<int>("CompletionTokens")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Profile")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<int>("PromptTokens")
.HasColumnType("int");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("varchar(4000)");
b.Property<int>("ToolCallCount")
.HasColumnType("int");
b.Property<string>("ToolCallsJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("sup_reports", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,105 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace IBKRTrader.Modules.Supervisor.Migrations
{
/// <inheritdoc />
public partial class InitialSupervisor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "sup_counterfactuals",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
DecisionId = table.Column<long>(type: "bigint", nullable: false),
CheckedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
SignalId = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Module = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Symbol = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Side = table.Column<string>(type: "varchar(10)", maxLength: 10, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SignalPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
LaterPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
HypotheticalPnlPerShare = table.Column<decimal>(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<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Profile = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Model = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Question = table.Column<string>(type: "varchar(4000)", maxLength: 4000, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Answer = table.Column<string>(type: "text", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ToolCallsJson = table.Column<string>(type: "text", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ToolCallCount = table.Column<int>(type: "int", nullable: false),
PromptTokens = table.Column<int>(type: "int", nullable: false),
CompletionTokens = table.Column<int>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sup_counterfactuals");
migrationBuilder.DropTable(
name: "sup_reports");
}
}
}
@@ -0,0 +1,140 @@
// <auto-generated />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CheckedAt")
.HasColumnType("datetime(6)");
b.Property<long>("DecisionId")
.HasColumnType("bigint");
b.Property<decimal>("HypotheticalPnlPerShare")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("LaterPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("SignalId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("SignalPrice")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Answer")
.IsRequired()
.HasColumnType("text");
b.Property<int>("CompletionTokens")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Profile")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<int>("PromptTokens")
.HasColumnType("int");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("varchar(4000)");
b.Property<int>("ToolCallCount")
.HasColumnType("int");
b.Property<string>("ToolCallsJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("sup_reports", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -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;
/// <summary>EF-Kontext des Supervisor-Moduls (gleiche MariaDB, Tabellen mit Präfix sup_).</summary>
public class SupervisorDbContext : DbContext
{
public SupervisorDbContext(DbContextOptions<SupervisorDbContext> options) : base(options) { }
public DbSet<SupervisorReport> Reports => Set<SupervisorReport>();
public DbSet<CounterfactualRecord> Counterfactuals => Set<CounterfactualRecord>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<SupervisorReport>(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<CounterfactualRecord>(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);
});
}
}
/// <summary>Design-Time-Factory für EF-Tooling (dotnet ef). Connection aus env IBKRTRADER_MYSQL.</summary>
public class SupervisorDbContextFactory : IDesignTimeDbContextFactory<SupervisorDbContext>
{
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<SupervisorDbContext>()
.UseMySql(conn, DatabaseServerVersion.Value)
.Options;
return new SupervisorDbContext(options);
}
}
/// <summary>Bericht-Ablage. Write fehlertolerant (Analyse darf nie an der Persistenz scheitern).</summary>
public interface ISupervisorReportRepository
{
void Insert(SupervisorReport report);
List<SupervisorReport> GetRecent(int limit);
}
/// <summary>Counterfactual-Ablage. Write fehlertolerant.</summary>
public interface ISupervisorCounterfactualRepository
{
HashSet<long> ExistingDecisionIds(IEnumerable<long> decisionIds);
void Insert(CounterfactualRecord record);
List<CounterfactualRecord> GetRecent(int limit);
}
public sealed class EfSupervisorReportRepository : ISupervisorReportRepository
{
private readonly IDbContextFactory<SupervisorDbContext> _dbf;
private readonly LoggingService _logger;
public EfSupervisorReportRepository(IDbContextFactory<SupervisorDbContext> 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<SupervisorReport> 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<SupervisorDbContext> _dbf;
private readonly LoggingService _logger;
public EfSupervisorCounterfactualRepository(IDbContextFactory<SupervisorDbContext> dbf, LoggingService logger)
{
_dbf = dbf;
_logger = logger;
}
public HashSet<long> ExistingDecisionIds(IEnumerable<long> 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<CounterfactualRecord> GetRecent(int limit)
{
using var db = _dbf.CreateDbContext();
return db.Counterfactuals.AsNoTracking().OrderByDescending(c => c.CheckedAt).Take(limit).ToList();
}
}
@@ -0,0 +1,23 @@
namespace IBKRTrader.Modules.Supervisor.Persistence;
/// <summary>
/// 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.
/// </summary>
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; } = "";
/// <summary>Tool-Aufrufe als JSON [{tool,args}] (Ergebnisse sind reproduzierbar, daher nicht gespeichert).</summary>
public string ToolCallsJson { get; set; } = "";
public int ToolCallCount { get; set; }
public int PromptTokens { get; set; }
public int CompletionTokens { get; set; }
}
@@ -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;
/// <summary>
/// Täglicher Supervisor-Bericht (OPT-IN via env IBKRTRADER_SUPERVISOR_DAILY = Stunde 023). 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.
/// </summary>
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.");
}
}
@@ -0,0 +1,89 @@
using IBKRTrader.Core.Analytics;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence;
namespace IBKRTrader.Modules.Supervisor.Services;
/// <summary>Kopfzeile eines Signals für die Übersichtsliste des Dossier-Browsers.</summary>
public sealed record SignalSummary(
string SignalId, DateTime FirstSeen, string Module, string Symbol,
string Side, string LastDecision, string LastReason, int DecisionCount);
/// <summary>
/// Beschafft die Daten für Trade-Dossiers: Entscheidungsjournal + Order-Events + Trade-Log +
/// JSONL-Log-Zeilen (per CorrelationId), Zusammenbau/Rendering pur im <see cref="DossierBuilder"/>
/// (Core). Read-only — der Supervisor ist Beobachter.
/// </summary>
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");
}
/// <summary>Jüngste Signale (gruppiert über das Entscheidungsjournal), neueste zuerst.</summary>
public List<SignalSummary> 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();
}
/// <summary>Baut das komplette Dossier zu einer SignalId (inkl. Log-Zeilen aus den JSONL-Tagesdateien).</summary>
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);
}
/// <summary>
/// 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).
/// </summary>
private List<LogJson.ParsedLogLine> ReadLogLines(string signalId, IEnumerable<DateTime> eventTimes)
{
var result = new List<LogJson.ParsedLogLine>();
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;
}
}
@@ -0,0 +1,42 @@
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Modules.Supervisor.Services;
/// <summary>
/// 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.
/// </summary>
public sealed class TradeLogReader
{
private readonly IDbContextFactory<CoreDbContext> _dbf;
public TradeLogReader(IDbContextFactory<CoreDbContext> dbf) => _dbf = dbf;
public List<CoreTrade> 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();
}
/// <summary>Alle Fills eines Moduls/Zeitraums (für die realisierte KPI-Berechnung, chronologisch).</summary>
public List<CoreTrade> 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<CoreTrade> BySignal(string signalId)
{
using var db = _dbf.CreateDbContext();
return db.TradeHistory.AsNoTracking()
.Where(t => t.SignalId == signalId)
.OrderBy(t => t.TradedAt)
.ToList();
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<SupervisorDbContext>(o => o.UseMySql(conn, DatabaseServerVersion.Value));
services.AddSingleton<ISupervisorReportRepository, EfSupervisorReportRepository>();
services.AddSingleton<ISupervisorCounterfactualRepository, EfSupervisorCounterfactualRepository>();
// Dossier-Beschaffung + Trade-Log-Reader (read-only auf Core-Daten).
services.AddSingleton<TradeLogReader>();
services.AddSingleton<DossierService>();
// read-only Tool-Registry + OpenRouter-Agent (Key getrennt vom Trading, siehe Konzept §5).
services.AddSingleton(sp => SupervisorTools.CreateRegistry(
sp.GetRequiredService<IDecisionJournal>(),
sp.GetRequiredService<IOrderEventLog>(),
sp.GetRequiredService<TradeLogReader>(),
sp.GetRequiredService<DossierService>(),
sp.GetRequiredService<ISupervisorCounterfactualRepository>()));
services.AddSingleton<IChatCompletionClient>(_ =>
new OpenRouterClient(new HttpClient { Timeout = TimeSpan.FromMinutes(3) }));
services.AddSingleton<SupervisorAgent>();
// Counterfactual-Auswertung (abgelehnte BUYs vs. späterer Kurs) Live-Quelle als Null-Stub.
services.AddSingleton<ICounterfactualResolutionSource, NullCounterfactualResolutionSource>();
services.AddHostedService<CounterfactualJob>();
// Tagesbericht (opt-in via IBKRTRADER_SUPERVISOR_DAILY) + MCP-Light (opt-in via IBKRTRADER_MCP_PORT).
services.AddHostedService<DailyReportService>();
services.AddHostedService<Mcp.McpLightServer>();
}
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<SupervisorAgent>(),
services.GetRequiredService<DossierService>(),
services.GetRequiredService<ISupervisorReportRepository>(),
services.GetRequiredService<LoggingService>())
});
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -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;
/// <summary>
/// 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).
/// </summary>
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 023.\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<string>(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}"); }
}
}
@@ -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<CoreOrderEvent>(), Array.Empty<CoreTrade>(), Array.Empty<LogJson.ParsedLogLine>());
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<CoreOrderEvent>(), Array.Empty<CoreTrade>(), Array.Empty<LogJson.ParsedLogLine>());
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");
}
}
@@ -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");
}
}
@@ -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<CoreTrade>());
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");
}
}
@@ -24,6 +24,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\IBKRTrader.Core\IBKRTrader.Core.csproj" /> <ProjectReference Include="..\..\src\IBKRTrader.Core\IBKRTrader.Core.csproj" />
<ProjectReference Include="..\..\src\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" /> <ProjectReference Include="..\..\src\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
<ProjectReference Include="..\..\src\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
<ProjectReference Include="..\..\src\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -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();
}
}
@@ -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");
}
}
@@ -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
}
}
@@ -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;
/// <summary>Ingest-Kern gegen EF-InMemory: Idempotenz (Doppel-Ingest bucht nicht doppelt) + Balance-Anker.</summary>
[Trait("cat", "unit")]
public class AccountingIngestServiceTests
{
private sealed class Factory(DbContextOptions<AccountingDbContext> options) : IDbContextFactory<AccountingDbContext>
{
public AccountingDbContext CreateDbContext() => new(options);
}
private sealed class FakeStatement : IStatementSource
{
public IReadOnlyList<RawExecution> Executions = Array.Empty<RawExecution>();
public IReadOnlyList<RawCashTransaction> Cash = Array.Empty<RawCashTransaction>();
public Task<IReadOnlyList<RawExecution>> GetExecutionsAsync(string a, DateTime? s, CancellationToken ct) => Task.FromResult(Executions);
public Task<IReadOnlyList<RawCashTransaction>> GetCashTransactionsAsync(string a, DateTime? s, CancellationToken ct) => Task.FromResult(Cash);
}
private sealed class FakeBalance(decimal? v) : IBalanceAnchorSource
{
public Task<decimal?> GetBalanceAsync(string a, CancellationToken ct) => Task.FromResult(v);
}
private static (AccountingIngestService svc, ILedgerRepository ledger) Build(FakeStatement stmt, decimal? anchor = null)
{
var opts = new DbContextOptionsBuilder<AccountingDbContext>()
.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();
}
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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");
}
}
@@ -0,0 +1,75 @@
using FluentAssertions;
using IBKRTrader.Modules.Supervisor.Agent;
namespace IBKRTrader.Tests.Modules.Supervisor;
[Trait("cat", "unit")]
public class SupervisorAgentTests
{
/// <summary>Fake-Client: gibt vorab definierte Antworten der Reihe nach zurück.</summary>
private sealed class FakeChat : IChatCompletionClient
{
private readonly Queue<ChatResponse> _responses;
public List<string> SeenToolResults { get; } = new();
public FakeChat(params ChatResponse[] responses) => _responses = new(responses);
public Task<ChatResponse> CompleteAsync(string model, IReadOnlyList<ChatMessage> messages,
IReadOnlyList<SupervisorTool> 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");
}
}
@@ -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");
}
}
@@ -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;
/// <summary>Entscheidungsjournal + Order-Event-Log gegen EF-InMemory, inkl. Robustheits-Garantie.</summary>
[Trait("cat", "unit")]
public class AnalysisJournalsTests
{
private sealed class Factory<T>(DbContextOptions<T> options) : IDbContextFactory<T> where T : DbContext
{
public T CreateDbContext() => (T)Activator.CreateInstance(typeof(T), options)!;
}
/// <summary>Factory, die immer wirft simuliert einen DB-Ausfall.</summary>
private sealed class ThrowingFactory : IDbContextFactory<CoreDbContext>
{
public CoreDbContext CreateDbContext() => throw new InvalidOperationException("DB weg");
}
private static Factory<CoreDbContext> InMemory() =>
new(new DbContextOptionsBuilder<CoreDbContext>()
.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();
}
}
@@ -1,5 +1,6 @@
using FluentAssertions; using FluentAssertions;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading; using IBKRTrader.Core.Trading;
using NSubstitute; using NSubstitute;
@@ -13,9 +14,11 @@ public class ExecutionServiceTests
private readonly IRiskService _risk = Substitute.For<IRiskService>(); private readonly IRiskService _risk = Substitute.For<IRiskService>();
private readonly IPortfolioService _portfolio = Substitute.For<IPortfolioService>(); private readonly IPortfolioService _portfolio = Substitute.For<IPortfolioService>();
private readonly SettingsService _settings = new(); private readonly SettingsService _settings = new();
private readonly IDecisionJournal _journal = Substitute.For<IDecisionJournal>();
private readonly IOrderEventLog _orderLog = Substitute.For<IOrderEventLog>();
private ExecutionService CreateSut() => 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() private static readonly TradeSignal BuySignal = new()
{ {
@@ -92,7 +95,7 @@ public class ExecutionServiceTests
result.Executed.Should().BeTrue(); result.Executed.Should().BeTrue();
result.Order!.OrderId.Should().Be("O1"); result.Order!.OrderId.Should().Be("O1");
await _portfolio.Received(1).RecordFillAsync( await _portfolio.Received(1).RecordFillAsync(
"CT", "AAPL", TradeSide.Buy, 5, 100m, "O1", Arg.Any<CancellationToken>()); "CT", "AAPL", TradeSide.Buy, 5, 100m, "O1", Arg.Any<string?>(), Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
@@ -107,6 +110,34 @@ public class ExecutionServiceTests
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
} }
[Fact]
public async Task TradingDisabled_WritesSkippedDecision()
{
await CreateSut().ExecuteAsync(BuySignal);
_journal.Received().Write(Arg.Is<IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord>(
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<CancellationToken>());
_journal.Received().Write(Arg.Is<IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord>(
d => d.SignalId == "sig-abc" &&
d.Decision == IBKRTrader.Core.Persistence.Entities.TradeDecision.Executed));
_orderLog.Received().Write(Arg.Is<IBKRTrader.Core.Persistence.Entities.CoreOrderEvent>(
e => e.SignalId == "sig-abc" &&
e.EventType == IBKRTrader.Core.Persistence.Entities.OrderEventType.Filled));
}
[Fact] [Fact]
public async Task OrderFails_ReturnsError_AndDoesNotBook() public async Task OrderFails_ReturnsError_AndDoesNotBook()
{ {
@@ -120,6 +151,6 @@ public class ExecutionServiceTests
result.Reason.Should().Contain("Broker abgelehnt"); result.Reason.Should().Contain("Broker abgelehnt");
await _portfolio.DidNotReceive().RecordFillAsync( await _portfolio.DidNotReceive().RecordFillAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TradeSide>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TradeSide>(),
Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<CancellationToken>()); Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
} }
} }
@@ -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;
/// <summary>
/// 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.
/// </summary>
[Trait("cat", "unit")]
public class UiConstructionTests
{
private sealed class Factory<T>(DbContextOptions<T> options) : IDbContextFactory<T> where T : DbContext
{
public T CreateDbContext() => (T)Activator.CreateInstance(typeof(T), options)!;
}
private static Factory<T> InMemory<T>() where T : DbContext =>
new(new DbContextOptionsBuilder<T>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
private sealed class NoChat : IChatCompletionClient
{
public Task<ChatResponse> CompleteAsync(string m, IReadOnlyList<ChatMessage> msgs,
IReadOnlyList<SupervisorTool> 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<AccountingDbContext>();
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<CoreDbContext>();
var supDbf = InMemory<SupervisorDbContext>();
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();
}
}