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