diff --git a/IBKRTrader.App.csproj b/IBKRTrader.App.csproj
index 2c4e752..894b7b6 100644
--- a/IBKRTrader.App.csproj
+++ b/IBKRTrader.App.csproj
@@ -43,6 +43,8 @@
+
+
diff --git a/IBKRTrader.slnx b/IBKRTrader.slnx
index 47f7475..8a7cc48 100644
--- a/IBKRTrader.slnx
+++ b/IBKRTrader.slnx
@@ -2,5 +2,7 @@
+
+
diff --git a/Program.cs b/Program.cs
index 065f160..58b6367 100644
--- a/Program.cs
+++ b/Program.cs
@@ -5,12 +5,16 @@ using IBKRTrader.Core.DependencyInjection;
using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
+using IBKRTrader.Core.Persistence;
+using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Security;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Core.Workers.BuiltIn;
+using IBKRTrader.Modules.Accounting;
using IBKRTrader.Modules.CongressTrading;
+using IBKRTrader.Modules.Supervisor;
using IBKRTrader.UI;
using IBKRTrader.UI.Views;
using Microsoft.Extensions.Configuration;
@@ -42,7 +46,7 @@ internal static class Program
ApplicationConfiguration.Initialize();
- var modules = new List { new CongressTradingModule() };
+ var modules = new List { new CongressTradingModule(), new AccountingModule(), new SupervisorModule() };
AppHost = Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
@@ -110,6 +114,10 @@ internal static class Program
services.AddSingleton();
services.AddSingleton();
+ // Datenfundament für Analyse/Forensik (Supervisor): Entscheidungsjournal + Order-Events.
+ services.AddSingleton();
+ services.AddSingleton();
+
// Trading-Kern
services.AddSingleton();
services.AddSingleton();
@@ -156,6 +164,8 @@ internal static class Program
["core.logs"] = Properties.Resources.error_log,
["core.settings"] = Properties.Resources.setting_tools,
["congresstrading.main"] = Properties.Resources.cross_reference,
+ ["accounting.main"] = Properties.Resources.coins_in_hand,
+ ["supervisor.main"] = Properties.Resources.token_quantifier,
};
foreach (var view in uiHost.Views)
if (view.Icon is null && map.TryGetValue(view.Id, out var img))
@@ -253,7 +263,7 @@ internal static class Program
{
ApplicationConfiguration.Initialize();
- var modules = new List { new CongressTradingModule() };
+ var modules = new List { new CongressTradingModule(), new AccountingModule(), new SupervisorModule() };
using var host = Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
diff --git a/README.md b/README.md
index e6805a8..ad3a48b 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@ tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory)
- **Persistenz**: EF Core (Pomelo/MariaDB), Migrationen **extern** angewendet (nicht zur Laufzeit).
- **Trading-Kern**: `IExecutionService` (Signal→Risiko→Order→Buchung), `IRiskService`, `IPortfolioService`,
Broker hinter `IBrokerClient` (Default: `NullBrokerClient` – handelt nie, bis IBKR angebunden).
+- **Analyse-Datenfundament**: `core_decision_journal` (jede Entscheidung + ReasonCode), `core_order_events`,
+ `SignalId`-Korrelation, JSONL-Log-Sink (`Logs/{yyyy-MM-dd}.jsonl`) – speist den Supervisor.
- Details: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Build & Test
@@ -30,6 +32,9 @@ dotnet run --project IBKRTrader.App.csproj # App starten
- `appsettings.Local.json` (gitignored) hält den DB-Connection-String (`Database:MySqlConnectionString`).
- `settings.json` (gitignored) – App-Settings (IBKR-Ports, Logging, Worker, Trading).
- Optional `IBKRTRADER_MASTER_KEY` bzw. `master.key` für at-rest-Verschlüsselung (AES-256-GCM).
+- Supervisor (optional): `IBKRTRADER_OPENROUTER_KEY` bzw. `openrouter.key` (KI-Analyse), sowie die
+ Opt-ins `IBKRTRADER_SUPERVISOR_DAILY` (Tagesbericht, Stunde 0–23) und `IBKRTRADER_MCP_PORT` (MCP-Light,
+ nur 127.0.0.1).
## Datenbank aufsetzen
Schema wird per EF-Migrationen extern angewendet – siehe [scripts/README.md](scripts/README.md):
@@ -40,9 +45,19 @@ powershell -File scripts/provision-db.ps1
## Module
- **CongressTrading** – kopiert US-Kongress-Trades (capitoltrades.com) → `TradeSignal` → ExecutionService.
+- **Accounting** – von der Trading-DB unabhängige Buchführung aus dem IBKR-Kontoauszug (Activity Flex
+ Query) → append-only Ledger `acc_*`, Periodenabrechnung/BWA, FX (USD/EUR), CSV/PDF-Export. Kein Handel.
+ Live-Abruf hinter Interfaces (Offline-Null-Stubs); Steuerschicht bewusst offen. Konzept:
+ [docs/konzepte/KONZEPT-Modul-Accounting.md](docs/konzepte/KONZEPT-Modul-Accounting.md).
+- **Supervisor** – read-only KI-Analyse/Forensik über alle Module (OpenRouter-Agent + read-only
+ Tool-Registry, Dossier-Browser, optional MCP-Light). Stützt sich auf das Core-Datenfundament
+ (`core_decision_journal`, `core_order_events`, `SignalId`, JSONL-Logs). Konzept:
+ [docs/konzepte/KONZEPT-Modul-Supervisor.md](docs/konzepte/KONZEPT-Modul-Supervisor.md).
## Status / Nächstes
- Kurskorrektur auf das PolytraderSharp-Konzept (R1–R7) abgeschlossen.
+- **Accounting**- und **Supervisor**-Modul (inkl. Core-Datenfundament S-0) ergänzt; Live-Abruf (IBKR
+ Flex / OpenRouter-Key) und Steuerschicht sind bewusst noch offen (Stubs/Platzhalter).
- **Nächster Meilenstein:** IBKR-Broker über die **TWS API / IB Gateway** (blockiert bis Paper-Zugang) –
Plan: [docs/IBKR-Integration.md](docs/IBKR-Integration.md).
- **Sicherheit:** DB-Passwort rotieren (liegt in der Git-Historie, Commit `ebeb035`).
diff --git a/docs/konzepte/KONZEPT-Modul-Accounting.md b/docs/konzepte/KONZEPT-Modul-Accounting.md
new file mode 100644
index 0000000..f8cd3e0
--- /dev/null
+++ b/docs/konzepte/KONZEPT-Modul-Accounting.md
@@ -0,0 +1,69 @@
+# Konzept: Modul „Accounting" (Buchhaltung/Reporting aller Konten)
+
+> Stand: 2026-07-30
+> Ziel: Vollständige, **von unserer Trading-DB unabhängige**, buchhalterisch korrekte Erfassung ALLER
+> Kontobewegungen der IBKR-Konten. Periodische (meist monatliche), vor einer Steuerbehörde
+> nachvollziehbare Aufstellungen — je Konto ODER über alle Konten, für frei wählbare Zeiträume.
+> BWA-artige Kennzahlen-Übersicht in der UI. Export als CSV und PDF. **Kein Handel; reines
+> Ingest-/Reporting-Modul.**
+>
+> Vorbild: gleichnamiges Modul in PolytraderSharp (Polymarket). Hier auf IBKR-Aktien übertragen.
+
+## 0. Leitprinzipien
+1. **Unabhängige Quelle = IBKR-Kontoauszug, NICHT unsere DB.** Das Modul erhebt die Buchungsgrundlage
+ ausschließlich über eigene Abrufe des **IBKR Activity Flex Query (XML)** und speichert sie roh +
+ normalisiert in eigenen `acc_`-Tabellen. Der Flex Web Service (Token + Query-Id) braucht **keine**
+ laufende TWS-Socket-Verbindung. Unsere eigenen Trade-Logs dienen nur dem optionalen Abgleich, nie
+ als Buchungsgrundlage.
+2. **Nachvollziehbarkeit / Audit.** Jeder Buchungssatz führt über `TransactionId` (IBKR tradeID /
+ transactionID) und den unveränderlichen `IdempotencyKey` auf einen prüfbaren Nachweis zurück. Der
+ Roh-Ingest ist **append-only**; Abrechnungen sind daraus reproduzierbar.
+3. **Lesend / idempotent.** Überlappende Wiederholungs-Abrufe buchen nichts doppelt (Unique-Index auf
+ `IdempotencyKey`, Upsert statt Insert).
+
+## 1. Architektur-Einbettung
+Projekt `src/IBKRTrader.Modules.Accounting/` als `IModule` (`Name="Accounting"`, `DbPrefix="acc_"`),
+Registrierung in `Program.cs`. Referenziert nur den Core. Eigener `AccountingDbContext`, eigene UI
+(ein Fenster mit Tabs), eigene Settings-Sektion.
+
+## 2. Datenbeschaffung
+- **Activity Flex Query** = primärer Kontoauszug: `` (Käufe/Verkäufe: Preis, Menge, Kommission,
+ Währung, FX-Rate zur Basiswährung, tradeID) und `` (Dividenden, Quellensteuer,
+ Zinsen, Ein-/Auszahlungen, Gebühren).
+- **Backfill + Inkrementell**: Erstlauf lädt die volle Historie, danach nur Neues ab dem letzten
+ bekannten Zeitpunkt mit Sicherheits-Lookback (Standard 24 h).
+- **Idempotenz-Schlüssel** je Satz: `TRD||` bzw. `CASH||`.
+- **Balance-Anker**: gemeldeter Kontosaldo je Abruf als Soll-Ist-Kontrollpunkt.
+- Der Abruf liegt hinter Interfaces (`IStatementSource`/`IBalanceAnchorSource`/`IAccountingAccountSource`)
+ mit **Offline-Null-Stubs** — das Modul läuft ohne Live-Anbindung vollständig (bucht dann korrekt nichts).
+ Der Live-Flex-Abruf ist **Zielland-Arbeit**.
+
+## 3. Persistenz (`acc_`-Tabellen, append-only)
+| Tabelle | Inhalt |
+|---|---|
+| `acc_ledger` | Normalisierte, unveränderliche Buchungssätze (Typ, Vorzeichen=Cash-Wirkung, native + Basiswährung, TransactionId, **IdempotencyKey unique**) |
+| `acc_ingest_runs` | Abruf-Protokoll je Konto (Von/Bis, #neu/#Duplikate, Balance-Anker-Δ) |
+| `acc_raw` | Rohdaten-Snapshots je Batch (Nachweis) |
+| `acc_fx_rates` | amtliche USD→EUR-Tageskurse (EZB) je Datum |
+
+## 4. Logik (pur, unit-getestet — `Logic/`)
+- `AccountingClassifier` — Flex-Zeile → Buchungssatz (Typ, Vorzeichen, Idempotenz-Key). Ein-/Auszahlung
+ per Vorzeichen (kombinierte IBKR-Kategorie).
+- `AccountingEngine` — Periodenabrechnung (Anfangs-/Endsaldo, Einlagen/Entnahmen, Handelsvolumen,
+ Dividenden, Zinsen, Fees, Quellensteuer, Netto-Handelsergebnis Cash-Basis) + Monatsvergleich.
+ Invariante: Endsaldo−Anfang = Ergebnis + Einzahlungen − Auszahlungen.
+- `FxConverter` — USD→EUR (Nearest-on-or-before). `CsvExporter` (RFC-4180, kulturinvariant).
+ `PdfExporter` (PDFsharp/MigraDoc, MIT).
+- Realisierte GuV nutzt den Core-`RealizedPnlEngine` (FIFO) — kein Duplikat.
+
+## 5. UI (WinForms, ein Fenster mit Tabs)
+Übersicht/BWA (KPI-Kacheln + Monatsvergleich, Zeitraum-/Konto-/Währungswahl), Ledger (filterbar),
+Steuer (Platzhalter, s. u.), Abrechnung/Export (CSV/PDF), Abruf/Status (Ingest-Läufe, Soll-Ist, manueller
+Trigger). DB-Zugriff nur auf Interaktion (Smoke-UI-sicher).
+
+## 6. Bewusst offen / Zielland-Arbeit
+- **Live-IBKR-Flex-Abruf** (Token/Query-Id) + Balance-Anker → echte Buchungen (heute Null-Stub).
+- **Steuerschicht**: Jurisdiktion (DE-Kapitalertragsteuer / US Form 8949) noch **nicht festgelegt**.
+ Der neutrale Ledger + die Abrechnung gelten unabhängig davon; die Steuer-UI/Engine ist als klar
+ abgetrennter, später füllbarer Platzhalter angelegt. **Keine Steuerberatung.**
+- **EZB-FX-Ingest** (`acc_fx_rates` füllen) → EUR-Ansicht; USD (Basis) ist sofort verfügbar.
diff --git a/docs/konzepte/KONZEPT-Modul-Supervisor.md b/docs/konzepte/KONZEPT-Modul-Supervisor.md
new file mode 100644
index 0000000..907f0e4
--- /dev/null
+++ b/docs/konzepte/KONZEPT-Modul-Supervisor.md
@@ -0,0 +1,63 @@
+# Konzept: Modul „Supervisor" (KI-gestützte Handels-Analyse & Forensik)
+
+> Stand: 2026-07-30
+> Ziel: ALLES, was IBKRTrader getan (und bewusst NICHT getan) hat, detailliert analysierbar machen —
+> Entscheidungen, Orders, Trades und Logs — und die Analyse durch ein KI-Modell (OpenRouter) durchführen
+> lassen: Warum hat ein Trade funktioniert? Warum nicht? Woran lag es?
+> **Leitidee: Erst das Datenfundament, dann die KI.** Ein Modell kann nur erklären, was aufgezeichnet wurde.
+>
+> Vorbild: gleichnamiges Modul in PolytraderSharp. Hier auf IBKR übertragen; strikt read-only.
+
+## S-0 Datenfundament (Core — umgesetzt)
+Grundlage jeder guten Analyse, sofort auch OHNE KI nützlich (abfragbare Rejects, Log-Forensik):
+- **`core_decision_journal`** — JEDE Entscheidung (Executed/Rejected/Skipped/Failed) mit `ReasonCode`
+ (Enum, als String persistiert), SignalId, Kontext-JSON und Freitext. Geschrieben vom `ExecutionService`.
+- **`core_order_events`** — Order-Lifecycle (Placed/Filled/PlaceFailed/…): Preise, Menge, Broker-Antwort.
+- **`SignalId`** wird durch `TradeSignal → ExecutionService → Portfolio → core_trade_history`
+ durchgereicht → verbindet Signal → Entscheidung(en) → Order(s) → Trade.
+- **JSONL-Log-Sink** — zusätzlich zur Textdatei eine Zeile je Event nach `Logs/{yyyy-MM-dd}.jsonl`
+ (`ts, level, source, cid=SignalId, message`); zeilenweise filter-/parsebar.
+- Pure Core-Analytik: `RealizedPnlEngine` (FIFO), `TradeAnalytics` (KPIs), `DossierBuilder`.
+- Schreibpfade sind **fehlertolerant** — ein Journal-/DB-Fehler bricht den Handel nie.
+
+## S-1 Dossier
+`DossierService` setzt zu einer SignalId Entscheidungen + Order-Events + Trades + JSONL-Log-Auszug
+zusammen; `DossierBuilder` (Core, pur) rendert JSON (fürs Modell) und Markdown (für Menschen).
+
+## S-2 Agent + Tool-Registry
+- In-Prozess-Function-Calling-Loop gegen **OpenRouter** (`OpenRouterClient`, OpenAI-kompatibel).
+- **Read-only-Tools** (`SupervisorTools`): `query_decisions`, `query_order_events`, `query_trades`,
+ `get_dossier`, `read_logs`, `get_kpis`, `get_architecture_context`, `query_counterfactuals`.
+ **Kein Tool kann handeln, canceln oder schreiben.**
+- **Profile** (`SupervisorProfiles`): Allgemein / Technik / CongressTrading = System-Prompt + Tool-Subset
+ über EINER Infrastruktur (bewusst keine Agent-zu-Agent-Orchestrierung).
+- Harte Iterationsgrenze gegen Endlosschleifen; jeder Tool-Aufruf wird in der UI sichtbar geloggt.
+- System-Kontext: kuratiertes Architektur-/Verhaltensdokument (`ArchitectureContext`, inline versioniert).
+
+## S-3 Berichte & Counterfactual
+- `sup_reports` — jede Analyse (Frage/Antwort/Profil/Modell/Tool-Aufrufe) → der Supervisor ist selbst
+ auditierbar.
+- `CounterfactualJob` — „was wäre aus abgelehnten BUYs geworden?" (späterer Kurs vs. Signalpreis).
+ Die Kursauflösung liegt hinter `ICounterfactualResolutionSource` mit **Null-Stub** (Zielland-Arbeit).
+- `DailyReportService` — täglicher Bericht, **opt-in** via `IBKRTRADER_SUPERVISOR_DAILY` (Stunde 0–23).
+
+## S-4 MCP-Light
+`McpLightServer` exponiert dieselbe read-only Tool-Registry als lokalen MCP-Endpoint für externe Clients
+(z. B. Claude Code). **Opt-in** via `IBKRTRADER_MCP_PORT`, bindet nur `127.0.0.1`. Handler `McpJsonRpc`
+ist pur + unit-getestet (initialize/ping/tools.list/tools.call).
+
+## Architektur & Unterbringung
+Projekt `src/IBKRTrader.Modules.Supervisor/` als `IModule` (`Name="Supervisor"`, `DbPrefix="sup_"`),
+referenziert nur den Core. Eigenes Fenster mit Tabs: Analyse (Chat), Dossier-Browser, Berichte, Settings.
+
+## Sicherheit
+- **OpenRouter = bewusst freigegebener externer Datenempfänger.** Es werden nur Analyse-Daten der Tools
+ gesendet, niemals Secrets/Keys/Connection-Strings.
+- Separater API-Key (`IBKRTRADER_OPENROUTER_KEY` oder gitignorierte `openrouter.key`), getrennt von
+ künftigen Trading-Keys.
+- **Read-only by design** — kein Order-/Schreib-Tool. Prompt-Injection über Fremdtexte bleibt auf
+ „falsche Analyse" begrenzt, kann nie handeln.
+
+## Bewusst offen / Zielland-Arbeit
+- Counterfactual-Kursauflösung für Aktien (späterer Kurs) — Interface + Stub vorhanden.
+- Externer Versand des Tagesberichts (z. B. Threema) — heute nur Persistenz/Log.
diff --git a/scripts/provision-db.ps1 b/scripts/provision-db.ps1
index ebbedd7..17c7c2c 100644
--- a/scripts/provision-db.ps1
+++ b/scripts/provision-db.ps1
@@ -45,4 +45,12 @@ Write-Host "Wende CongressTradingDbContext-Migrationen an..." -ForegroundColor C
dotnet ef database update --project src/IBKRTrader.Modules.CongressTrading --startup-project src/IBKRTrader.Modules.CongressTrading --context CongressTradingDbContext
if ($LASTEXITCODE -ne 0) { Write-Error "CongressTradingDbContext-Migration fehlgeschlagen."; exit 1 }
+Write-Host "Wende AccountingDbContext-Migrationen an..." -ForegroundColor Cyan
+dotnet ef database update --project src/IBKRTrader.Modules.Accounting --startup-project src/IBKRTrader.Modules.Accounting --context AccountingDbContext
+if ($LASTEXITCODE -ne 0) { Write-Error "AccountingDbContext-Migration fehlgeschlagen."; exit 1 }
+
+Write-Host "Wende SupervisorDbContext-Migrationen an..." -ForegroundColor Cyan
+dotnet ef database update --project src/IBKRTrader.Modules.Supervisor --startup-project src/IBKRTrader.Modules.Supervisor --context SupervisorDbContext
+if ($LASTEXITCODE -ne 0) { Write-Error "SupervisorDbContext-Migration fehlgeschlagen."; exit 1 }
+
Write-Host "DB-Provisioning abgeschlossen." -ForegroundColor Green
diff --git a/src/IBKRTrader.Core/Analytics/DossierBuilder.cs b/src/IBKRTrader.Core/Analytics/DossierBuilder.cs
new file mode 100644
index 0000000..6e4cffb
--- /dev/null
+++ b/src/IBKRTrader.Core/Analytics/DossierBuilder.cs
@@ -0,0 +1,95 @@
+using System.Text;
+using System.Text.Json;
+using IBKRTrader.Core.Logging;
+using IBKRTrader.Core.Persistence.Entities;
+
+namespace IBKRTrader.Core.Analytics;
+
+///
+/// Das komplette, rekonstruierte Bild zu einer SignalId: Entscheidungskette, Order-Events,
+/// gebuchte Trades und der zugehörige JSONL-Log-Auszug. Read-only zusammengesetzt.
+///
+public sealed record TradeDossier(
+ string SignalId,
+ IReadOnlyList Decisions,
+ IReadOnlyList OrderEvents,
+ IReadOnlyList Trades,
+ IReadOnlyList LogLines);
+
+///
+/// Reiner Zusammenbau + Rendering eines Dossiers (JSON fürs Modell, Markdown für Menschen). Keine I/O –
+/// die Beschaffung (DB-Queries, JSONL-Lesen) liegt im DossierService des Supervisor-Moduls.
+///
+public static class DossierBuilder
+{
+ public static TradeDossier Build(
+ string signalId,
+ IEnumerable decisions,
+ IEnumerable orderEvents,
+ IEnumerable trades,
+ IEnumerable logLines) =>
+ new(signalId,
+ decisions.OrderBy(d => d.Timestamp).ToList(),
+ orderEvents.OrderBy(e => e.Timestamp).ToList(),
+ trades.OrderBy(t => t.TradedAt).ToList(),
+ logLines.OrderBy(l => l.Ts).ToList());
+
+ public static string ToMarkdown(TradeDossier d)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"# Dossier – Signal `{d.SignalId}`");
+ sb.AppendLine();
+
+ sb.AppendLine("## Entscheidungen");
+ if (d.Decisions.Count == 0) sb.AppendLine("_(keine)_");
+ foreach (var r in d.Decisions)
+ sb.AppendLine($"- {r.Timestamp:u} · **{r.Decision}** ({r.Reason}) · {r.Module} · {r.Side} {r.Symbol} @ {r.SignalPrice:F2} · {r.Message}");
+ sb.AppendLine();
+
+ sb.AppendLine("## Order-Events");
+ if (d.OrderEvents.Count == 0) sb.AppendLine("_(keine)_");
+ foreach (var e in d.OrderEvents)
+ sb.AppendLine($"- {e.Timestamp:u} · **{e.EventType}** · {e.Side} {e.Quantity}x {e.Symbol} @ {e.Price:F2} ({e.OrderType}) · {e.Response}");
+ sb.AppendLine();
+
+ sb.AppendLine("## Trades");
+ if (d.Trades.Count == 0) sb.AppendLine("_(keine)_");
+ foreach (var t in d.Trades)
+ sb.AppendLine($"- {t.TradedAt:u} · {t.Action} {t.Quantity}x {t.Symbol} @ {t.Price:F2} = {t.TotalValue:F2} · {t.Status}");
+ sb.AppendLine();
+
+ sb.AppendLine("## Log-Auszug");
+ if (d.LogLines.Count == 0) sb.AppendLine("_(keine passenden JSONL-Zeilen)_");
+ foreach (var l in d.LogLines)
+ sb.AppendLine($"- {l.Ts:u} [{l.Level}] {l.Source}: {l.Message}");
+
+ return sb.ToString();
+ }
+
+ public static string ToJson(TradeDossier d)
+ {
+ var payload = new
+ {
+ signalId = d.SignalId,
+ decisions = d.Decisions.Select(r => new
+ {
+ ts = r.Timestamp, r.Module, r.Symbol, r.Side, price = r.SignalPrice,
+ decision = r.Decision.ToString(), reason = r.Reason.ToString(), r.Message, ctx = r.ContextJson
+ }),
+ orderEvents = d.OrderEvents.Select(e => new
+ {
+ ts = e.Timestamp, e.Module, e.Symbol, eventType = e.EventType.ToString(),
+ e.Side, e.Price, e.Quantity, e.OrderType, e.Response, details = e.DetailsJson
+ }),
+ trades = d.Trades.Select(t => new
+ {
+ ts = t.TradedAt, t.Module, t.Symbol, t.Action, t.Quantity, t.Price, t.TotalValue, t.Status
+ }),
+ logLines = d.LogLines.Select(l => new { ts = l.Ts, l.Level, l.Source, l.Message })
+ };
+ return JsonSerializer.Serialize(payload, new JsonSerializerOptions
+ {
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ });
+ }
+}
diff --git a/src/IBKRTrader.Core/Analytics/RealizedPnlEngine.cs b/src/IBKRTrader.Core/Analytics/RealizedPnlEngine.cs
new file mode 100644
index 0000000..74c4f1f
--- /dev/null
+++ b/src/IBKRTrader.Core/Analytics/RealizedPnlEngine.cs
@@ -0,0 +1,81 @@
+using IBKRTrader.Core.Persistence.Entities;
+
+namespace IBKRTrader.Core.Analytics;
+
+///
+/// Ein geschlossener (realisierter) Teil-Trade: eine SELL-Menge, gegen ihre FIFO-gematchten BUY-Lots
+/// abgerechnet. Aus den Fills der core_trade_history rein rechnerisch abgeleitet.
+///
+public sealed record RealizedTrade(
+ string Module,
+ string Symbol,
+ decimal Quantity,
+ decimal BuyPrice,
+ decimal SellPrice,
+ DateTime OpenedAt,
+ DateTime ClosedAt)
+{
+ /// Realisierte GuV dieser Menge (Erlös − Einstand); Fees sind hier nicht berücksichtigt.
+ public decimal RealizedPnl => (SellPrice - BuyPrice) * Quantity;
+}
+
+///
+/// Reines, seiteneffektfreies FIFO-Lot-Matching über Fills (BUY öffnet Lots, SELL realisiert gegen die
+/// ältesten offenen Lots) — je (Modul, Symbol). Grundlage sowohl für die Supervisor-KPIs
+/// () als auch für die realisierte GuV im Accounting-Modul (kein Duplikat).
+/// Geldkritisch → unit-getestet. Long-only-Sicht: SELL-Mengen ohne passendes offenes Lot werden
+/// ignoriert (kein Leerverkauf modelliert).
+///
+public static class RealizedPnlEngine
+{
+ private sealed class Lot
+ {
+ public decimal Quantity;
+ public decimal Price;
+ public DateTime OpenedAt;
+ }
+
+ /// Matcht alle Fills zu realisierten Teil-Trades (chronologisch, FIFO je Modul+Symbol).
+ public static IReadOnlyList Match(IEnumerable fills)
+ {
+ var result = new List();
+
+ var groups = fills
+ .GroupBy(f => (f.Module, f.Symbol));
+
+ foreach (var g in groups)
+ {
+ var open = new Queue();
+ foreach (var f in g.OrderBy(x => x.TradedAt).ThenBy(x => x.Id))
+ {
+ bool isBuy = string.Equals(f.Action, "BUY", StringComparison.OrdinalIgnoreCase);
+ if (isBuy)
+ {
+ open.Enqueue(new Lot { Quantity = f.Quantity, Price = f.Price, OpenedAt = f.TradedAt });
+ continue;
+ }
+
+ // SELL: gegen die ältesten offenen Lots abrechnen.
+ decimal remaining = f.Quantity;
+ while (remaining > 0 && open.Count > 0)
+ {
+ var lot = open.Peek();
+ decimal matched = Math.Min(remaining, lot.Quantity);
+ result.Add(new RealizedTrade(
+ g.Key.Module, g.Key.Symbol, matched, lot.Price, f.Price, lot.OpenedAt, f.TradedAt));
+
+ lot.Quantity -= matched;
+ remaining -= matched;
+ if (lot.Quantity <= 0) open.Dequeue();
+ }
+ // Überschüssige SELL-Menge ohne offenes Lot: ignoriert (kein Short modelliert).
+ }
+ }
+
+ return result;
+ }
+
+ /// Summe der realisierten GuV über alle gematchten Teil-Trades.
+ public static decimal TotalRealized(IEnumerable fills) =>
+ Match(fills).Sum(t => t.RealizedPnl);
+}
diff --git a/src/IBKRTrader.Core/Analytics/TradeAnalytics.cs b/src/IBKRTrader.Core/Analytics/TradeAnalytics.cs
new file mode 100644
index 0000000..4e9c2c3
--- /dev/null
+++ b/src/IBKRTrader.Core/Analytics/TradeAnalytics.cs
@@ -0,0 +1,47 @@
+using IBKRTrader.Core.Persistence.Entities;
+
+namespace IBKRTrader.Core.Analytics;
+
+/// Kennzahlen über realisierte Trades.
+public sealed record Kpis(
+ int TradeCount,
+ decimal NetPnl,
+ double WinRatePct,
+ decimal AvgPnlPerTrade,
+ double ProfitFactor);
+
+/// GuV-Aufteilung nach einem Schlüssel (z. B. Modul).
+public sealed record PnlBucket(string Key, decimal Pnl, int Count);
+
+///
+/// Reine KPI-Berechnung über die Fills der core_trade_history: erst FIFO-Realisierung
+/// (), dann Aggregat. Genutzt von den Supervisor-Tools (get_kpis).
+///
+public static class TradeAnalytics
+{
+ public static Kpis ComputeKpis(IEnumerable fills)
+ {
+ var realized = RealizedPnlEngine.Match(fills);
+ int count = realized.Count;
+ if (count == 0) return new Kpis(0, 0m, 0d, 0m, 0d);
+
+ decimal net = realized.Sum(t => t.RealizedPnl);
+ int winners = realized.Count(t => t.RealizedPnl > 0);
+ decimal grossProfit = realized.Where(t => t.RealizedPnl > 0).Sum(t => t.RealizedPnl);
+ decimal grossLoss = Math.Abs(realized.Where(t => t.RealizedPnl < 0).Sum(t => t.RealizedPnl));
+
+ double winRate = 100d * winners / count;
+ double profitFactor = grossLoss == 0m ? (grossProfit > 0m ? double.PositiveInfinity : 0d)
+ : (double)(grossProfit / grossLoss);
+
+ return new Kpis(count, net, winRate, net / count, profitFactor);
+ }
+
+ /// Realisierte GuV je Modul (absteigend nach GuV).
+ public static IReadOnlyList PnlByModule(IEnumerable fills) =>
+ RealizedPnlEngine.Match(fills)
+ .GroupBy(t => t.Module)
+ .Select(g => new PnlBucket(g.Key, g.Sum(t => t.RealizedPnl), g.Count()))
+ .OrderByDescending(b => b.Pnl)
+ .ToList();
+}
diff --git a/src/IBKRTrader.Core/Logging/LogJson.cs b/src/IBKRTrader.Core/Logging/LogJson.cs
new file mode 100644
index 0000000..c3533b1
--- /dev/null
+++ b/src/IBKRTrader.Core/Logging/LogJson.cs
@@ -0,0 +1,59 @@
+using System.Text;
+using System.Text.Json;
+
+namespace IBKRTrader.Core.Logging;
+
+///
+/// Pure, seiteneffektfreie (De-)Serialisierung einer JSONL-Log-Zeile. JSONL (eine JSON-Zeile je Event)
+/// ist append-fähig, streambar und zeilenweise filterbar – KI-freundlich und effizient. Die eigentliche
+/// Datei-I/O liegt im ; hier nur das Format, damit es unit-getestet werden
+/// kann (Round-Trip). Feldnamen bewusst kurz: ts, level, source, cid, message.
+///
+public static class LogJson
+{
+ private static readonly JsonSerializerOptions Opts = new()
+ {
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ };
+
+ /// Eine geparste JSONL-Zeile (Beiwerk – fehlende/kaputte Zeilen liefern null).
+ public sealed record ParsedLogLine(DateTime Ts, string Level, string Source, string? Cid, string Message);
+
+ /// Serialisiert ein Log-Event zu genau einer JSON-Zeile (ohne Zeilenumbruch).
+ public static string WriteLine(DateTime ts, AppLogLevel level, string source, string message, string? cid)
+ {
+ using var ms = new MemoryStream();
+ using (var w = new Utf8JsonWriter(ms))
+ {
+ w.WriteStartObject();
+ w.WriteString("ts", ts.ToUniversalTime().ToString("O"));
+ w.WriteString("level", level.ToString());
+ w.WriteString("source", source ?? "");
+ if (!string.IsNullOrEmpty(cid)) w.WriteString("cid", cid);
+ w.WriteString("message", message ?? "");
+ w.WriteEndObject();
+ }
+ return Encoding.UTF8.GetString(ms.ToArray());
+ }
+
+ /// Parst eine JSONL-Zeile. Gibt null zurück, wenn die Zeile leer oder kein gültiges JSON ist.
+ public static ParsedLogLine? ParseLine(string? line)
+ {
+ if (string.IsNullOrWhiteSpace(line)) return null;
+ try
+ {
+ using var doc = JsonDocument.Parse(line);
+ var r = doc.RootElement;
+ DateTime ts = r.TryGetProperty("ts", out var tsp) && tsp.TryGetDateTime(out var d) ? d : default;
+ string level = r.TryGetProperty("level", out var lp) ? lp.GetString() ?? "" : "";
+ string source = r.TryGetProperty("source", out var sp) ? sp.GetString() ?? "" : "";
+ string? cid = r.TryGetProperty("cid", out var cp) ? cp.GetString() : null;
+ string msg = r.TryGetProperty("message", out var mp) ? mp.GetString() ?? "" : "";
+ return new ParsedLogLine(ts, level, source, cid, msg);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/IBKRTrader.Core/Logging/LoggingService.cs b/src/IBKRTrader.Core/Logging/LoggingService.cs
index 11aab33..778bda5 100644
--- a/src/IBKRTrader.Core/Logging/LoggingService.cs
+++ b/src/IBKRTrader.Core/Logging/LoggingService.cs
@@ -11,7 +11,8 @@ public class LoggingService
{
private RichTextBox? _rtb;
private AppLogLevel _minLevel = AppLogLevel.Info;
- private readonly object _fileLock = new();
+ private readonly object _fileLock = new();
+ private readonly object _jsonlLock = new();
private static readonly string LogBaseDir =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
@@ -33,11 +34,23 @@ public class LoggingService
public void Error(string module, string message, Exception? ex = null)
=> Write(AppLogLevel.Error, module, message, ex);
- public void Write(AppLogLevel level, string module, string message, Exception? ex = null)
+ // Überladungen mit CorrelationId (SignalId) – schreiben zusätzlich strukturiert ins JSONL,
+ // sodass der Supervisor die komplette Kette „alles zu diesem Signal" filtern kann.
+ public void Info (string module, string message, string? cid, Exception? ex = null)
+ => Write(AppLogLevel.Info, module, message, ex, cid);
+
+ public void Warn (string module, string message, string? cid, Exception? ex = null)
+ => Write(AppLogLevel.Warn, module, message, ex, cid);
+
+ public void Error(string module, string message, string? cid, Exception? ex = null)
+ => Write(AppLogLevel.Error, module, message, ex, cid);
+
+ public void Write(AppLogLevel level, string module, string message, Exception? ex = null, string? cid = null)
{
if (level < _minLevel) return;
var entry = new LogEntry(DateTime.Now, level, module, message, ex);
WriteToFile(entry);
+ WriteToJsonl(entry, cid);
WriteToRtb(entry);
}
@@ -61,6 +74,27 @@ public class LoggingService
catch { /* Logging darf niemals abstürzen */ }
}
+ // ─── JSONL (KI-freundlicher Zweit-Sink) ─────────────────────────────────────
+
+ ///
+ /// Schreibt zusätzlich eine JSON-Zeile nach Logs\{yyyy-MM-dd}.jsonl (Dual-Sink). Zeilenweise
+ /// filter-/parsebar (Datum/Level/Quelle/Text/CorrelationId) – Grundlage für Log Viewer + Supervisor.
+ ///
+ private void WriteToJsonl(LogEntry e, string? cid)
+ {
+ try
+ {
+ Directory.CreateDirectory(LogBaseDir);
+ var file = Path.Combine(LogBaseDir, $"{e.Timestamp:yyyy-MM-dd}.jsonl");
+ var message = e.Exception != null ? $"{e.Message} | {e.Exception.Message}" : e.Message;
+ var json = LogJson.WriteLine(e.Timestamp, e.Level, e.Module, message, cid);
+
+ lock (_jsonlLock)
+ File.AppendAllText(file, json + "\n");
+ }
+ catch { /* Logging darf niemals abstürzen */ }
+ }
+
// ─── RichTextBox ──────────────────────────────────────────────────────────
private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150);
diff --git a/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs b/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs
index 3f613a2..b2be094 100644
--- a/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs
+++ b/src/IBKRTrader.Core/Persistence/Ef/CoreDbContext.cs
@@ -18,6 +18,10 @@ public class CoreDbContext : DbContext
public DbSet WorkerLog => Set();
public DbSet Settings => Set();
+ // Datenfundament für Analyse/Forensik (Supervisor)
+ public DbSet DecisionJournal => Set();
+ public DbSet OrderEvents => Set();
+
// IBKR-Marktdaten
public DbSet Instruments => Set();
public DbSet MarketBars => Set();
@@ -41,6 +45,7 @@ public class CoreDbContext : DbContext
e.Property(x => x.Module).HasMaxLength(50);
e.Property(x => x.Symbol).HasMaxLength(20);
e.Property(x => x.Action).HasMaxLength(10);
+ e.Property(x => x.SignalId).HasMaxLength(64);
e.Property(x => x.IbkrOrderId).HasMaxLength(100);
e.Property(x => x.Status).HasMaxLength(50);
e.Property(x => x.Quantity).HasPrecision(18, 4);
@@ -48,6 +53,7 @@ public class CoreDbContext : DbContext
e.Property(x => x.TotalValue).HasPrecision(18, 4);
e.HasIndex(x => x.Symbol);
e.HasIndex(x => x.Module);
+ e.HasIndex(x => x.SignalId);
});
b.Entity(e =>
@@ -119,5 +125,39 @@ public class CoreDbContext : DbContext
e.Property(x => x.Source).HasMaxLength(50);
e.Property(x => x.Ticker).HasMaxLength(50);
});
+
+ b.Entity(e =>
+ {
+ e.ToTable("core_decision_journal");
+ e.HasKey(x => x.Id);
+ e.Property(x => x.SignalId).HasMaxLength(64);
+ e.Property(x => x.Module).HasMaxLength(50);
+ e.Property(x => x.Symbol).HasMaxLength(20);
+ e.Property(x => x.Side).HasMaxLength(10);
+ e.Property(x => x.SignalPrice).HasPrecision(18, 4);
+ e.Property(x => x.Decision).HasConversion().HasMaxLength(20);
+ e.Property(x => x.Reason).HasConversion().HasMaxLength(40);
+ e.Property(x => x.ContextJson).HasColumnType("text");
+ e.Property(x => x.Message).HasColumnType("text");
+ e.HasIndex(x => x.SignalId);
+ e.HasIndex(x => new { x.Module, x.Timestamp });
+ });
+
+ b.Entity(e =>
+ {
+ e.ToTable("core_order_events");
+ e.HasKey(x => x.Id);
+ e.Property(x => x.SignalId).HasMaxLength(64);
+ e.Property(x => x.Module).HasMaxLength(50);
+ e.Property(x => x.Symbol).HasMaxLength(20);
+ e.Property(x => x.Side).HasMaxLength(10);
+ e.Property(x => x.OrderType).HasMaxLength(20);
+ e.Property(x => x.Price).HasPrecision(18, 4);
+ e.Property(x => x.EventType).HasConversion().HasMaxLength(20);
+ e.Property(x => x.Response).HasColumnType("text");
+ e.Property(x => x.DetailsJson).HasColumnType("text");
+ e.HasIndex(x => x.SignalId);
+ e.HasIndex(x => new { x.Symbol, x.Timestamp });
+ });
}
}
diff --git a/src/IBKRTrader.Core/Persistence/Ef/EfAnalysisJournals.cs b/src/IBKRTrader.Core/Persistence/Ef/EfAnalysisJournals.cs
new file mode 100644
index 0000000..2419bd6
--- /dev/null
+++ b/src/IBKRTrader.Core/Persistence/Ef/EfAnalysisJournals.cs
@@ -0,0 +1,84 @@
+using System.Linq.Expressions;
+using IBKRTrader.Core.Logging;
+using IBKRTrader.Core.Persistence.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace IBKRTrader.Core.Persistence.Ef;
+
+///
+/// EF-Implementierung des Entscheidungsjournals. ist bewusst fehlertolerant:
+/// ein Journal-/DB-Fehler darf den Geld-Pfad (ExecutionService) niemals brechen – er wird geloggt
+/// und verworfen. Kurzlebiger DbContext je Operation (thread-safe über die Factory).
+///
+public sealed class EfDecisionJournal : IDecisionJournal
+{
+ private readonly IDbContextFactory _dbf;
+ private readonly LoggingService _logger;
+
+ public EfDecisionJournal(IDbContextFactory dbf, LoggingService logger)
+ {
+ _dbf = dbf;
+ _logger = logger;
+ }
+
+ public void Write(CoreDecisionRecord record)
+ {
+ try
+ {
+ using var db = _dbf.CreateDbContext();
+ db.DecisionJournal.Add(record);
+ db.SaveChanges();
+ }
+ catch (Exception ex)
+ {
+ _logger.Warn("Core", $"DecisionJournal-Write fehlgeschlagen (ignoriert): {ex.Message}");
+ }
+ }
+
+ public List Query(Expression> predicate, int limit = 1000)
+ {
+ using var db = _dbf.CreateDbContext();
+ return db.DecisionJournal.AsNoTracking()
+ .Where(predicate)
+ .OrderByDescending(r => r.Timestamp)
+ .Take(limit)
+ .ToList();
+ }
+}
+
+/// EF-Implementierung des Order-Lifecycle-Logs (gleiche Robustheits-Garantie).
+public sealed class EfOrderEventLog : IOrderEventLog
+{
+ private readonly IDbContextFactory _dbf;
+ private readonly LoggingService _logger;
+
+ public EfOrderEventLog(IDbContextFactory dbf, LoggingService logger)
+ {
+ _dbf = dbf;
+ _logger = logger;
+ }
+
+ public void Write(CoreOrderEvent record)
+ {
+ try
+ {
+ using var db = _dbf.CreateDbContext();
+ db.OrderEvents.Add(record);
+ db.SaveChanges();
+ }
+ catch (Exception ex)
+ {
+ _logger.Warn("Core", $"OrderEvent-Write fehlgeschlagen (ignoriert): {ex.Message}");
+ }
+ }
+
+ public List Query(Expression> predicate, int limit = 1000)
+ {
+ using var db = _dbf.CreateDbContext();
+ return db.OrderEvents.AsNoTracking()
+ .Where(predicate)
+ .OrderByDescending(r => r.Timestamp)
+ .Take(limit)
+ .ToList();
+ }
+}
diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.Designer.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.Designer.cs
new file mode 100644
index 0000000..8f5442d
--- /dev/null
+++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.Designer.cs
@@ -0,0 +1,484 @@
+//
+using System;
+using IBKRTrader.Core.Persistence.Ef;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace IBKRTrader.Core.Persistence.Ef.Migrations
+{
+ [DbContext(typeof(CoreDbContext))]
+ [Migration("20260730164359_AddAnalysisFoundation")]
+ partial class AddAnalysisFoundation
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.13")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRExternalIdentifier", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("InstrumentId")
+ .HasColumnType("bigint");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Ticker")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Source", "Ticker");
+
+ b.HasIndex("InstrumentId", "Source", "Ticker")
+ .IsUnique();
+
+ b.ToTable("core_ibkr_external_identifiers", (string)null);
+ });
+
+ modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRInstrument", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Active")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("CompanyName")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasMaxLength(5)
+ .HasColumnType("varchar(5)");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Exchange")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("IbkrConid")
+ .HasColumnType("bigint");
+
+ b.Property("Industry")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("Isin")
+ .HasMaxLength(12)
+ .HasColumnType("varchar(12)");
+
+ b.Property("LastFetched")
+ .HasColumnType("datetime(6)");
+
+ b.Property("PrimaryExchange")
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("SecType")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("varchar(10)");
+
+ b.Property("Sector")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Active");
+
+ b.HasIndex("IbkrConid")
+ .IsUnique();
+
+ b.HasIndex("Symbol");
+
+ b.ToTable("core_ibkr_instruments", (string)null);
+ });
+
+ modelBuilder.Entity("IBKRTrader.Core.IBKR.IBKRMarketBar", b =>
+ {
+ b.Property("InstrumentId")
+ .HasColumnType("bigint");
+
+ b.Property("BarSize")
+ .HasMaxLength(10)
+ .HasColumnType("varchar(10)");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetime(6)");
+
+ b.Property("BarCount")
+ .HasColumnType("int");
+
+ b.Property("Close")
+ .HasPrecision(12, 6)
+ .HasColumnType("decimal(12,6)");
+
+ b.Property("High")
+ .HasPrecision(12, 6)
+ .HasColumnType("decimal(12,6)");
+
+ b.Property("Low")
+ .HasPrecision(12, 6)
+ .HasColumnType("decimal(12,6)");
+
+ b.Property("Open")
+ .HasPrecision(12, 6)
+ .HasColumnType("decimal(12,6)");
+
+ b.Property("Volume")
+ .HasColumnType("bigint");
+
+ b.Property("Wap")
+ .HasPrecision(12, 6)
+ .HasColumnType("decimal(12,6)");
+
+ b.HasKey("InstrumentId", "BarSize", "Timestamp");
+
+ b.HasIndex("Timestamp");
+
+ b.ToTable("core_ibkr_market_data", (string)null);
+ });
+
+ modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreBudget", b =>
+ {
+ b.Property("Module")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("MaxPerTrade")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("TotalBudget")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UsedBudget")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.HasKey("Module");
+
+ b.ToTable("core_budget", (string)null);
+ });
+
+ modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ContextJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Decision")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Module")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("Side")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("varchar(10)");
+
+ b.Property("SignalId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("SignalPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SignalId");
+
+ b.HasIndex("Module", "Timestamp");
+
+ b.ToTable("core_decision_journal", (string)null);
+ });
+
+ modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("DetailsJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Module")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("OrderType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Price")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Quantity")
+ .HasColumnType("int");
+
+ b.Property("Response")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Side")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("varchar(10)");
+
+ b.Property("SignalId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SignalId");
+
+ b.HasIndex("Symbol", "Timestamp");
+
+ b.ToTable("core_order_events", (string)null);
+ });
+
+ modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b =>
+ {
+ b.Property("Module")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Symbol")
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("AvgPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Quantity")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Module", "Symbol");
+
+ b.ToTable("core_position", (string)null);
+ });
+
+ modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreSetting", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Key");
+
+ b.ToTable("core_settings", (string)null);
+ });
+
+ modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreTrade", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("varchar(10)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IbkrOrderId")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("Module")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Notes")
+ .HasColumnType("longtext");
+
+ b.Property("Price")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Quantity")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("SignalId")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Status")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("TotalValue")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("TradedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Module");
+
+ b.HasIndex("SignalId");
+
+ b.HasIndex("Symbol");
+
+ b.ToTable("core_trade_history", (string)null);
+ });
+
+ modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreWorkerLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("FinishedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Message")
+ .HasColumnType("longtext");
+
+ b.Property("Module")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("StartedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("WorkerName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("WorkerName", "StartedAt");
+
+ b.ToTable("core_worker_log", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.cs
new file mode 100644
index 0000000..f12ce41
--- /dev/null
+++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/20260730164359_AddAnalysisFoundation.cs
@@ -0,0 +1,130 @@
+using System;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IBKRTrader.Core.Persistence.Ef.Migrations
+{
+ ///
+ public partial class AddAnalysisFoundation : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "SignalId",
+ table: "core_trade_history",
+ type: "varchar(64)",
+ maxLength: 64,
+ nullable: true)
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "core_decision_journal",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false)
+ .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
+ Timestamp = table.Column(type: "datetime(6)", nullable: false),
+ SignalId = table.Column(type: "varchar(64)", maxLength: 64, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Module = table.Column(type: "varchar(50)", maxLength: 50, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Symbol = table.Column(type: "varchar(20)", maxLength: 20, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Side = table.Column(type: "varchar(10)", maxLength: 10, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ SignalPrice = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
+ Decision = table.Column(type: "varchar(20)", maxLength: 20, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Reason = table.Column(type: "varchar(40)", maxLength: 40, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ ContextJson = table.Column(type: "text", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Message = table.Column(type: "text", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_core_decision_journal", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "core_order_events",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false)
+ .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
+ Timestamp = table.Column(type: "datetime(6)", nullable: false),
+ SignalId = table.Column(type: "varchar(64)", maxLength: 64, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Module = table.Column(type: "varchar(50)", maxLength: 50, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Symbol = table.Column(type: "varchar(20)", maxLength: 20, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ EventType = table.Column(type: "varchar(20)", maxLength: 20, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Side = table.Column(type: "varchar(10)", maxLength: 10, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Price = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
+ Quantity = table.Column(type: "int", nullable: false),
+ OrderType = table.Column(type: "varchar(20)", maxLength: 20, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Response = table.Column(type: "text", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ DetailsJson = table.Column(type: "text", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_core_order_events", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_core_trade_history_SignalId",
+ table: "core_trade_history",
+ column: "SignalId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_core_decision_journal_Module_Timestamp",
+ table: "core_decision_journal",
+ columns: new[] { "Module", "Timestamp" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_core_decision_journal_SignalId",
+ table: "core_decision_journal",
+ column: "SignalId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_core_order_events_SignalId",
+ table: "core_order_events",
+ column: "SignalId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_core_order_events_Symbol_Timestamp",
+ table: "core_order_events",
+ columns: new[] { "Symbol", "Timestamp" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "core_decision_journal");
+
+ migrationBuilder.DropTable(
+ name: "core_order_events");
+
+ migrationBuilder.DropIndex(
+ name: "IX_core_trade_history_SignalId",
+ table: "core_trade_history");
+
+ migrationBuilder.DropColumn(
+ name: "SignalId",
+ table: "core_trade_history");
+ }
+ }
+}
diff --git a/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs b/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs
index a8def56..7f17d98 100644
--- a/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs
+++ b/src/IBKRTrader.Core/Persistence/Ef/Migrations/CoreDbContextModelSnapshot.cs
@@ -201,6 +201,133 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
b.ToTable("core_budget", (string)null);
});
+ modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreDecisionRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ContextJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Decision")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Module")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("Side")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("varchar(10)");
+
+ b.Property("SignalId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("SignalPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SignalId");
+
+ b.HasIndex("Module", "Timestamp");
+
+ b.ToTable("core_decision_journal", (string)null);
+ });
+
+ modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CoreOrderEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("DetailsJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Module")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("OrderType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Price")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Quantity")
+ .HasColumnType("int");
+
+ b.Property("Response")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Side")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("varchar(10)");
+
+ b.Property("SignalId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SignalId");
+
+ b.HasIndex("Symbol", "Timestamp");
+
+ b.ToTable("core_order_events", (string)null);
+ });
+
modelBuilder.Entity("IBKRTrader.Core.Persistence.Entities.CorePosition", b =>
{
b.Property("Module")
@@ -279,6 +406,10 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
+ b.Property("SignalId")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
b.Property("Status")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
@@ -299,6 +430,8 @@ namespace IBKRTrader.Core.Persistence.Ef.Migrations
b.HasIndex("Module");
+ b.HasIndex("SignalId");
+
b.HasIndex("Symbol");
b.ToTable("core_trade_history", (string)null);
diff --git a/src/IBKRTrader.Core/Persistence/Entities/AnalysisEntities.cs b/src/IBKRTrader.Core/Persistence/Entities/AnalysisEntities.cs
new file mode 100644
index 0000000..fe74315
--- /dev/null
+++ b/src/IBKRTrader.Core/Persistence/Entities/AnalysisEntities.cs
@@ -0,0 +1,100 @@
+namespace IBKRTrader.Core.Persistence.Entities;
+
+/// Ausgang einer Handelsentscheidung im Entscheidungsjournal.
+public enum TradeDecision
+{
+ Executed, // Aktion ausgeführt (Order platziert)
+ Rejected, // aktiv abgelehnt (Risiko-/Plausibilitätsregel)
+ Skipped, // bewusst übersprungen (z. B. Trading global aus, kein Kurs)
+ Failed // versucht, aber fehlgeschlagen (z. B. Order-Fehler)
+}
+
+///
+/// Strukturierter Grund einer Entscheidung (statt Freitext). Als STRING persistiert – neue Werte können
+/// gefahrlos ergänzt werden. Deckt die heutigen Verzweigungen im ExecutionService ab und lässt
+/// Raum für künftige Modul-/Risiko-Regeln. Vorbild: PolytraderSharp DecisionReason.
+///
+public enum DecisionReason
+{
+ None = 0,
+
+ // ----- Modus / Zustand -----
+ TradingDisabled, // globaler Hauptschalter aus
+ NoQuote, // kein Kurs für das Symbol verfügbar
+
+ // ----- Risiko / Sizing -----
+ RiskRejected, // Risikoprüfung abgelehnt (Grund im Message-/ContextJson)
+ PositionLimitReached, // Modul-/Positions-Limit erreicht
+ MaxSlippageExceeded, // Abweichung Limit ↔ Kurs zu groß
+ InsufficientFunds, // verfügbares Guthaben reicht nicht
+ DuplicateOrder, // bereits offene/gleiche Order
+
+ // ----- Ausführung -----
+ OrderPlaced, // Order erfolgreich platziert
+ OrderFailed // Broker/Order-Fehler beim Platzieren
+}
+
+/// Art eines Order-Lifecycle-Ereignisses (als String persistiert – erweiterbar).
+public enum OrderEventType
+{
+ Placed, // Order an den Broker gesendet, Ergebnis in Response
+ PlaceFailed, // Platzierung fehlgeschlagen
+ Filled, // vollständig ausgeführt
+ PartiallyFilled, // teilweise ausgeführt
+ Cancelled // storniert (Grund in DetailsJson)
+}
+
+///
+/// Eine Zeile im Entscheidungsjournal (core_decision_journal): JEDE Handelsentscheidung –
+/// ausgeführt, abgelehnt oder übersprungen – strukturiert und abfragbar. Grundlage für
+/// Supervisor-Analysen („warum (nicht) gehandelt?"). Siehe docs/konzepte/KONZEPT-Modul-Supervisor.md.
+///
+public class CoreDecisionRecord
+{
+ public long Id { get; set; }
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+
+ /// Korrelation: verbindet Signal → Entscheidung(en) → Order(s) → Trade.
+ public string SignalId { get; set; } = "";
+
+ public string Module { get; set; } = "";
+ public string Symbol { get; set; } = "";
+ public string Side { get; set; } = ""; // BUY / SELL
+ public decimal SignalPrice { get; set; }
+
+ public TradeDecision Decision { get; set; }
+ public DecisionReason Reason { get; set; }
+
+ /// Kompakte Kontext-Zahlen als JSON (Limitwerte, Budgets, berechnete Größen …).
+ public string ContextJson { get; set; } = "";
+
+ /// Menschlicher Begründungstext (wie bisher im Log).
+ public string Message { get; set; } = "";
+}
+
+///
+/// Ein Order-Lifecycle-Ereignis (core_order_events): Platzierungen, Broker-Antworten, Cancels – als
+/// Daten statt nur als Log. Zusammen mit dem Entscheidungsjournal ergibt das die rekonstruierbare Kette
+/// je Signal (Dossier).
+///
+public class CoreOrderEvent
+{
+ public long Id { get; set; }
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+
+ public string SignalId { get; set; } = "";
+ public string Module { get; set; } = "";
+ public string Symbol { get; set; } = "";
+
+ public OrderEventType EventType { get; set; }
+ public string Side { get; set; } = ""; // BUY / SELL
+ public decimal Price { get; set; }
+ public int Quantity { get; set; }
+ public string OrderType { get; set; } = ""; // Market / Limit
+
+ /// Broker-Antwort ("OK" oder Fehlertext) bzw. Ergebnis der Aktion.
+ public string Response { get; set; } = "";
+
+ /// Zusatzkontext als kompaktes JSON (z. B. Fill-Preis, Teilmenge, Grund).
+ public string DetailsJson { get; set; } = "";
+}
diff --git a/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs b/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs
index de7d8c7..a624e18 100644
--- a/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs
+++ b/src/IBKRTrader.Core/Persistence/Entities/CoreEntities.cs
@@ -21,6 +21,8 @@ public class CoreTrade
public decimal Price { get; set; }
public decimal TotalValue { get; set; }
public DateTime TradedAt { get; set; }
+ /// Korrelation zu Entscheidungsjournal/Order-Events (leer für Trades ohne Signal-Kette).
+ public string? SignalId { get; set; }
public string? IbkrOrderId { get; set; }
public string? Status { get; set; }
public string? Notes { get; set; }
diff --git a/src/IBKRTrader.Core/Persistence/IAnalysisJournals.cs b/src/IBKRTrader.Core/Persistence/IAnalysisJournals.cs
new file mode 100644
index 0000000..75b6b5e
--- /dev/null
+++ b/src/IBKRTrader.Core/Persistence/IAnalysisJournals.cs
@@ -0,0 +1,21 @@
+using System.Linq.Expressions;
+using IBKRTrader.Core.Persistence.Entities;
+
+namespace IBKRTrader.Core.Persistence;
+
+///
+/// Entscheidungsjournal (core_decision_journal). darf den Trading-Pfad NIEMALS
+/// brechen – Implementierungen fangen Persistenzfehler ab (Log statt Exception).
+///
+public interface IDecisionJournal
+{
+ void Write(CoreDecisionRecord record);
+ List Query(Expression> predicate, int limit = 1000);
+}
+
+/// Order-Lifecycle-Log (core_order_events). Gleiche Robustheits-Garantie wie das Journal.
+public interface IOrderEventLog
+{
+ void Write(CoreOrderEvent record);
+ List Query(Expression> predicate, int limit = 1000);
+}
diff --git a/src/IBKRTrader.Core/Settings/AppSettings.cs b/src/IBKRTrader.Core/Settings/AppSettings.cs
index 6912493..3112240 100644
--- a/src/IBKRTrader.Core/Settings/AppSettings.cs
+++ b/src/IBKRTrader.Core/Settings/AppSettings.cs
@@ -214,6 +214,53 @@ public class TradingSettings
public override string ToString() => $"{Mode} – {(TradingEnabled ? "aktiv" : "inaktiv")}";
}
+// ─── Accounting ──────────────────────────────────────────────────────────────
+
+[TypeConverter(typeof(ExpandableObjectConverter))]
+public class AccountingSettings
+{
+ [Category("Accounting")]
+ [DisplayName("Basiswährung")]
+ [Description("Kontobasiswährung für die Abrechnung (Standard: USD)")]
+ public string BaseCurrency { get; set; } = "USD";
+
+ [Category("Accounting")]
+ [DisplayName("Ingest-Intervall (Stunden)")]
+ [Description("Abstand der automatischen Kontoauszug-Abrufe (Live-Quelle; offline ungenutzt)")]
+ public int IngestIntervalHours { get; set; } = 6;
+
+ [Category("Accounting")]
+ [DisplayName("Flex Query-Id")]
+ [Description("IBKR Activity Flex Query-Id (Zielland – Live-Abruf; leer = offline)")]
+ public string FlexQueryId { get; set; } = "";
+
+ [Category("Accounting")]
+ [DisplayName("Flex-Token")]
+ [Description("IBKR Flex Web Service Token (Zielland; verschlüsselt ablegen, nicht im Klartext)")]
+ [PasswordPropertyText(true)]
+ public string FlexToken { get; set; } = "";
+
+ public override string ToString() => $"Basis {BaseCurrency}, alle {IngestIntervalHours} h";
+}
+
+// ─── Supervisor ──────────────────────────────────────────────────────────────
+
+[TypeConverter(typeof(ExpandableObjectConverter))]
+public class SupervisorSettings
+{
+ [Category("Supervisor")]
+ [DisplayName("Modell")]
+ [Description("OpenRouter-Modell für die Analyse (Standard: openrouter/auto)")]
+ public string Model { get; set; } = "openrouter/auto";
+
+ [Category("Supervisor")]
+ [DisplayName("Token-Budget/Analyse")]
+ [Description("Weiches Token-Budget je Analyse-Lauf (Hinweis; harte Grenze = max. Tool-Iterationen)")]
+ public int TokenBudgetPerRun { get; set; } = 60000;
+
+ public override string ToString() => Model;
+}
+
// ─── Root ────────────────────────────────────────────────────────────────────
public class AppSettings
@@ -252,4 +299,14 @@ public class AppSettings
[DisplayName("Trading")]
[Description("Handelsmodus und Risiko-Parameter")]
public TradingSettings Trading { get; set; } = new();
+
+ [Category("Accounting")]
+ [DisplayName("Accounting")]
+ [Description("Buchhaltung/Reporting (unabhängiger Kontoauszug-Ingest)")]
+ public AccountingSettings Accounting { get; set; } = new();
+
+ [Category("Supervisor")]
+ [DisplayName("Supervisor")]
+ [Description("KI-Analyse/Forensik (OpenRouter, read-only)")]
+ public SupervisorSettings Supervisor { get; set; } = new();
}
diff --git a/src/IBKRTrader.Core/Trading/ExecutionService.cs b/src/IBKRTrader.Core/Trading/ExecutionService.cs
index 6d7c7e4..89548bb 100644
--- a/src/IBKRTrader.Core/Trading/ExecutionService.cs
+++ b/src/IBKRTrader.Core/Trading/ExecutionService.cs
@@ -1,4 +1,7 @@
+using System.Text.Json;
using IBKRTrader.Core.Logging;
+using IBKRTrader.Core.Persistence;
+using IBKRTrader.Core.Persistence.Entities;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Trading;
@@ -6,42 +9,59 @@ namespace IBKRTrader.Core.Trading;
///
/// Führt Modul-Signale aus: globaler Schalter → Kurs → Konto → Risiko → Order → Buchung.
/// Kennt kein Modul – Module rufen nur mit ihrem Signal auf.
+///
+/// Schreibt an jeder Verzweigung strukturiert ins Entscheidungsjournal (core_decision_journal) und bei
+/// Order-Aktionen ins Order-Lifecycle-Log (core_order_events) und reicht die SignalId bis in die
+/// Trade-Historie durch – Grundlage für die Supervisor-Forensik. Journal-Fehler brechen den Handel nie.
///
public sealed class ExecutionService : IExecutionService
{
- private readonly IBrokerClient _broker;
- private readonly IRiskService _risk;
+ private readonly IBrokerClient _broker;
+ private readonly IRiskService _risk;
private readonly IPortfolioService _portfolio;
- private readonly SettingsService _settings;
- private readonly LoggingService _logger;
+ private readonly SettingsService _settings;
+ private readonly LoggingService _logger;
+ private readonly IDecisionJournal _journal;
+ private readonly IOrderEventLog _orderEvents;
public ExecutionService(
IBrokerClient broker,
IRiskService risk,
IPortfolioService portfolio,
SettingsService settings,
- LoggingService logger)
+ LoggingService logger,
+ IDecisionJournal journal,
+ IOrderEventLog orderEvents)
{
- _broker = broker;
- _risk = risk;
- _portfolio = portfolio;
- _settings = settings;
- _logger = logger;
+ _broker = broker;
+ _risk = risk;
+ _portfolio = portfolio;
+ _settings = settings;
+ _logger = logger;
+ _journal = journal;
+ _orderEvents = orderEvents;
}
public async Task ExecuteAsync(TradeSignal signal, CancellationToken ct = default)
{
var trading = _settings.Settings.Trading;
var module = signal.SourceModule;
+ var sideStr = signal.Side == TradeSide.Buy ? "BUY" : "SELL";
// 1. Globaler Hauptschalter
if (!trading.TradingEnabled)
+ {
+ Journal(signal, TradeDecision.Skipped, DecisionReason.TradingDisabled, "Trading global deaktiviert.");
return Log(module, ExecutionResult.Skip("Trading global deaktiviert."));
+ }
// 2. Kurs
var quote = await _broker.GetQuoteAsync(signal.Symbol, ct);
if (quote is null || quote.Last <= 0)
+ {
+ Journal(signal, TradeDecision.Skipped, DecisionReason.NoQuote, $"Kein Kurs für {signal.Symbol} verfügbar.");
return Log(module, ExecutionResult.Skip($"Kein Kurs für {signal.Symbol} verfügbar."));
+ }
// 3. Konto + 4. bestehende Exposure/Position
var account = await _broker.GetAccountStateAsync(ct);
@@ -62,7 +82,11 @@ public sealed class ExecutionService : IExecutionService
(decimal)trading.MaxSlippagePercent);
var decision = _risk.Evaluate(signal, context, riskParams);
if (!decision.Approved)
+ {
+ Journal(signal, TradeDecision.Rejected, DecisionReason.RiskRejected, decision.Reason,
+ new { price = quote.Last, netLiq = account.NetLiquidation, exposure, existingQty });
return Log(module, ExecutionResult.Skip(decision.Reason));
+ }
// 6. Order platzieren
var order = new OrderRequest
@@ -76,16 +100,62 @@ public sealed class ExecutionService : IExecutionService
var result = await _broker.PlaceOrderAsync(order, ct);
if (!result.Success)
+ {
+ OrderEvent(signal, OrderEventType.PlaceFailed, sideStr, order, quote.Last, result.Error ?? "Order fehlgeschlagen.");
+ Journal(signal, TradeDecision.Failed, DecisionReason.OrderFailed, result.Error ?? "Order fehlgeschlagen.");
return Log(module, ExecutionResult.Error(result.Error ?? "Order fehlgeschlagen.", result));
+ }
+
+ OrderEvent(signal, OrderEventType.Filled, sideStr, order, result.AvgFillPrice, result.OrderId ?? "OK",
+ new { filled = result.FilledQuantity, avgPrice = result.AvgFillPrice });
// 7. Buchung
await _portfolio.RecordFillAsync(
module, signal.Symbol, signal.Side,
- result.FilledQuantity, result.AvgFillPrice, result.OrderId, ct);
+ result.FilledQuantity, result.AvgFillPrice, result.OrderId, signal.SignalId, ct);
+
+ Journal(signal, TradeDecision.Executed, DecisionReason.OrderPlaced,
+ $"Ausgeführt: {sideStr} {result.FilledQuantity}x {signal.Symbol} @ {result.AvgFillPrice:F2}",
+ new { orderId = result.OrderId, filled = result.FilledQuantity, avgPrice = result.AvgFillPrice });
return Log(module, ExecutionResult.Execute(result));
}
+ // ─── Journal-/Order-Event-Helfer (fehlertolerant über die EF-Implementierung) ────────────────
+
+ private void Journal(TradeSignal signal, TradeDecision decision, DecisionReason reason,
+ string message, object? context = null) =>
+ _journal.Write(new CoreDecisionRecord
+ {
+ Timestamp = DateTime.UtcNow,
+ SignalId = signal.SignalId,
+ Module = signal.SourceModule,
+ Symbol = signal.Symbol,
+ Side = signal.Side == TradeSide.Buy ? "BUY" : "SELL",
+ SignalPrice = signal.LimitPrice ?? 0m,
+ Decision = decision,
+ Reason = reason,
+ ContextJson = context is null ? "" : JsonSerializer.Serialize(context),
+ Message = message
+ });
+
+ private void OrderEvent(TradeSignal signal, OrderEventType type, string side, OrderRequest order,
+ decimal price, string response, object? details = null) =>
+ _orderEvents.Write(new CoreOrderEvent
+ {
+ Timestamp = DateTime.UtcNow,
+ SignalId = signal.SignalId,
+ Module = signal.SourceModule,
+ Symbol = signal.Symbol,
+ EventType = type,
+ Side = side,
+ Price = price,
+ Quantity = order.Quantity,
+ OrderType = order.Type.ToString(),
+ Response = response,
+ DetailsJson = details is null ? "" : JsonSerializer.Serialize(details)
+ });
+
private ExecutionResult Log(string module, ExecutionResult result)
{
var text = $"[{result.Action}] {result.Reason}";
diff --git a/src/IBKRTrader.Core/Trading/IPortfolioService.cs b/src/IBKRTrader.Core/Trading/IPortfolioService.cs
index f1544c4..6ddc306 100644
--- a/src/IBKRTrader.Core/Trading/IPortfolioService.cs
+++ b/src/IBKRTrader.Core/Trading/IPortfolioService.cs
@@ -15,7 +15,8 @@ public interface IPortfolioService
/// Verbucht einen Fill: aktualisiert Position, Budget und Trade-Historie.
Task RecordFillAsync(
string module, string symbol, TradeSide side,
- int quantity, decimal price, string? orderId, CancellationToken ct = default);
+ int quantity, decimal price, string? orderId, string? signalId = null,
+ CancellationToken ct = default);
/// Alle offenen Positionen eines Moduls.
Task> GetPositionsAsync(string module, CancellationToken ct = default);
diff --git a/src/IBKRTrader.Core/Trading/PortfolioService.cs b/src/IBKRTrader.Core/Trading/PortfolioService.cs
index a132bd9..aec5983 100644
--- a/src/IBKRTrader.Core/Trading/PortfolioService.cs
+++ b/src/IBKRTrader.Core/Trading/PortfolioService.cs
@@ -54,12 +54,13 @@ public sealed class PortfolioService : IPortfolioService
public async Task RecordFillAsync(
string module, string symbol, TradeSide side,
- int quantity, decimal price, string? orderId, CancellationToken ct = default)
+ int quantity, decimal price, string? orderId, string? signalId = null,
+ CancellationToken ct = default)
{
if (quantity <= 0) return;
var action = side == TradeSide.Buy ? "BUY" : "SELL";
- await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId);
+ await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId, signalId: signalId);
await using (var db = await _dbf.CreateDbContextAsync(ct))
{
diff --git a/src/IBKRTrader.Core/Trading/TradeHistoryService.cs b/src/IBKRTrader.Core/Trading/TradeHistoryService.cs
index abd96fe..7fd1047 100644
--- a/src/IBKRTrader.Core/Trading/TradeHistoryService.cs
+++ b/src/IBKRTrader.Core/Trading/TradeHistoryService.cs
@@ -19,7 +19,8 @@ public class TradeHistoryService
public async Task RecordTradeAsync(
string module, string symbol, string action,
- decimal quantity, decimal price, string? ibkrOrderId = null, string? notes = null)
+ decimal quantity, decimal price, string? ibkrOrderId = null, string? notes = null,
+ string? signalId = null)
{
await using var db = await _dbf.CreateDbContextAsync();
db.TradeHistory.Add(new CoreTrade
@@ -31,6 +32,7 @@ public class TradeHistoryService
Price = price,
TotalValue = quantity * price,
TradedAt = DateTime.UtcNow,
+ SignalId = signalId,
IbkrOrderId = ibkrOrderId,
Status = "Executed",
Notes = notes,
diff --git a/src/IBKRTrader.Core/Trading/TradingModels.cs b/src/IBKRTrader.Core/Trading/TradingModels.cs
index 044fb86..7de0db6 100644
--- a/src/IBKRTrader.Core/Trading/TradingModels.cs
+++ b/src/IBKRTrader.Core/Trading/TradingModels.cs
@@ -15,6 +15,12 @@ public enum TradingMode { Paper, Live }
///
public sealed record TradeSignal
{
+ ///
+ /// Korrelations-ID der Signal-Kette (Signal → Entscheidung(en) → Order(s) → Trade). Standardmäßig
+ /// neu erzeugt; ein Modul kann eine eigene ID setzen, um mehrere Signale zu verknüpfen.
+ ///
+ public string SignalId { get; init; } = Guid.NewGuid().ToString("N");
+
/// Ticker-Symbol (z. B. "AAPL").
public required string Symbol { get; init; }
diff --git a/src/IBKRTrader.Modules.Accounting/AccountingModule.cs b/src/IBKRTrader.Modules.Accounting/AccountingModule.cs
new file mode 100644
index 0000000..34caf1a
--- /dev/null
+++ b/src/IBKRTrader.Modules.Accounting/AccountingModule.cs
@@ -0,0 +1,70 @@
+using IBKRTrader.Core.Configuration;
+using IBKRTrader.Core.DependencyInjection;
+using IBKRTrader.Core.Logging;
+using IBKRTrader.Core.Modularity;
+using IBKRTrader.Modules.Accounting.Persistence;
+using IBKRTrader.Modules.Accounting.Services;
+using IBKRTrader.Modules.Accounting.Ui;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace IBKRTrader.Modules.Accounting;
+
+///
+/// Modul „Accounting": von unserer Trading-DB UNABHÄNGIGE, buchhalterisch korrekte Erfassung aller
+/// Kontobewegungen (IBKR-Kontoauszug → append-only Ledger) mit Periodenabrechnung/BWA, FX (USD/EUR) und
+/// CSV/PDF-Export. Reines Ingest-/Reporting-Modul, KEIN Handel. Konzept:
+/// docs/konzepte/KONZEPT-Modul-Accounting.md.
+///
+/// Live-Abruf (IBKR Flex Query) liegt hinter Interfaces mit Null-Stubs → das Modul läuft offline und
+/// bucht dann korrekt nichts. Die konkrete Steuerschicht ist bewusst offen (neutraler Ledger gilt
+/// unabhängig davon).
+///
+public sealed class AccountingModule : IModule
+{
+ public string Name => "Accounting";
+ public string DbPrefix => "acc_";
+
+ public void RegisterServices(IServiceCollection services, IConfiguration configuration)
+ {
+ var conn = ServiceCollectionExtensions.EffectiveConnectionString(configuration["Database:MySqlConnectionString"]);
+ services.AddDbContextFactory(o => o.UseMySql(conn, DatabaseServerVersion.Value));
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
+
+ // Ingest-Quellen: Offline-Null-Stubs. Im Zielland werden die echten IBKR-Flex-Quellen registriert.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddHostedService(sp => sp.GetRequiredService());
+ }
+
+ public void RegisterUi(IModuleUiHost host, IServiceProvider services)
+ {
+ host.RegisterView(new ModuleView
+ {
+ Id = "accounting.main",
+ Title = "Accounting",
+ Group = Name,
+ Order = 400,
+ CreateForm = () => new AccountingMainForm(
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService())
+ });
+ }
+
+ // DB-Schema wird extern per `dotnet ef database update` angewendet (keine Laufzeit-Migration).
+ public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
diff --git a/src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj b/src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj
new file mode 100644
index 0000000..4c26bd9
--- /dev/null
+++ b/src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net10.0-windows
+ enable
+ enable
+
+ true
+ en
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>IBKRTrader.Tests
+
+
+
+
diff --git a/src/IBKRTrader.Modules.Accounting/Logic/AccountingClassifier.cs b/src/IBKRTrader.Modules.Accounting/Logic/AccountingClassifier.cs
new file mode 100644
index 0000000..cf70509
--- /dev/null
+++ b/src/IBKRTrader.Modules.Accounting/Logic/AccountingClassifier.cs
@@ -0,0 +1,88 @@
+using IBKRTrader.Modules.Accounting.Models;
+
+namespace IBKRTrader.Modules.Accounting.Logic;
+
+///
+/// Reine, seiteneffektfreie Klassifikation roher IBKR-Auszugszeilen → normalisierter Buchungssatz
+/// (Typ, Vorzeichen, Idempotenz-Schlüssel). Geldkritisch → vollständig unit-getestet. Enthält keine
+/// I/O; die Datenbeschaffung liegt in den Ingest-Services hinter Interfaces.
+///
+/// Vorzeichenkonvention : Cash-Wirkung aufs Konto (+ Zufluss / − Abfluss).
+/// Annahme (im Export dokumentiert): BUY kostet Brutto+Kommission, SELL bringt Brutto−Kommission.
+///
+public static class AccountingClassifier
+{
+ public static LedgerEntry ClassifyExecution(RawExecution e, long ingestBatchId)
+ {
+ bool isSell = string.Equals(e.Side?.Trim(), "SELL", StringComparison.OrdinalIgnoreCase);
+ LedgerEventType type = isSell ? LedgerEventType.TradeSell : LedgerEventType.TradeBuy;
+
+ decimal gross = Math.Abs(e.GrossBase);
+ decimal fee = Math.Abs(e.FeeBase);
+ decimal net = isSell ? gross - fee : -(gross + fee);
+
+ return new LedgerEntry
+ {
+ AccountId = e.AccountId,
+ EventType = type,
+ Timestamp = e.Timestamp,
+ Symbol = e.Symbol,
+ AssetClass = e.AssetClass,
+ Currency = e.Currency,
+ Side = isSell ? "SELL" : "BUY",
+ Quantity = e.Quantity,
+ PriceNative = e.PriceNative,
+ GrossBase = gross,
+ FeeBase = fee,
+ NetBase = net,
+ TransactionId = e.TradeId,
+ Source = "ibkr-flex",
+ IngestBatchId = ingestBatchId,
+ IdempotencyKey = $"TRD|{type}|{e.TradeId.Trim()}"
+ };
+ }
+
+ public static LedgerEntry ClassifyCashTransaction(RawCashTransaction t, long ingestBatchId)
+ {
+ LedgerEventType type = MapCashType(t.Type);
+ decimal amount = t.AmountBase; // signiert wie im Auszug
+ decimal gross = Math.Abs(amount);
+
+ // Ein-/Auszahlung: Richtung bestimmt das Vorzeichen (IBKR-Kategorie oft kombiniert "Deposits/Withdrawals").
+ if (type is LedgerEventType.Deposit or LedgerEventType.Withdrawal)
+ type = amount >= 0 ? LedgerEventType.Deposit : LedgerEventType.Withdrawal;
+
+ return new LedgerEntry
+ {
+ AccountId = t.AccountId,
+ EventType = type,
+ Timestamp = t.Timestamp,
+ Symbol = t.Symbol,
+ Currency = t.Currency,
+ GrossBase = gross,
+ FeeBase = type == LedgerEventType.Fee ? gross : 0m,
+ NetBase = amount,
+ TransactionId = t.TransactionId,
+ Source = "ibkr-flex",
+ IngestBatchId = ingestBatchId,
+ IdempotencyKey = $"CASH|{type}|{t.TransactionId.Trim()}"
+ };
+ }
+
+ /// Σ NetBase – die Buchhaltungs-Sicht des Kontosaldos (für den Balance-Anker-Abgleich).
+ public static decimal SumNet(IEnumerable entries) => entries.Sum(e => e.NetBase);
+
+ // ----- intern -----
+
+ internal static LedgerEventType MapCashType(string? ibkrType)
+ {
+ string t = (ibkrType ?? string.Empty).Trim().ToUpperInvariant();
+ if (t.Contains("WITHHOLDING") || t.Contains("QUELLENSTEUER")) return LedgerEventType.TaxWithholding;
+ if (t.Contains("DIVIDEND")) return LedgerEventType.Dividend;
+ if (t.Contains("INTEREST") || t.Contains("ZINS")) return LedgerEventType.Interest;
+ if (t.Contains("DEPOSIT") || t.Contains("EINZAHLUNG")) return LedgerEventType.Deposit;
+ if (t.Contains("WITHDRAWAL") || t.Contains("AUSZAHLUNG")) return LedgerEventType.Withdrawal;
+ if (t.Contains("FEE") || t.Contains("COMMISSION") || t.Contains("GEBÜHR")) return LedgerEventType.Fee;
+ return LedgerEventType.Other;
+ }
+}
diff --git a/src/IBKRTrader.Modules.Accounting/Logic/AccountingEngine.cs b/src/IBKRTrader.Modules.Accounting/Logic/AccountingEngine.cs
new file mode 100644
index 0000000..3f1a5d4
--- /dev/null
+++ b/src/IBKRTrader.Modules.Accounting/Logic/AccountingEngine.cs
@@ -0,0 +1,89 @@
+using IBKRTrader.Modules.Accounting.Models;
+
+namespace IBKRTrader.Modules.Accounting.Logic;
+
+///
+/// Neutrale Periodenabrechnung: aggregiert die Ledger-Sätze eines Zeitraums (× Account oder alle) zu
+/// einer prüfbaren Aufstellung — länderneutral, ohne steuerliche Einordnung (die käme getrennt in einer
+/// späteren Steuerschicht). Alle Beträge in Kontobasiswährung; die EUR-Umrechnung liegt separat im
+/// . Vorbild: PolytraderSharp AccountingEngine.
+///
+public sealed record PeriodStatement(
+ string? AccountId,
+ DateTime From,
+ DateTime To,
+ decimal OpeningBalance,
+ decimal ClosingBalance,
+ decimal Deposits,
+ decimal Withdrawals,
+ decimal TradeVolume,
+ decimal Dividends,
+ decimal Interest,
+ decimal Fees,
+ decimal TaxWithheld,
+ decimal NetTradingResult, // operatives Ergebnis (Cash-Basis, EXKL. Ein-/Auszahlungen)
+ int TradeCount,
+ int EntryCount)
+{
+ /// Invariante: Endsaldo − Anfangssaldo = Ergebnis + Einzahlungen − Auszahlungen.
+ public decimal BalanceChange => ClosingBalance - OpeningBalance;
+}
+
+public static class AccountingEngine
+{
+ private static bool IsCashflowType(LedgerEventType t) =>
+ t is LedgerEventType.Deposit or LedgerEventType.Withdrawal;
+
+ ///
+ /// Baut die Abrechnung für [, ].
+ /// enthält ALLE Ledger-Sätze des Scopes bis (für den Anfangssaldo werden die
+ /// Sätze vor kumuliert). Grenzen inklusive.
+ ///
+ public static PeriodStatement BuildStatement(
+ IEnumerable allUpToTo, DateTime from, DateTime to, string? accountId)
+ {
+ var list = allUpToTo.Where(e => e.Timestamp <= to).ToList();
+
+ decimal opening = list.Where(e => e.Timestamp < from).Sum(e => e.NetBase);
+ var period = list.Where(e => e.Timestamp >= from && e.Timestamp <= to).ToList();
+
+ decimal SumWhere(Func pred, Func sel) =>
+ period.Where(pred).Sum(sel);
+
+ decimal deposits = SumWhere(e => e.EventType == LedgerEventType.Deposit, e => e.GrossBase);
+ decimal withdrawals = SumWhere(e => e.EventType == LedgerEventType.Withdrawal, e => e.GrossBase);
+ decimal tradeVolume = SumWhere(e => e.EventType is LedgerEventType.TradeBuy or LedgerEventType.TradeSell, e => e.GrossBase);
+ decimal dividends = SumWhere(e => e.EventType == LedgerEventType.Dividend, e => e.NetBase);
+ decimal interest = SumWhere(e => e.EventType == LedgerEventType.Interest, e => e.NetBase);
+ decimal taxWithheld = SumWhere(e => e.EventType == LedgerEventType.TaxWithholding, e => e.GrossBase);
+ decimal fees = period.Sum(e => e.FeeBase);
+ decimal netTrading = period.Where(e => !IsCashflowType(e.EventType)).Sum(e => e.NetBase);
+ decimal closing = opening + period.Sum(e => e.NetBase);
+ int tradeCount = period.Count(e => e.EventType is LedgerEventType.TradeBuy or LedgerEventType.TradeSell);
+
+ return new PeriodStatement(accountId, from, to, opening, closing, deposits, withdrawals,
+ tradeVolume, dividends, interest, fees, taxWithheld, netTrading, tradeCount, period.Count);
+ }
+
+ ///
+ /// Zerlegt den Zeitraum in Kalendermonate und liefert je Monat eine Abrechnung (für den
+ /// Perioden-/Monatsvergleich). Anfangssaldo jedes Monats = Endsaldo des Vormonats.
+ ///
+ public static List BuildMonthlyBreakdown(
+ IEnumerable allUpToTo, DateTime from, DateTime to, string? accountId)
+ {
+ var list = allUpToTo.ToList();
+ var result = new List();
+ var monthStart = new DateTime(from.Year, from.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ while (monthStart <= to)
+ {
+ DateTime monthEnd = monthStart.AddMonths(1).AddTicks(-1);
+ DateTime windowFrom = monthStart < from ? from : monthStart;
+ DateTime windowTo = monthEnd > to ? to : monthEnd;
+ result.Add(BuildStatement(list, windowFrom, windowTo, accountId));
+ monthStart = monthStart.AddMonths(1);
+ }
+ return result;
+ }
+}
diff --git a/src/IBKRTrader.Modules.Accounting/Logic/CsvExporter.cs b/src/IBKRTrader.Modules.Accounting/Logic/CsvExporter.cs
new file mode 100644
index 0000000..77f343f
--- /dev/null
+++ b/src/IBKRTrader.Modules.Accounting/Logic/CsvExporter.cs
@@ -0,0 +1,72 @@
+using System.Globalization;
+using System.Text;
+using IBKRTrader.Modules.Accounting.Models;
+
+namespace IBKRTrader.Modules.Accounting.Logic;
+
+///
+/// Reiner CSV-Export: erzeugt maschinen-/prüfbare CSV-Strings aus Ledger und Abrechnung. Kulturinvariant
+/// (Punkt-Dezimal, ISO-Datum), RFC-4180-Quoting. Der Dateizugriff liegt in der UI; die Formatierung ist
+/// hier pur + testbar.
+///
+public static class CsvExporter
+{
+ private static string F(decimal d) => d.ToString("0.######", CultureInfo.InvariantCulture);
+ private static string T(DateTime d) => d.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
+
+ private static string Q(string? s)
+ {
+ s ??= string.Empty;
+ bool needsQuote = s.Contains(',') || s.Contains('"') || s.Contains('\n') || s.Contains('\r');
+ if (needsQuote) s = "\"" + s.Replace("\"", "\"\"") + "\"";
+ return s;
+ }
+
+ /// Vollständiger Ledger-Export (eine Zeile je Buchungssatz, inkl. Nachweisspalten).
+ public static string Ledger(IEnumerable entries)
+ {
+ var sb = new StringBuilder();
+ sb.Append("Timestamp,AccountId,EventType,Side,Symbol,AssetClass,Currency,Quantity,PriceNative,GrossBase,FeeBase,NetBase,TransactionId,Source,IdempotencyKey\n");
+ foreach (var e in entries.OrderBy(e => e.Timestamp))
+ {
+ sb.Append(T(e.Timestamp)).Append(',')
+ .Append(Q(e.AccountId)).Append(',')
+ .Append(e.EventType).Append(',')
+ .Append(Q(e.Side)).Append(',')
+ .Append(Q(e.Symbol)).Append(',')
+ .Append(Q(e.AssetClass)).Append(',')
+ .Append(Q(e.Currency)).Append(',')
+ .Append(F(e.Quantity)).Append(',')
+ .Append(F(e.PriceNative)).Append(',')
+ .Append(F(e.GrossBase)).Append(',')
+ .Append(F(e.FeeBase)).Append(',')
+ .Append(F(e.NetBase)).Append(',')
+ .Append(Q(e.TransactionId)).Append(',')
+ .Append(Q(e.Source)).Append(',')
+ .Append(Q(e.IdempotencyKey)).Append('\n');
+ }
+ return sb.ToString();
+ }
+
+ /// Aggregat-Export einer Abrechnung (Kennzahl,Wert) – prüfbare Zusammenfassung.
+ public static string Statement(PeriodStatement s, string currencyCode = "USD")
+ {
+ var sb = new StringBuilder();
+ sb.Append("Kennzahl,").Append(Q(currencyCode)).Append('\n');
+ void Row(string k, decimal v) => sb.Append(Q(k)).Append(',').Append(F(v)).Append('\n');
+ sb.Append(Q($"Zeitraum {T(s.From)} .. {T(s.To)}" + (s.AccountId is null ? " | alle Konten" : $" | Konto {s.AccountId}"))).Append(",\n");
+ Row("Anfangssaldo", s.OpeningBalance);
+ Row("Einzahlungen", s.Deposits);
+ Row("Auszahlungen", s.Withdrawals);
+ Row("Handelsvolumen", s.TradeVolume);
+ Row("Dividenden", s.Dividends);
+ Row("Zinsen", s.Interest);
+ Row("Fees", s.Fees);
+ Row("Quellensteuer", s.TaxWithheld);
+ Row("Netto-Handelsergebnis (Cash)", s.NetTradingResult);
+ Row("Endsaldo", s.ClosingBalance);
+ sb.Append(Q("Anzahl Trades")).Append(',').Append(s.TradeCount).Append('\n');
+ sb.Append(Q("Anzahl Buchungen")).Append(',').Append(s.EntryCount).Append('\n');
+ return sb.ToString();
+ }
+}
diff --git a/src/IBKRTrader.Modules.Accounting/Logic/FxConverter.cs b/src/IBKRTrader.Modules.Accounting/Logic/FxConverter.cs
new file mode 100644
index 0000000..ec582a2
--- /dev/null
+++ b/src/IBKRTrader.Modules.Accounting/Logic/FxConverter.cs
@@ -0,0 +1,39 @@
+using IBKRTrader.Modules.Accounting.Models;
+
+namespace IBKRTrader.Modules.Accounting.Logic;
+
+///
+/// Reine FX-Umrechnung der Basiswährung (USD) → EUR über amtliche Tageskurse (acc_fx_rates,
+/// EZB-Referenzkurs). „Nearest-on-or-before": zu einem Datum gilt der jüngste Kurs an oder vor diesem
+/// Tag (Wochenenden/Feiertage). Gibt es keinen passenden Kurs, ist der EUR-Wert nicht bestimmbar (null).
+///
+public sealed class FxConverter
+{
+ // aufsteigend nach Datum sortierte Kurse
+ private readonly List _rates;
+
+ public FxConverter(IEnumerable rates)
+ {
+ _rates = rates.OrderBy(r => r.Date.Date).ToList();
+ }
+
+ /// USD→EUR-Kurs, der an oder vor gültig war (null, wenn keiner existiert).
+ public decimal? UsdToEurOn(DateTime date)
+ {
+ decimal? rate = null;
+ var day = date.Date;
+ foreach (var r in _rates)
+ {
+ if (r.Date.Date <= day) rate = r.UsdToEur;
+ else break;
+ }
+ return rate;
+ }
+
+ /// Rechnet einen USD-Betrag zum Kurs des Datums in EUR um (null, wenn kein Kurs vorliegt).
+ public decimal? UsdToEur(decimal usdAmount, DateTime date)
+ {
+ var rate = UsdToEurOn(date);
+ return rate.HasValue ? decimal.Round(usdAmount * rate.Value, 2, MidpointRounding.AwayFromZero) : null;
+ }
+}
diff --git a/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs b/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs
new file mode 100644
index 0000000..5289444
--- /dev/null
+++ b/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs
@@ -0,0 +1,162 @@
+using System.Security.Cryptography;
+using System.Text;
+using IBKRTrader.Modules.Accounting.Models;
+using MigraDoc.DocumentObjectModel;
+using MigraDoc.DocumentObjectModel.Tables;
+using MigraDoc.Rendering;
+
+namespace IBKRTrader.Modules.Accounting.Logic;
+
+///
+/// PDF-Export der neutralen Abrechnung via PDFsharp/MigraDoc (MIT, keine Umsatzschwelle). Kopf +
+/// Aggregat-Tabelle + Monatsvergleich + Transaktionsliste + Methodik-/Nachweis-Seite. Länderneutral
+/// (KEINE steuerliche Einordnung). Beträge in der übergebenen Anzeige-Währung; die Umrechnung erfolgt
+/// außerhalb (Services), hier nur der Faktor.
+///
+public static class PdfExporter
+{
+ public static byte[] Render(
+ PeriodStatement statement,
+ IReadOnlyList monthly,
+ IReadOnlyList periodEntries,
+ string currencyCode, decimal currencyFactor, string currencyNote)
+ {
+ decimal V(decimal baseAmount) => Math.Round(baseAmount * currencyFactor, 2, MidpointRounding.AwayFromZero);
+ string M(decimal baseAmount) => V(baseAmount).ToString("N2") + " " + currencyCode;
+
+ var doc = new Document();
+ doc.Info.Title = "Buchhalterische Abrechnung";
+ var style = doc.Styles["Normal"]!;
+ style.Font.Name = "Segoe UI";
+ style.Font.Size = 9;
+
+ var section = doc.AddSection();
+ section.PageSetup.Orientation = MigraDoc.DocumentObjectModel.Orientation.Landscape;
+ section.PageSetup.LeftMargin = Unit.FromCentimeter(1.5);
+ section.PageSetup.RightMargin = Unit.FromCentimeter(1.5);
+
+ // ---- Kopf ----
+ var head = section.AddParagraph("Buchhalterische Abrechnung (neutral)");
+ head.Format.Font.Size = 16; head.Format.Font.Bold = true;
+ head.Format.SpaceAfter = Unit.FromMillimeter(2);
+
+ var meta = section.AddParagraph();
+ meta.Format.SpaceAfter = Unit.FromMillimeter(4);
+ meta.AddText($"Konto: {statement.AccountId ?? "alle Konten"}");
+ meta.AddLineBreak();
+ meta.AddText($"Zeitraum: {statement.From:yyyy-MM-dd} bis {statement.To:yyyy-MM-dd}");
+ meta.AddLineBreak();
+ meta.AddText($"Währung: {currencyCode} ({currencyNote})");
+ meta.AddLineBreak();
+ meta.AddText($"Erstellt: {DateTime.Now:yyyy-MM-dd HH:mm}");
+
+ // ---- Aggregat ----
+ AddSectionTitle(section, "Zusammenfassung");
+ var agg = NewTable(section, new[] { 8.0, 6.0 });
+ AddKeyValue(agg, "Anfangssaldo", M(statement.OpeningBalance));
+ AddKeyValue(agg, "Einzahlungen", M(statement.Deposits));
+ AddKeyValue(agg, "Auszahlungen", M(statement.Withdrawals));
+ AddKeyValue(agg, "Handelsvolumen", M(statement.TradeVolume));
+ AddKeyValue(agg, "Dividenden", M(statement.Dividends));
+ AddKeyValue(agg, "Zinsen", M(statement.Interest));
+ AddKeyValue(agg, "Fees", M(statement.Fees));
+ AddKeyValue(agg, "Quellensteuer", M(statement.TaxWithheld));
+ AddKeyValue(agg, "Netto-Handelsergebnis (Cash-Basis)", M(statement.NetTradingResult));
+ AddKeyValue(agg, "Endsaldo", M(statement.ClosingBalance));
+ AddKeyValue(agg, "Anzahl Trades", statement.TradeCount.ToString());
+ AddKeyValue(agg, "Anzahl Buchungen", statement.EntryCount.ToString());
+
+ // ---- Monatsvergleich ----
+ if (monthly.Count > 1)
+ {
+ AddSectionTitle(section, "Monatsvergleich");
+ var mt = NewTable(section, new[] { 3.0, 3.5, 3.5, 3.5, 3.5, 3.5, 3.5, 3.0 });
+ HeaderRow(mt, "Monat", "Anfang", "Einz.", "Ausz.", "Volumen", "Fees", "Ergebnis", "Endsaldo");
+ foreach (var m in monthly)
+ DataRow(mt, m.From.ToString("yyyy-MM"), M(m.OpeningBalance), M(m.Deposits), M(m.Withdrawals),
+ M(m.TradeVolume), M(m.Fees), M(m.NetTradingResult), M(m.ClosingBalance));
+ }
+
+ // ---- Transaktionsliste ----
+ AddSectionTitle(section, $"Transaktionen ({periodEntries.Count})");
+ var lt = NewTable(section, new[] { 3.5, 3.0, 2.0, 3.0, 2.5, 2.5, 2.5, 4.0 });
+ HeaderRow(lt, "Zeit (UTC)", "Typ", "Side", "Symbol", "Menge", "Preis", "Netto", "Transaktion");
+ foreach (var e in periodEntries.OrderBy(e => e.Timestamp))
+ DataRow(lt, e.Timestamp.ToString("yyyy-MM-dd HH:mm"), e.EventType.ToString(), e.Side,
+ Trim(e.Symbol, 12), e.Quantity.ToString("0.###"), e.PriceNative.ToString("0.###"),
+ M(e.NetBase), Trim(e.TransactionId, 22));
+
+ // ---- Methodik / Nachweis ----
+ AddSectionTitle(section, "Methodik & Nachweis");
+ var method = section.AddParagraph();
+ method.Format.Font.Size = 8;
+ method.AddText("• Buchungsgrundlage sind ausschließlich unabhängige IBKR-Kontoauszüge (Activity Flex Query), nicht unsere Trading-DB; append-only.");
+ method.AddLineBreak();
+ method.AddText("• Netto-Handelsergebnis ist Cash-Basis (Erlöse − Kosten − Fees) und schließt Ein-/Auszahlungen aus.");
+ method.AddLineBreak();
+ method.AddText($"• Währungsumrechnung: {currencyNote}");
+ method.AddLineBreak();
+ method.AddText("• Dies ist eine neutrale, prüfbare Aufstellung und KEINE Steuerberatung. Eine steuerliche Einordnung erfolgt getrennt.");
+ method.AddLineBreak();
+ method.AddText($"• Daten-Hash (SHA-256 über Ledger+Aggregat): {DataHash(statement, periodEntries)}");
+
+ var renderer = new PdfDocumentRenderer { Document = doc };
+ renderer.RenderDocument();
+ using var ms = new MemoryStream();
+ renderer.PdfDocument.Save(ms, false);
+ return ms.ToArray();
+ }
+
+ // ---- MigraDoc-Helfer ----
+
+ private static void AddSectionTitle(Section s, string text)
+ {
+ var p = s.AddParagraph(text);
+ p.Format.Font.Size = 12; p.Format.Font.Bold = true;
+ p.Format.SpaceBefore = Unit.FromMillimeter(4); p.Format.SpaceAfter = Unit.FromMillimeter(1);
+ }
+
+ private static Table NewTable(Section s, double[] widthsCm)
+ {
+ var t = s.AddTable();
+ t.Borders.Width = 0.25; t.Borders.Color = Colors.LightGray;
+ foreach (var w in widthsCm) t.AddColumn(Unit.FromCentimeter(w));
+ return t;
+ }
+
+ private static void AddKeyValue(Table t, string key, string value)
+ {
+ var r = t.AddRow();
+ r.Cells[0].AddParagraph(key);
+ var vp = r.Cells[1].AddParagraph(value);
+ vp.Format.Alignment = ParagraphAlignment.Right;
+ }
+
+ private static void HeaderRow(Table t, params string[] cells)
+ {
+ var r = t.AddRow();
+ r.Shading.Color = Colors.WhiteSmoke;
+ for (int i = 0; i < cells.Length; i++)
+ {
+ var p = r.Cells[i].AddParagraph(cells[i]);
+ p.Format.Font.Bold = true;
+ }
+ }
+
+ private static void DataRow(Table t, params string[] cells)
+ {
+ var r = t.AddRow();
+ for (int i = 0; i < cells.Length; i++)
+ r.Cells[i].AddParagraph(cells[i] ?? string.Empty);
+ }
+
+ private static string Trim(string s, int max) =>
+ string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s[..max] + "…");
+
+ private static string DataHash(PeriodStatement s, IReadOnlyList entries)
+ {
+ string material = CsvExporter.Statement(s) + CsvExporter.Ledger(entries);
+ byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(material));
+ return Convert.ToHexString(hash)[..16].ToLowerInvariant();
+ }
+}
diff --git a/src/IBKRTrader.Modules.Accounting/Migrations/20260730165418_InitialAccounting.Designer.cs b/src/IBKRTrader.Modules.Accounting/Migrations/20260730165418_InitialAccounting.Designer.cs
new file mode 100644
index 0000000..9abc12b
--- /dev/null
+++ b/src/IBKRTrader.Modules.Accounting/Migrations/20260730165418_InitialAccounting.Designer.cs
@@ -0,0 +1,236 @@
+//
+using System;
+using IBKRTrader.Modules.Accounting.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace IBKRTrader.Modules.Accounting.Migrations
+{
+ [DbContext(typeof(AccountingDbContext))]
+ [Migration("20260730165418_InitialAccounting")]
+ partial class InitialAccounting
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.13")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("IBKRTrader.Modules.Accounting.Models.FxRate", b =>
+ {
+ b.Property("Date")
+ .HasColumnType("date");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("UsdToEur")
+ .HasPrecision(18, 8)
+ .HasColumnType("decimal(18,8)");
+
+ b.HasKey("Date");
+
+ b.ToTable("acc_fx_rates", (string)null);
+ });
+
+ modelBuilder.Entity("IBKRTrader.Modules.Accounting.Models.IngestRun", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("AccountId")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("Backfill")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("BalanceAnchorBase")
+ .HasPrecision(28, 8)
+ .HasColumnType("decimal(28,8)");
+
+ b.Property("BalanceDeltaBase")
+ .HasPrecision(28, 8)
+ .HasColumnType("decimal(28,8)");
+
+ b.Property("DuplicateEntries")
+ .HasColumnType("int");
+
+ b.Property("FinishedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("FromTimestamp")
+ .HasColumnType("datetime(6)");
+
+ b.Property("LedgerNetBase")
+ .HasPrecision(28, 8)
+ .HasColumnType("decimal(28,8)");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("NewEntries")
+ .HasColumnType("int");
+
+ b.Property("StartedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property