diff --git a/NuGet.config b/NuGet.config
index edd55f6..08e5058 100644
--- a/NuGet.config
+++ b/NuGet.config
@@ -18,6 +18,8 @@
+
+
diff --git a/Program.cs b/Program.cs
index 58b6367..dd10d8e 100644
--- a/Program.cs
+++ b/Program.cs
@@ -10,6 +10,7 @@ using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Security;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
+using IBKRTrader.Core.Trading.Ibkr;
using IBKRTrader.Core.Workers;
using IBKRTrader.Core.Workers.BuiltIn;
using IBKRTrader.Modules.Accounting;
@@ -123,8 +124,11 @@ internal static class Program
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- // Sicherer Standard-Broker: handelt nicht, bis der echte IBKR-Adapter verifiziert ist.
- services.AddSingleton();
+ // Echter TWS-Broker nur, wenn ausdrücklich aktiviert – sonst der NullBroker, der nie handelt.
+ if (settingsService.Settings.IBKR.UseTwsApi)
+ services.AddSingleton();
+ else
+ services.AddSingleton();
// Core-Worker/Services
services.AddSingleton();
diff --git a/README.md b/README.md
index ad3a48b..e219f33 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,8 @@ tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory)
- **Module** über `IModule` (RegisterServices/RegisterUi/Start/Stop); UI über `IModuleUiHost`/`ModuleView`.
- **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).
+ Broker hinter `IBrokerClient`: `NullBrokerClient` (Default, handelt nie) oder `IbkrBrokerClient`
+ über die TWS API – aktivierbar mit `IBKR.UseTwsApi`.
- **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).
@@ -58,11 +59,13 @@ powershell -File scripts/provision-db.ps1
- 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).
+- **IBKR-Broker über die TWS API / IB Gateway** ist implementiert (Paper-Konto steht, Verbindung
+ verifiziert) – Design und offene Punkte: [docs/IBKR-Integration.md](docs/IBKR-Integration.md),
+ TWS-Einstellungen: [docs/TWS-Setup-Checkliste.md](docs/TWS-Setup-Checkliste.md).
- **Sicherheit:** DB-Passwort rotieren (liegt in der Git-Historie, Commit `ebeb035`).
## Sicherheitshinweis
-Automatisierter Handel ist riskant. Standardmäßig handelt die App **nicht** (`NullBrokerClient` +
-globales `TradingEnabled=false`). Echter Handel erst nach bewusster Freigabe und Verifikation gegen
-den Paper-Account.
+Automatisierter Handel ist riskant. Standardmäßig handelt die App **nicht**: der Broker-Adapter ist
+über `IBKR.UseTwsApi` abgeschaltet, und selbst mit aktivem Adapter platziert der `ExecutionService`
+ohne globales `TradingEnabled=true` keine Order. Beide Schalter sind bewusst getrennt. Echter Handel
+erst nach Verifikation gegen den Paper-Account.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 4f247c3..8311e6c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -125,7 +125,10 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin
- [x] Core-**Dashboard-View** (Gesamtüberblick: Trading-Modus, aggregierte Kennzahlen, geladene Module) + Icon
- [x] `DashboardService` (aggregiert Positionen/Exposure/Trades via EF) + **2 InMemory-Tests** → 58/58 grün
- [x] Tests durchgehend portiert; `--smoke-ui` deckt alle Views ab
-- [ ] **Nächster Meilenstein (blockiert bis Paper-Zugang):** echter `IbkrBrokerClient` über die **TWS API / IB Gateway** (entschieden 2026-07-29). Setup + Design: siehe [IBKR-Integration.md](IBKR-Integration.md). Ports Paper 4002 / Live 4001, region-agnostisch (US↔IE), `NullBroker` bleibt Default bis verifiziert.
+- [x] **Echter `IbkrBrokerClient` über die TWS API** (2026-07-31): `src/IBKRTrader.Core/Trading/Ibkr/` – `IbkrMapping` (rein, unit-getestet), `IbkrConnection` (Socket + reqId-Korrelation), `IbkrBrokerClient` (`IBrokerClient`). Aktivierung über `IBKRSettings.UseTwsApi`; `NullBrokerClient` bleibt Default. Design und Grenzen: [IBKR-Integration.md](IBKR-Integration.md), Einstellungen: [TWS-Setup-Checkliste.md](TWS-Setup-Checkliste.md).
+- [x] Gegen Paper-Konto DUR371528 verifiziert: Verbindung, Konto (NetLiquidation 100.105,50 EUR), Kurse (AAPL/MSFT/NVDA, verzögert) und Fehlerpfade
+- [x] Orderpfad bis zur Broker-Annahme per **What-If-Order** verifiziert (Aktie + Option, keine Ausführung); **Optionsberechtigung im Paper-Konto bestätigt**
+- [ ] **Offen:** `PlaceOrderAsync` mit echter Ausführung verifizieren (Fill → Buchung); asynchrone Fill-Verfolgung (Orders ohne sofortige Ausführung)
- [ ] IBKR-Account-Credentials mit `EncryptedStringConverter` speichern
---
diff --git a/docs/IBKR-Integration.md b/docs/IBKR-Integration.md
index 1b5fe44..dbc1843 100644
--- a/docs/IBKR-Integration.md
+++ b/docs/IBKR-Integration.md
@@ -1,11 +1,13 @@
-# IBKR-Anbindung – Plan (TWS API via IB Gateway)
+# IBKR-Anbindung (TWS API via IB Gateway)
-**Entscheidung:** Der `IbkrBrokerClient` (ersetzt `NullBrokerClient`) nutzt die **TWS API** über das
-**IB Gateway** – native C#-Lib, robusteste Order-Ausführung, region-agnostisch.
+**Entscheidung:** Der `IbkrBrokerClient` (Alternative zum `NullBrokerClient`) nutzt die **TWS API**
+über das **IB Gateway** – native C#-Lib, robusteste Order-Ausführung, region-agnostisch.
-> Umsetzung ist **blockiert**, bis der Paper-Zugang + ein laufendes IB Gateway verfügbar sind
-> (echte Verifikation von Quotes/Konto/Orders nur gegen den Gateway möglich). Dieses Dokument hält
-> Setup und Design fest, damit die Umsetzung dann schnell und korrekt läuft.
+> **Stand 2026-07-31:** Der Adapter ist implementiert (`src/IBKRTrader.Core/Trading/Ibkr/`) und wird
+> über `IBKRSettings.UseTwsApi` aktiviert. Gegen das Paper-Konto **DUR371528** verifiziert sind
+> Verbindung, `GetAccountStateAsync` und `GetQuoteAsync` (verzögerte Kurse, siehe unten) sowie der
+> Fehlerpfad bei unbekanntem Symbol und nicht erreichbarer TWS.
+> **Noch nicht verifiziert: `PlaceOrderAsync`** – dafür ist eine echte Order auf dem Paper-Konto nötig.
## Regionen (US ↔ IE)
Gleiche API, gleiche Gateway-Software, **eine Codebasis**. Unterschied nur: Login/Entity
@@ -13,6 +15,8 @@ Gleiche API, gleiche Gateway-Software, **eine Codebasis**. Unterschied nur: Logi
Paper vs. Live = Konto + Port, kein Code-Unterschied.
## Betrieb (operativ)
+- **Setup auf neuem System:** alle nötigen TWS-/Gateway-Einstellungen Schritt für Schritt in der
+ [TWS-Setup-Checkliste](TWS-Setup-Checkliste.md).
- **IB Gateway** (schlank, statt TWS) muss laufen und eingeloggt sein.
- **Ports:** Paper **4002**, Live **4001** (Auswahl über `TradingSettings.Mode`).
→ passt exakt zu den bestehenden `IBKRSettings` (Host `127.0.0.1`, Port, `ClientId`).
@@ -20,38 +24,104 @@ Paper vs. Live = Konto + Port, kein Code-Unterschied.
Server-Betrieb **IBC/IBController** (Auto-Login + geplanter Neustart).
## Bibliothek
-- Empfohlen: offizielle C#-API als NuGet-Mirror **`IB.TWS.CSharpApi`** (keine Abhängigkeiten),
- ggf. der vereinfachte Wrapper **`IB.CSharpApiClient`** (mathpaquette) für weniger Callback-Boilerplate.
-- NuGet-Allowlist (`NuGet.config`) muss um das Paket ergänzt werden.
+Offizielle C#-API als NuGet-Mirror **`IB.TWS.CSharpApi` 9.76.1**, referenziert im Core und in der
+NuGet-Allowlist (`NuGet.config`) freigegeben. Das Paket zielt auf .NET Framework, ist aber reiner
+managed Code und läuft auf net10 – `NU1701` ist in der `.csproj` bewusst unterdrückt.
-## Design `IbkrBrokerClient : IBrokerClient`
-Die TWS API ist **callback-basiert** (`EClientSocket` sendet, `EWrapper` empfängt Events). Wir kapseln
-das hinter unserem bestehenden async-Seam `IBrokerClient`:
-- **Verbindung:** `EClientSocket.eConnect(host, port, clientId)` + Reader-Thread; Reconnect-Logik.
-- **Korrelation:** je Request eine `reqId`; Antworten via `TaskCompletionSource` in einem
- `ConcurrentDictionary` auflösen. Timeout je Request.
-- **`GetQuoteAsync(symbol)`** → Kontrakt (`reqContractDetails`/`reqMatchingSymbols`) → Snapshot-Kurs
- (`reqMktData` snapshot bzw. `reqTickByTickData`) → `Quote(Last, Bid, Ask)`.
-- **`GetAccountStateAsync()`** → `reqAccountSummary` (NetLiquidation, AvailableFunds).
-- **`PlaceOrderAsync(OrderRequest)`** → `Order` bauen (MKT/LMT, Menge, Side) → `placeOrder(orderId, contract, order)`;
- Ergebnis über `orderStatus`/`openOrder`/`execDetails`-Callbacks einsammeln → `OrderResult`.
- `nextValidId` liefert die Order-IDs.
-- **Sicherheit bleibt:** `NullBrokerClient` bleibt Default; `IbkrBrokerClient` wird erst registriert
- (via Config-Schalter), wenn verifiziert. Handel zusätzlich weiter durch `TradingEnabled`-Gate geschützt.
+## Umsetzung (`src/IBKRTrader.Core/Trading/Ibkr/`)
+Die TWS API ist **callback-basiert** (`EClientSocket` sendet, `EWrapper` empfängt Events). Das ist
+hinter dem bestehenden async-Seam `IBrokerClient` gekapselt, aufgeteilt in drei Dateien:
+
+| Datei | Aufgabe |
+|---|---|
+| `IbkrMapping.cs` | Reine Abbildung Core ↔ TWS (Kontrakt, Order, Kurs, Port-/Statusregeln) – **unit-getestet** |
+| `IbkrConnection.cs` | Socket-Lebenszyklus, Reader-Thread, reqId-Korrelation über `TaskCompletionSource` |
+| `IbkrBrokerClient.cs` | Implementiert `IBrokerClient`, übersetzt Fehler in leere Ergebnisse |
+
+**Ablauf je Methode**
+- **`GetQuoteAsync`** → `reqContractDetails` (Ergebnis wird gecacht) → `reqMktData` als Snapshot →
+ `Quote`. Preis-Reihenfolge: letzter Handelspreis, sonst Bid/Ask-Mitte, sonst Schlusskurs.
+- **`GetAccountStateAsync`** → `reqAccountSummary` (NetLiquidation, AvailableFunds).
+- **`PlaceOrderAsync`** → Kontrakt auflösen → `placeOrder`; das Ergebnis kommt über `orderStatus`,
+ die Order-IDs liefert `nextValidId`.
+
+**Bewusste Entscheidungen**
+- **Träges Verbinden mit Wiederholung statt Verbindungsaufbau beim Start.** TWS ist nach einem
+ Neustart minutenlang nicht bereit (erst abgelehnte Verbindungen, dann abgebrochene Handshakes);
+ ein einmaliger Versuch beim Programmstart würde das nicht überleben.
+- **Port-Prüfung gegen den Handelsmodus.** Paper-Modus auf einem Live-Port (oder umgekehrt) wird
+ abgelehnt, der Broker bleibt dann inaktiv – sonst würde echtes Geld bewegt, wo ein Test erwartet wird.
+- **Fehler werden nie geworfen, sondern zu leeren Ergebnissen.** Konto 0 lässt die Risikoprüfung
+ jedes Signal ablehnen – die sichere Richtung bei nicht erreichbarem Broker.
+- **`MarketDataType` standardmäßig 4 (verzögert + Frozen).** Paper-Konten ohne Datenabo bekommen
+ sonst überhaupt keine Kurse, und der `ExecutionService` überspringt jedes Signal mit „kein Kurs".
+- **Zwei Sicherungen bleiben:** `UseTwsApi` entscheidet über den Adapter, `TradingEnabled` über den
+ Handel. Ohne den zweiten Schalter platziert der `ExecutionService` keine Order.
+
+### Bekannte Grenze: Orders ohne sofortige Ausführung
+`IBrokerClient.PlaceOrderAsync` ist synchron gedacht (Ausführung oder Fehlschlag). Eine Order, die
+innerhalb von `OrderTimeoutSeconds` keinen Endstatus erreicht – etwa eine Limit-Order im Orderbuch
+oder eine Market-Order außerhalb der Handelszeiten –, wird als Fehlschlag zurückgegeben, **kann bei
+IBKR aber weiterhin aktiv sein**. Die Meldung enthält deshalb Order-ID und letzten Status. Sauber
+lösen ließe sich das nur mit asynchroner Fill-Verfolgung (Order-Zustand persistieren, `orderStatus`
+und `execDetails` dauerhaft mitschreiben) – das ändert den Seam und ist eigene Arbeit.
## Marktdaten
+Das Paper-Konto hat **kein Marktdaten-Abo**. TWS meldet deshalb bei jeder Kursanfrage den Hinweis
+**10167** („nicht abonniert, es werden verzögerte Marktdaten angezeigt") und liefert danach ganz
+normal verzögerte Ticks. Das ist ein **Statushinweis, kein Fehler** – wird er als Fehler behandelt,
+bricht die Kursanfrage ab, bevor die Ticks eintreffen, und jedes Symbol liefert „kein Kurs".
+`IbkrMapping.IsInformational` deckt den Code deshalb ausdrücklich ab (mit Test).
+
+Verifiziert am 2026-07-31 gegen DUR371528: AAPL 302,94 / MSFT 476,88 / NVDA 209,81 (verzögert).
+
+## Optionen (Berechtigung verifiziert 2026-08-04)
+
+Das Paper-Konto **DUR371528 ist für den Optionshandel freigeschaltet**. Geprüft ohne jede Ausführung
+über eine **What-If-Order** (`Order.WhatIf = true`): IBKR rechnet dabei Margin und Gebühren durch die
+komplette Prüfkette – inklusive Handelsberechtigung – und verwirft die Order anschließend.
+
+| Prüfung | Ergebnis |
+|---|---|
+| `reqSecDefOptParams` AAPL | 24 Verfallstermine, 127 Strikes (5–600), Multiplier 100, TradingClass AAPL |
+| Börsen | SMART, CBOE, ISE, EMERALD, NASDAQBX, PHLX, AMEX, MEMX, PSE |
+| Kontrakt `AAPL 20260812 C302.5` | aufgelöst, ConId 906106141, LocalSymbol `AAPL 260812C00302500` |
+| What-If BUY 1 Kontrakt | **angenommen**, Init-Margin 0 → 589,52 |
+| What-If BUY 1 AAPL (Aktie) | **angenommen**, Init-Margin 0 → 87,61, Kommission 1,00 USD |
+
+Fehlte die Berechtigung, hätte IBKR die What-If-Order mit einem Berechtigungsfehler abgelehnt statt
+eine Margin zu liefern. Für **Realtime**-Optionskurse wäre zusätzlich ein OPRA-Abo nötig; ohne Abo
+kommen verzögerte Daten (siehe Marktdaten unten). Modul-Konzept:
+[konzepte/KONZEPT-Modul-OptionsWheel.md](konzepte/KONZEPT-Modul-OptionsWheel.md).
+
+> **What-If als Testwerkzeug:** Damit lässt sich der gesamte Orderpfad bis zur Broker-Annahme prüfen,
+> ohne eine Position zu eröffnen. Der Adapter nutzt es nicht produktiv – für Vorabprüfungen
+> (Margin-Deckung vor einer echten Order) wäre es aber ein naheliegender Ausbau.
+### Historie: welcher Gateway?
Aktuell laufen die Marktdaten-Worker über die **Client Portal Web API** (`IBKRGatewayService`).
-Optionen (später entscheiden):
+Später zu entscheiden:
- Broker (Quotes/Konto/Orders) → TWS API; Marktdaten-Historie **vorerst auf CP Web API belassen**, oder
- Historie ebenfalls auf TWS (`reqHistoricalData`) migrieren → nur noch ein Gateway.
-## Testbarkeit
-- Reine Logik (Order-/Kontrakt-Mapping, Response-Parsing) → Unit-Tests möglich.
-- Verbindung/Quotes/Orders → **manuell gegen das Paper-Gateway**, sobald verfügbar.
+## Konfiguration (`settings.json`, Abschnitt `IBKR`)
-## To-do bei Gateway-Zugang
-1. `IB.TWS.CSharpApi` (+ NuGet-Allowlist) hinzufügen.
-2. `IbkrBrokerClient` implementieren (Design oben), hinter Config-Schalter registrieren.
-3. `TradingSettings.Mode` → Port 4002/4001; `IBKRSettings.ClientId` nutzen.
-4. Gegen Paper verifizieren: Verbindung → Quote → Konto → Test-Order (Paper) → Buchung.
-5. IBC für Auto-Login/Neustart einrichten.
+| Schlüssel | Default | Bedeutung |
+|---|---|---|
+| `UseTwsApi` | `false` | Aktiviert den echten Adapter; sonst `NullBrokerClient` |
+| `Host` / `Port` / `ClientId` | `127.0.0.1` / `4002` / `1` | Verbindung; Port muss zum Handelsmodus passen |
+| `MarketDataType` | `4` | 1 Realtime, 2 Frozen, 3 verzögert, 4 verzögert+Frozen |
+| `ConnectTimeoutSeconds` | `15` | Socket-Verbindung und Handshake |
+| `RequestTimeoutSeconds` | `15` | Kurs-, Kontrakt- und Kontoantworten |
+| `OrderTimeoutSeconds` | `60` | Wartezeit auf den Endstatus einer Order |
+
+## Testbarkeit
+- `IbkrMapping` ist vollständig unit-getestet (`tests/.../IbkrMappingTests.cs`): Port-/Modus-Regeln,
+ Kontrakt- und Order-Aufbau, Kursableitung, Tick- und Statusklassifizierung.
+- Verbindung, Kurse und Orders bleiben **manuelle Verifikation gegen das Paper-Gateway** – siehe
+ [TWS-Setup-Checkliste](TWS-Setup-Checkliste.md), Abschnitt Verifikation.
+
+## Offen
+1. `PlaceOrderAsync` gegen das Paper-Konto verifizieren (Order → Fill → Buchung).
+2. IBC für Auto-Login/Neustart einrichten (Server-Betrieb).
+3. Asynchrone Fill-Verfolgung, siehe „Bekannte Grenze" oben.
+4. Entscheiden, ob die Marktdaten-Historie von der CP Web API auf `reqHistoricalData` wandert.
diff --git a/docs/TWS-Setup-Checkliste.md b/docs/TWS-Setup-Checkliste.md
new file mode 100644
index 0000000..a7f6302
--- /dev/null
+++ b/docs/TWS-Setup-Checkliste.md
@@ -0,0 +1,119 @@
+# TWS / IB-Gateway – Setup-Checkliste (Neuinstallation)
+
+Ziel: Auf einem frischen System alle Einstellungen so setzen, dass IBKRTrader **ohne manuelle
+Eingriffe** (Popups, Bestätigungen, nächtliche Abbrüche) gegen die TWS API arbeiten kann.
+Stand: 2026-07-31. **Verifiziert**: TWS Paper (Konto DUR371528), Verbindung über
+`127.0.0.1:4002` erfolgreich, ServerVersion 151, Kontodaten + Positionen werden geliefert.
+
+## 1. Voraussetzungen (einmalig, im IBKR Client Portal)
+
+- [ ] Paper-Trading-Konto aktiv (Konto-ID beginnt mit `DU…`).
+- [ ] Paper-**Benutzername** und Passwort bekannt: Client Portal → **Einstellungen →
+ Konto-Einstellungen → Paper-Trading-Konto**. (Login erfolgt mit dem Paper-Username,
+ nicht mit der Konto-ID!)
+- [ ] Optional: **Marktdaten-Freigabe** Live → Paper aktivieren (sonst nur verzögerte Kurse):
+ ebenfalls unter Einstellungen → Paper-Trading-Konto.
+- [ ] Software installieren: **TWS** (Desktop/Entwicklung) oder **IB Gateway** (Server, schlanker).
+ Version „Stable“ genügt.
+
+## 2. Login
+
+- [ ] Beim Start Modus **„Paper Trading“** wählen (nicht „Live Trading“).
+- [ ] Mit Paper-Benutzername anmelden. Paper verlangt **kein 2FA** → automatisierbar.
+
+## 3. API-Einstellungen
+
+TWS: **File → Global Configuration → API → Einstellungen** (Gateway: Configure → Settings → API):
+
+- [ ] **„ActiveX- und Socket-Clients aktivieren“** ☑
+- [ ] **„Schreibgeschützte API“** ☐ (deaktiviert lassen, sonst sind keine Orders möglich)
+- [ ] **Socket-Port:** Paper **4002**, Live **4001** (TWS-Default wäre 7497/7496 → umstellen,
+ damit es zur App-Konfiguration passt)
+- [ ] **„Nur Verbindungen vom lokalen Host zulassen“** ☑ (App läuft auf derselben Maschine)
+- [ ] **„Vertrauenswürdige IPs“: `127.0.0.1` eintragen** (Erstellen → IP → Übernehmen).
+ **Kritisch für den Automatikbetrieb:** Ohne Eintrag verlangt TWS für jede API-Verbindung
+ eine Popup-Bestätigung; ein übersehenes Popup = tote Verbindung.
+- [ ] Optional fürs Debugging: „API-Nachrichten-Logdatei erstellen“ ☑
+- [ ] **Master-API-Client-ID:** leer lassen (App nutzt `IBKRSettings.ClientId`, Default 1;
+ Test-Tools nutzen andere IDs, z. B. 99 — jede parallele Verbindung braucht eine eigene ID)
+
+## 4. Vorsichtseinstellungen (API → Vorsichtseinstellungen / Precautions)
+
+- [ ] **„Bypass Order Precautions for API Orders“** ☑ — sonst hält TWS automatische Orders mit
+ Bestätigungsdialogen an (Preis-/Größen-Warnungen). Die Risiko-Limits übernimmt stattdessen
+ unsere App (`TradingSettings`: MaxTradePercent, MaxSlippagePercent, …).
+
+## 5. Dauerbetrieb (kein nächtlicher Ausfall)
+
+- [ ] **Sperren und schließen** (Lock and Exit): **„Automatischer Neustart“** statt „Beenden“
+ wählen; Neustartzeit außerhalb der Handelszeiten legen (z. B. 03:00).
+ → TWS läuft dann bis zu ~1 Woche ohne neuen Login durch.
+- [ ] Wöchentlicher Neuanmelde-Zwang bleibt: beim Paper-Konto ohne 2FA unkritisch,
+ fürs Live-Konto später **IB Key mit „Weekly Re-Authentication“**.
+- [ ] Server-Zielbild (siehe [IBKR-Integration.md](IBKR-Integration.md)): **IB Gateway + IBC**
+ (IBController) für Auto-Login und geplante Neustarts, als Autostart/geplanter Task.
+
+## 6. App-Konfiguration (`settings.json`, lokal / gitignored)
+
+- [ ] `IBKR.Host` = `127.0.0.1`
+- [ ] `IBKR.Port` = **4002** (Paper) bzw. **4001** (Live)
+- [ ] `IBKR.ClientId` = `1` (jede parallele API-Verbindung braucht eine eigene ID)
+- [ ] `IBKR.UseTwsApi` = `true` → echter Broker; `false` → `NullBrokerClient` (handelt nie)
+- [ ] `IBKR.MarketDataType` = `4` (verzögert + Frozen) – ohne Datenabo liefert `1` keine Kurse
+- [ ] `Trading.Mode` = `Paper`
+- [ ] `Trading.TradingEnabled` = `false`, bis die Verbindung verifiziert ist
+
+Port und Handelsmodus müssen zusammenpassen: Paper-Modus auf einem Live-Port (und umgekehrt) lehnt
+der Adapter ab und bleibt inaktiv, statt auf dem falschen Konto zu handeln.
+
+## 7. Verifikation
+
+- [ ] Port erreichbar? `Test-NetConnection 127.0.0.1 -Port 4002` → `TcpTestSucceeded: True`
+- [ ] API-Verbindung: Verbindungstest ausführen (Konto-ID, NetLiquidation, Positionen müssen
+ kommen). Hängt der Handshake > 10 s → Trusted IP fehlt (Punkt 3) oder Popup offen.
+
+Erfolgreiche Ausgabe sieht so aus (Referenz vom 2026-07-31):
+
+```
+VERBUNDEN. ServerVersion=151, Konten: DUR371528
+ [2104] Verbindung zum Marktdatenzentrum ist OK: usfarm, eufarm, …
+ [2106] Verbindung zum HMDS-Datenzentrum ist OK: ushmds, euhmds, …
+DUR371528 AccountType INDIVIDUAL
+DUR371528 NetLiquidation 100047.93 EUR
+DUR371528 TotalCashValue 100000.00 EUR
+```
+
+Die Meldungen 2104/2106/2158 sind **keine Fehler**, sondern Status-Infos („Datenzentrum OK”).
+Dasselbe gilt für **10167** („nicht abonniert, verzögerte Marktdaten”) – ohne Marktdaten-Abo ist das
+bei jeder Kursanfrage der Normalfall, die Kurse kommen danach trotzdem.
+
+## Troubleshooting
+
+- **TCP-Verbindung wird angenommen, aber der API-Handshake bleibt ohne Antwort** (Client hängt
+ beim Verbinden): TWS wartet intern auf die Bestätigung eines Verbindungs-Popups — auch wenn
+ keines (mehr) sichtbar ist. Passiert, wenn die Trusted IP fehlte, das Popup unbemerkt verfiel
+ oder der Konfigurationsdialog offen war (blockiert als modaler Dialog die Popups).
+ **Lösung (am 2026-07-31 so verifiziert):** Konfigurationsdialog mit OK schließen, Trusted IP
+ `127.0.0.1` prüfen (Punkt 3), dann **TWS komplett neu starten** — das räumt hängengebliebene,
+ unbeantwortete Verbindungsanfragen ab. Danach verbindet sich der Client ohne Rückfrage.
+ Wichtig: Die Trusted IP allein reichte **nicht**, solange TWS noch die alten Anfragen hielt —
+ der Neustart war zwingend.
+- **Nach dem TWS-Start kurz warten:** Direkt nach dem Login lehnt der Port die Verbindung noch ab
+ („Verbindung verweigert“), danach folgt eine Phase mit abgebrochenem Handshake
+ („Unable to read beyond the end of the stream“). Erst wenn TWS vollständig hochgefahren ist,
+ klappt der Login. Ein Client sollte deshalb **Reconnect mit Retry** machen statt einmalig zu
+ scheitern — relevant für `IbkrBrokerClient`.
+- **`ClientId` muss je Verbindung eindeutig sein.** Eine zweite Verbindung mit derselben ID
+ verdrängt die erste. App nutzt `IBKRSettings.ClientId` (Default 1), Diagnose-Tools eine andere.
+- Einstellungsänderungen im API-Dialog immer mit **Übernehmen/OK** abschließen; solange der
+ Dialog offen ist, gelten sie nicht.
+
+## Unterschiede Live-Betrieb (später)
+
+| Punkt | Paper | Live |
+|--------------|------------------------|-----------------------------------------|
+| Login-Modus | Paper Trading | Live Trading |
+| Port | 4002 | 4001 |
+| 2FA | nein | ja → IB Key, Weekly Re-Authentication |
+| Marktdaten | geteilt vom Live-Konto | eigene Abos |
+| App | `Trading.Mode=Paper` | `Trading.Mode=Live` + Port umstellen |
diff --git a/docs/konzepte/KONZEPT-Modul-OptionsWheel.md b/docs/konzepte/KONZEPT-Modul-OptionsWheel.md
new file mode 100644
index 0000000..fe86d9c
--- /dev/null
+++ b/docs/konzepte/KONZEPT-Modul-OptionsWheel.md
@@ -0,0 +1,241 @@
+# Konzept: Modul „OptionsWheel" (Covered Call / Cash-Secured Put)
+
+> Stand: 2026-08-03
+> Ziel: Auf einer kleinen Watchlist von Tickern, an deren langfristigen Erfolg wir glauben,
+> **systematisch Optionsprämien vereinnahmen** – Cash-Secured Put → (Zuteilung) → Aktienbestand →
+> Covered Call → (Abruf) → wieder Cash. Vollautomatisch, delta-basierte Strike-Wahl, aktives Rollen.
+>
+> **Festlegungen (2026-08-03, mit dem Betreiber abgestimmt):**
+> 1. Put-Seite ist **Cash-Secured Put**, nicht Covered Put (kein Leerverkauf der Aktie).
+> 2. **Vollautomatisch** von Anfang an – Sicherungen sind Schalter und Limits, keine Klick-Freigabe.
+> 3. Strike-Wahl **delta-basiert** (Zielband 0,15–0,30).
+> 4. Bei drohender Zuteilung wird **gerollt**, solange das per Netto-Kredit möglich ist.
+
+## 0. Leitprinzipien
+1. **Niemals nackt.** Ein Short Call ist nur zulässig mit 100 freien Aktien je Kontrakt, ein Short Put
+ nur mit reserviertem Cash über Strike × 100 je Kontrakt. Diese Deckungsprüfung gehört in den
+ **Core-`RiskService`**, nicht ins Modul – ein Modulfehler darf keine ungedeckte Option schreiben können.
+2. **Der Broker ist die Wahrheit.** Zuteilung und Verfall ändern Positionen **ohne** eine Order von uns.
+ Ohne regelmäßigen Positionsabgleich gegen IBKR läuft die eigene Buchführung zwangsläufig auseinander.
+3. **Nur Watchlist.** Das Modul handelt ausschließlich explizit eingetragene Ticker. Keine Entdeckung,
+ kein Screening, keine Ausweitung zur Laufzeit.
+4. **Jede Entscheidung ist nachlesbar.** Kandidatenbewertung, Zustandsübergang und Order gehen über
+ `core_decision_journal` / `core_order_events` mit gemeinsamer `SignalId` – dieselbe Forensik-Grundlage,
+ die der Supervisor bereits nutzt.
+
+---
+
+## 1. Warum das nicht additiv geht: der Core ist heute aktienbasiert
+
+| Stelle | Heutiger Stand | Konsequenz |
+|---|---|---|
+| `IbkrMapping.Stock()` | „der einzige Instrumententyp, den die Module handeln" | Kein Options-Kontrakt (Expiry/Strike/Right/Multiplier) baubar |
+| `TradeSignal` / `OrderRequest` | nur `Symbol` + `Side` + `Quantity` | Ein Signal kann keine Option benennen |
+| `Position.Notional` | `Quantity × AvgPrice` | Bei Optionen um Faktor 100 falsch → Risikolimits wirkungslos |
+| `RiskService.EvaluateSell` | „Verkauf schließt Position", lehnt ohne Bestand ab | **Sell-to-open wird grundsätzlich abgelehnt** – die Kernoperation des Moduls |
+| `IBrokerClient.PlaceOrderAsync` | Fill-oder-Fehlschlag innerhalb `OrderTimeoutSeconds` | Limitorder am Mid liegt im Buch → gilt als Fehlschlag, ist aber aktiv (bekannte Grenze, [IBKR-Integration.md](../IBKR-Integration.md)) |
+| `IBrokerClient` | kein `reqPositions`, keine Optionskette, keine Greeks | Zuteilung/Verfall unsichtbar, Strike-Wahl unmöglich |
+
+Das Modul setzt also auf einem **Core-Options-Fundament** auf, das zuerst gebaut wird. Der Aktienpfad
+bleibt dabei unverändert (neue Felder sind optional, `Kind = Stock` ist der Default) – CongressTrading
+darf nicht regressieren.
+
+---
+
+## 2. Core-Erweiterungen
+
+### 2.1 Instrumententyp
+```csharp
+public enum InstrumentKind { Stock, Option }
+public enum OptionRight { Call, Put }
+
+public sealed record OptionSpec(
+ string Underlying,
+ DateOnly Expiry,
+ decimal Strike,
+ OptionRight Right,
+ int Multiplier = 100);
+```
+`TradeSignal` und `OrderRequest` bekommen je ein **optionales** `OptionSpec? Option`. Ist es null,
+verhält sich alles exakt wie heute.
+
+### 2.2 Positionen mit Instrument-Identität
+`core_position` erhält `Kind`, `Expiry`, `Strike`, `Right`, `Multiplier`; der fachliche Schlüssel wird
+`(Module, Symbol, Kind, Expiry, Strike, Right)`. Bestandszeilen sind `Kind = Stock` mit leeren
+Optionsfeldern – Migration ohne Datenumzug. `Notional` rechnet ab jetzt mit dem Multiplikator.
+`IPortfolioService` bekommt entsprechend überladene Abfragen; die bestehenden Aktien-Signaturen bleiben.
+
+### 2.3 Optionskette + Greeks
+Neuer Seam am `IBrokerClient`:
+```csharp
+Task GetOptionChainAsync(string underlying, DateOnly from, DateOnly to, CancellationToken ct);
+Task GetOptionQuoteAsync(OptionSpec spec, CancellationToken ct); // + Delta, IV, OpenInterest
+Task> GetPositionsAsync(CancellationToken ct);
+```
+Umsetzung in `IbkrConnection`:
+- **Kette** über `reqSecDefOptParams` – liefert Expiries und Strikes je Underlying **ohne** Marktdaten
+ und ohne Abo. Das ist der einzige gangbare Einstieg: eine volle Kette hat hunderte Kontrakte,
+ TWS erlaubt aber nur ~100 gleichzeitige Marktdatenzeilen.
+- **Vorfilter, dann Quotes:** Expiry-Fenster aus dem DTE-Ziel, Strike-Fenster um den Spot (± x %) –
+ erst für diese Handvoll Kontrakte `reqMktData` mit Greeks (`tickOptionComputation`).
+- `IbkrMapping.Option(spec)` → `SecType="OPT"`, `Exchange="SMART"`, `LastTradeDateOrContractMonth`
+ als `yyyyMMdd`, `Strike`, `Right="C"/"P"`, `Multiplier="100"`, plus `TradingClass` (nötig, sobald ein
+ Underlying mehrere Klassen führt, z. B. nach Splits oder bei Mini-Optionen). Rein, unit-getestet –
+ wie das bestehende `IbkrMapping`.
+
+### 2.4 Asynchrone Order- und Fill-Verfolgung
+Das ist der **teuerste, aber unumgängliche** Teil: eine Optionsorder wird limitiert am Mid platziert und
+füllt oft erst Minuten später oder gar nicht.
+- Neue Tabelle `core_order_state`: Order-ID, `SignalId`, Kontrakt, Menge, Limit, Status, gefüllte Menge,
+ Zeitstempel – überlebt einen App-Neustart.
+- `IBrokerClient.SubmitOrderAsync(...)` gibt die Order-ID **ohne Warten** zurück; `PlaceOrderAsync`
+ bleibt für den Aktienpfad erhalten (intern: submit + warten).
+- Dauerabo auf `orderStatus` / `openOrder` / `execDetails` statt Slot-je-Order; ein
+ `OrderTrackingService` (`IHostedService`) trägt Endstatus nach, ruft dann `RecordFillAsync` und
+ schreibt `core_order_events`. Nicht gefüllte Orders werden nach einer Frist neu bepreist oder storniert.
+- **Nebennutzen:** löst die in [IBKR-Integration.md](../IBKR-Integration.md) dokumentierte Grenze auch
+ für CongressTrading.
+
+### 2.5 Positionsabgleich (Zuteilung & Verfall)
+`ReconcilePositionsWorker` (`IHostedService`, z. B. stündlich und nach Marktschluss) vergleicht
+`reqPositions` mit `core_position`:
+- Aktien tauchen auf / Short Put verschwindet → **Zuteilung**.
+- Short Call verschwindet, Aktien weg → **Abruf (called away)**.
+- Option verschwindet am Verfallstag ohne Gegenbuchung → **wertlos verfallen**.
+Jede Abweichung wird gebucht **und** ins Entscheidungsjournal geschrieben. Unerklärbare Differenzen
+(manueller Eingriff in TWS) setzen den betroffenen Ticker auf `Halted`.
+
+### 2.6 Risiko: sell-to-open + Deckung
+`RiskContext` bekommt `FreeShares` (nicht bereits durch andere Short Calls gebunden) und
+`AvailableCashForReservation`. Neue Regeln im `RiskService`:
+
+| Fall | Regel |
+|---|---|
+| Sell-to-open Call | `FreeShares ≥ 100 × Kontrakte`, sonst Ablehnung „ungedeckt" |
+| Sell-to-open Put | `AvailableCashForReservation ≥ Strike × 100 × Kontrakte`, sonst Ablehnung |
+| Buy-to-close | zulässig bis zur offenen Short-Menge |
+| Limits | `MaxTradePercent` / `MaxPositionPercentPerModule` rechnen mit dem **Multiplikator** |
+
+---
+
+## 3. Modul `IBKRTrader.Modules.OptionsWheel`
+
+`IModule` mit `Name = "OptionsWheel"`, `DbPrefix = "ow_"`, referenziert nur den Core, eigener DbContext,
+eigenes Fenster, eigene Settings-Sektion – analog Accounting/CongressTrading.
+
+### 3.1 Zustandsautomat je Ticker
+```
+Idle ──sell put──► ShortPut ──ITM/DTE──► Rolling ──► ShortPut
+ ▲ │ │
+ │ │ Zuteilung └─ kein Netto-Kredit ─► (Zuteilung zulassen)
+ │ ▼
+ └── called away ── Stock ──sell call──► ShortCall ──ITM/DTE──► Rolling ──► ShortCall
+ ▲ │
+ └─ verfallen ─────────┘
+```
+Zusätzlich `PendingOrder` (Order im Buch) und `Halted` (Sperre nach Fehler/Abweichung, nur manuell lösbar).
+Der Automat ist **rein und unit-getestet** – er kennt weder Broker noch Datenbank.
+
+### 3.2 Persistenz (`ow_`)
+| Tabelle | Inhalt |
+|---|---|
+| `ow_underlyings` | Watchlist: Ticker, aktiv, Parameter-Überschreibungen, aktueller Zustand |
+| `ow_cycles` | Ein Wheel-Durchlauf (Start, Ende, vereinnahmte Prämie brutto/netto, Ergebnis, Aktien-Einstand) |
+| `ow_legs` | Jede verkaufte/geschlossene Option: Kontrakt, Prämie, Delta+IV bei Eröffnung, DTE, Status, Roll-Kette |
+| `ow_candidates` | Bewertete Strike-Kandidaten je Scan (auch die verworfenen, mit Grund) – Nachvollziehbarkeit |
+| `ow_events` | Zustandsübergänge mit Auslöser |
+
+`ow_candidates` ist bewusst dabei: bei Vollautomatik ist die **nicht** getroffene Wahl die wichtigste
+Information für die spätere Fehlersuche und für den Supervisor.
+
+### 3.3 Reine Logik (`Logic/`, vollständig unit-getestet)
+- **`StrikeSelector`** – filtert die Kette: DTE-Fenster, Delta-Zielband, Mindestprämie (absolut und als
+ annualisierte Rendite), maximaler Bid/Ask-Spread, Mindest-Open-Interest. Bewertet die Verbleibenden
+ und begründet jede Verwerfung.
+- **`RollDecider`** – rollen ja/nein: Trigger, Zielkontrakt, Netto-Kredit-Bedingung.
+- **`CoverageCalculator`** – welche Aktien/welches Cash sind frei, welche durch offene Shorts gebunden.
+- **`PremiumMath`** – annualisierte Rendite, Break-even, effektiver Einstand nach Prämien.
+- **`WheelStateMachine`** – zulässige Übergänge, Ableitung des Zustands aus Positionen.
+
+### 3.4 Worker
+- `WheelScanWorker` – im Handelszeitfenster: Zustand je Ticker prüfen, ggf. neues Leg eröffnen.
+- `WheelManageWorker` – offene Legs überwachen: rollen, schließen, Orders nachbepreisen.
+
+### 3.5 UI (ein Fenster, Tabs)
+Übersicht (je Ticker: Zustand, offenes Leg, DTE, aktuelles Delta, Prämie vereinnahmt/annualisiert),
+Watchlist (Ticker + Parameter, Not-Aus je Ticker), Kandidaten (letzter Scan inkl. Verwerfungsgründe),
+Zyklen/Historie, Log. DB-Zugriff nur auf Interaktion (Smoke-UI-sicher).
+
+---
+
+## 4. Regelwerk (Defaults, je Ticker überschreibbar)
+
+| Parameter | Default | Begründung |
+|---|---|---|
+| DTE beim Öffnen | 30–45 | Bestes Verhältnis Zeitwertverfall/Handelskosten |
+| Ziel-Delta | 0,20 (Band 0,15–0,30) | ≈ 20 % Zuteilungswahrscheinlichkeit |
+| Roll-Trigger | DTE ≤ 21 **und** (Delta > 0,50 oder ITM) | Gamma-Risiko steigt in der letzten Woche stark |
+| Roll-Bedingung | nur bei **Netto-Kredit** | Ein Debit-Roll kauft nur Zeit und kostet Geld |
+| Covered-Call-Strike | **≥ effektiver Einstand** der Aktien | Verhindert, dass der Wheel systematisch Verluste realisiert |
+| Max. Kontrakte je Ticker | 1 | Bewusst klein starten |
+| Max. gebundenes Cash gesamt | Anteil des Kontowerts | Zweite Grenze über die Core-Limits hinaus |
+| Vorzeitiges Schließen bei Gewinnziel | **aus** | Nicht gewählt – siehe Hinweis unten |
+
+> **Hinweis zum Gewinnziel:** Der übliche Begleiter der Roll-Politik ist, ein Leg bei ~50 % vereinnahmter
+> Prämie zurückzukaufen; das nimmt Gamma-Risiko aus der letzten Woche und ist der Grund, warum viele
+> Roll-Trigger nie greifen. Der Parameter ist vorgesehen (`ProfitTargetPercent` existiert bereits in
+> `TradingSettings`), steht per Vorgabe auf „aus" und lässt sich ohne Codeänderung zuschalten.
+
+> **Zuteilung ist nicht abwählbar.** Auch bei konsequentem Rollen teilt IBKR zu – amerikanische Optionen
+> können jederzeit ausgeübt werden, beim Call besonders vor dem Ex-Dividenden-Tag, und ein Roll ist nicht
+> immer per Netto-Kredit möglich. Zuteilung ist deshalb ein **regulärer Pfad** des Automaten, kein Fehler.
+> Genau dafür ist der Positionsabgleich (2.5) Pflicht.
+
+---
+
+## 5. Sicherungen
+Zu den bestehenden zwei Schaltern (`IBKR.UseTwsApi`, `Trading.TradingEnabled`) kommen:
+1. `OptionsWheel.Enabled` – Modulschalter.
+2. **Watchlist als Whitelist** – kein Ticker außerhalb.
+3. **Naked-Sperre im Core-`RiskService`** (2.6) – wirkt auch, wenn die Modullogik falsch liegt.
+4. `Halted` je Ticker bei unerklärter Positionsabweichung; Neueröffnungen stoppen, bestehende Legs
+ werden weiter verwaltet.
+5. Kill-Switch „keine Neueröffnungen" – laufende Positionen bleiben handhabbar.
+
+---
+
+## 6. Phasen
+
+| Phase | Inhalt | Abschlusskriterium |
+|---|---|---|
+| **W-0** | Core: `InstrumentKind`/`OptionSpec`, `IbkrMapping.Option`, Positionen mit Multiplikator | Build + Tests grün, Aktienpfad unverändert |
+| **W-1** | Core: Optionskette + Greeks (`reqSecDefOptParams`, `tickOptionComputation`) | Kette eines Watchlist-Tickers gegen das Paper-Gateway abrufbar |
+| **W-2** | Core: asynchrone Order-/Fill-Verfolgung (`core_order_state`, `OrderTrackingService`) | Limitorder überlebt Timeout und App-Neustart, Fill wird nachgebucht |
+| **W-3** | Core: Positionsabgleich + `RiskService` sell-to-open/Deckung | Zuteilung im Paper erkannt und gebucht; ungedeckte Order wird abgelehnt |
+| **W-4** | Modul-Gerüst: `IModule`, `ow_`-DbContext + Migration, UI-Tabs, Watchlist | App startet, `--smoke-ui` grün, kein Handel |
+| **W-5** | Reine Strategie-Logik + Tests (StrikeSelector, StateMachine, RollDecider, PremiumMath) | Hohe Testabdeckung ohne Broker |
+| **W-6** | Verdrahtung + vollautomatischer Paper-Betrieb, Beobachtung über mehrere Verfallszyklen | Mindestens ein vollständiger Wheel-Durchlauf im Paper |
+| **W-7** | Live-Freigabe | Eigene Entscheidung nach W-6 |
+
+W-0 bis W-3 sind Core-Arbeit und nützen auch den anderen Modulen; erst ab W-4 entsteht das Modul selbst.
+
+---
+
+## 7. Bewusst offen / zu klären
+1. ~~**Optionsberechtigung im Paper-Konto DUR371528 prüfen**~~ → **erledigt am 2026-08-04, vorhanden.**
+ Verifiziert gegen das laufende Gateway (Details siehe [IBKR-Integration.md](../IBKR-Integration.md),
+ Abschnitt „Optionen"): `reqSecDefOptParams` für AAPL liefert 24 Verfallstermine, 127 Strikes,
+ Multiplier 100 über SMART; eine **What-If-Order** auf `AAPL 20260812 C302.5` wurde von IBKR
+ angenommen (Init-Margin 589,52) statt mit einem Berechtigungsfehler abgelehnt.
+ Damit ist die Grundvoraussetzung für dieses Modul gegeben.
+2. **Greeks bei verzögerten Daten.** `MarketDataType = 4` liefert Optionsberechnungen als verzögerte
+ Tick-Variante; ob Delta zuverlässig ankommt, muss gegen das laufende Gateway verifiziert werden.
+ Fällt es aus, greift ersatzweise eine Strike-Wahl über Abstand in % + Mindestprämie – der
+ `StrikeSelector` wird von vornherein so geschnitten, dass beide Kriterien einsetzbar sind.
+3. **Marktdatenabo (OPRA)** für Realtime-Optionskurse – Kosten/Notwendigkeit später entscheiden.
+4. **Earnings-Sperre**: keine neuen Legs über Quartalszahlen hinweg. Sinnvoll, aber es fehlt eine
+ Datenquelle für Earnings-Termine – Punkt bewusst offen.
+5. **Accounting-Anschluss**: Optionsprämien, Zuteilungen und Abrufe müssen im `AccountingClassifier`
+ eigene Buchungskategorien bekommen; der `RealizedPnlEngine` (FIFO) kennt weder Multiplikator noch
+ die Einstandsverschiebung durch Zuteilung. Eigene Arbeit im Accounting-Modul.
+6. **Steuer** bleibt wie im Accounting-Konzept unberührt und offen. Keine Steuerberatung.
diff --git a/settings.example.json b/settings.example.json
index 3031bbc..25d698d 100644
--- a/settings.example.json
+++ b/settings.example.json
@@ -8,8 +8,13 @@
},
"IBKR": {
"Host": "127.0.0.1",
- "Port": 4001,
- "ClientId": 1
+ "Port": 4002,
+ "ClientId": 1,
+ "UseTwsApi": false,
+ "MarketDataType": 4,
+ "ConnectTimeoutSeconds": 15,
+ "RequestTimeoutSeconds": 15,
+ "OrderTimeoutSeconds": 60
},
"Logging": {
"Level": "Info",
diff --git a/src/IBKRTrader.Core/IBKRTrader.Core.csproj b/src/IBKRTrader.Core/IBKRTrader.Core.csproj
index 043cd6a..2fa5a20 100644
--- a/src/IBKRTrader.Core/IBKRTrader.Core.csproj
+++ b/src/IBKRTrader.Core/IBKRTrader.Core.csproj
@@ -14,6 +14,9 @@
+
+
diff --git a/src/IBKRTrader.Core/Settings/AppSettings.cs b/src/IBKRTrader.Core/Settings/AppSettings.cs
index 3112240..c0886a0 100644
--- a/src/IBKRTrader.Core/Settings/AppSettings.cs
+++ b/src/IBKRTrader.Core/Settings/AppSettings.cs
@@ -63,7 +63,36 @@ public class IBKRSettings
[Description("Eindeutige Client-ID für die API-Verbindung")]
public int ClientId { get; set; } = 1;
- public override string ToString() => $"{Host}:{Port} (Client {ClientId})";
+ [Category("IBKR Gateway")]
+ [DisplayName("TWS-Broker verwenden")]
+ [Description("Aktiviert den echten TWS-Broker. Aus = NullBroker (handelt nie). " +
+ "Der globale Handelsschalter unter Trading bleibt davon unberührt.")]
+ public bool UseTwsApi { get; set; } = false;
+
+ [Category("IBKR Gateway")]
+ [DisplayName("Marktdaten-Typ")]
+ [Description("1 = Realtime, 2 = Frozen, 3 = verzögert, 4 = verzögert+Frozen. " +
+ "Paper-Konten ohne Datenabo brauchen 3 oder 4, sonst kommen keine Kurse.")]
+ public int MarketDataType { get; set; } = 4;
+
+ [Category("IBKR Gateway")]
+ [DisplayName("Verbindungs-Timeout (s)")]
+ [Description("Wartezeit auf Socket-Verbindung und Handshake. TWS braucht nach einem Neustart mehrere Minuten.")]
+ public int ConnectTimeoutSeconds { get; set; } = 15;
+
+ [Category("IBKR Gateway")]
+ [DisplayName("Anfrage-Timeout (s)")]
+ [Description("Wartezeit auf Kurs-, Kontrakt- und Kontoantworten")]
+ public int RequestTimeoutSeconds { get; set; } = 15;
+
+ [Category("IBKR Gateway")]
+ [DisplayName("Order-Timeout (s)")]
+ [Description("Wartezeit auf den Endstatus einer Order. Danach gilt sie als fehlgeschlagen, " +
+ "kann bei IBKR aber weiterhin aktiv sein.")]
+ public int OrderTimeoutSeconds { get; set; } = 60;
+
+ public override string ToString() =>
+ $"{Host}:{Port} (Client {ClientId}){(UseTwsApi ? "" : " – TWS-Broker aus")}";
}
// ─── IBKR Web API (Client Portal Gateway) ────────────────────────────────────
diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
new file mode 100644
index 0000000..952b547
--- /dev/null
+++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
@@ -0,0 +1,103 @@
+using IBKRTrader.Core.Logging;
+using IBKRTrader.Core.Settings;
+
+namespace IBKRTrader.Core.Trading.Ibkr;
+
+///
+/// Echter Broker-Adapter über die TWS API (IB Gateway bzw. TWS, Socket-Verbindung).
+///
+/// Wird nur registriert, wenn IBKRSettings.UseTwsApi gesetzt ist – sonst bleibt der
+/// aktiv. Der globale Handelsschalter
+/// (TradingSettings.TradingEnabled) bleibt davon unberührt: ohne ihn platziert der
+/// gar keine Order, egal welcher Broker registriert ist.
+///
+/// Fehler werden nie geworfen, sondern in leere Ergebnisse übersetzt (kein Kurs, Konto 0,
+/// fehlgeschlagene Order). Ein Konto mit Wert 0 lässt die Risikoprüfung jedes Signal ablehnen –
+/// die sichere Richtung, wenn der Broker nicht erreichbar ist.
+///
+public sealed class IbkrBrokerClient : IBrokerClient, IDisposable
+{
+ private const string LogModule = "IBKR";
+
+ private readonly LoggingService _logger;
+ private readonly IbkrConnection? _connection;
+ private readonly TimeSpan _requestTimeout;
+ private readonly TimeSpan _orderTimeout;
+
+ public IbkrBrokerClient(SettingsService settings, LoggingService logger)
+ {
+ _logger = logger;
+
+ var ibkr = settings.Settings.IBKR;
+ var mode = settings.Settings.Trading.ParsedMode;
+
+ _requestTimeout = TimeSpan.FromSeconds(Math.Max(1, ibkr.RequestTimeoutSeconds));
+ _orderTimeout = TimeSpan.FromSeconds(Math.Max(1, ibkr.OrderTimeoutSeconds));
+
+ // Ein Paper-Modus auf dem Live-Port würde echtes Geld bewegen: dann lieber gar nicht
+ // verbinden, statt auf dem falschen Konto zu handeln.
+ var mismatch = IbkrMapping.ValidatePort(ibkr.Port, mode);
+ if (mismatch is not null)
+ {
+ _logger.Error(LogModule,
+ $"{mismatch} Broker bleibt inaktiv – Port oder Handelsmodus in den Einstellungen korrigieren " +
+ $"(Paper: {IbkrMapping.GatewayPaperPort}, Live: {IbkrMapping.GatewayLivePort}).");
+ return;
+ }
+
+ _connection = new IbkrConnection(
+ logger, ibkr.Host, ibkr.Port, ibkr.ClientId, ibkr.MarketDataType,
+ TimeSpan.FromSeconds(Math.Max(1, ibkr.ConnectTimeoutSeconds)));
+
+ _logger.Info(LogModule,
+ $"TWS-Broker aktiv: {ibkr.Host}:{ibkr.Port} (Client {ibkr.ClientId}), Modus {mode}.");
+ }
+
+ public async Task GetQuoteAsync(string symbol, CancellationToken ct = default)
+ {
+ if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null;
+
+ var contract = await _connection!.ResolveContractAsync(symbol, _requestTimeout, ct).ConfigureAwait(false);
+ if (contract is null) return null;
+
+ return await _connection.RequestQuoteAsync(contract, symbol, _requestTimeout, ct).ConfigureAwait(false);
+ }
+
+ public async Task GetAccountStateAsync(CancellationToken ct = default)
+ {
+ if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Empty;
+
+ return await _connection!.RequestAccountAsync(_requestTimeout, ct).ConfigureAwait(false) ?? Empty;
+ }
+
+ public async Task PlaceOrderAsync(OrderRequest request, CancellationToken ct = default)
+ {
+ if (!await IsReadyAsync(ct).ConfigureAwait(false))
+ return OrderResult.Fail("Keine Verbindung zur TWS bzw. zum IB Gateway.");
+
+ var contract = await _connection!.ResolveContractAsync(request.Symbol, _requestTimeout, ct).ConfigureAwait(false);
+ if (contract is null)
+ return OrderResult.Fail($"Kontrakt für {request.Symbol} nicht auflösbar – Order nicht platziert.");
+
+ var result = await _connection.PlaceOrderAsync(request, contract, _orderTimeout, ct).ConfigureAwait(false);
+
+ if (result.Success)
+ _logger.Info(LogModule,
+ $"Order {result.OrderId} ausgeführt: {request.Side} {result.FilledQuantity}x {request.Symbol} " +
+ $"@ {result.AvgFillPrice:F2}.");
+ else
+ _logger.Error(LogModule, result.Error ?? "Order fehlgeschlagen.");
+
+ return result;
+ }
+
+ private static AccountState Empty => new(0m, 0m);
+
+ private async Task IsReadyAsync(CancellationToken ct)
+ {
+ if (_connection is null) return false;
+ return await _connection.EnsureConnectedAsync(ct).ConfigureAwait(false);
+ }
+
+ public void Dispose() => _connection?.Dispose();
+}
diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs
new file mode 100644
index 0000000..477d569
--- /dev/null
+++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs
@@ -0,0 +1,489 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using IBApi;
+using IBKRTrader.Core.Logging;
+
+namespace IBKRTrader.Core.Trading.Ibkr;
+
+///
+/// Hält die Socket-Verbindung zur TWS bzw. zum IB Gateway und übersetzt die callback-basierte
+/// TWS-API in awaitable Anfragen: jede Anfrage bekommt eine reqId, deren Antworten in einem
+/// Slot gesammelt und über einen aufgelöst werden.
+///
+/// Verbunden wird träge bei der ersten Anfrage und danach bei Bedarf erneut: TWS ist nach einem
+/// Neustart mehrere Minuten nicht bereit (erst abgelehnte Verbindungen, dann abgebrochene
+/// Handshakes), ein einmaliger Verbindungsversuch beim Programmstart würde das nicht überleben.
+///
+internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
+{
+ private const string LogModule = "IBKR";
+
+ private readonly LoggingService _logger;
+ private readonly string _host;
+ private readonly int _port;
+ private readonly int _clientId;
+ private readonly int _marketDataType;
+ private readonly TimeSpan _connectTimeout;
+
+ private readonly EReaderMonitorSignal _signal = new();
+ private readonly EClientSocket _socket;
+ private readonly SemaphoreSlim _connectLock = new(1, 1);
+
+ private readonly ConcurrentDictionary _quotes = new();
+ private readonly ConcurrentDictionary _accounts = new();
+ private readonly ConcurrentDictionary _contracts = new();
+ private readonly ConcurrentDictionary _orders = new();
+ private readonly ConcurrentDictionary _contractCache = new(StringComparer.OrdinalIgnoreCase);
+
+ private TaskCompletionSource _handshake = NewTcs();
+ private volatile bool _ready;
+ private volatile bool _disposed;
+ private int _nextRequestId = 1000;
+ private int _nextOrderId = -1;
+ private string? _account;
+
+ public IbkrConnection(LoggingService logger, string host, int port, int clientId,
+ int marketDataType, TimeSpan connectTimeout)
+ {
+ _logger = logger;
+ _host = host;
+ _port = port;
+ _clientId = clientId;
+ _marketDataType = marketDataType;
+ _connectTimeout = connectTimeout;
+ _socket = new EClientSocket(this, _signal);
+ }
+
+ /// Kontonummer, die TWS beim Verbinden gemeldet hat (z. B. "DUR371528").
+ public string? Account => _account;
+
+ // ─── Verbindung ───────────────────────────────────────────────────────────
+
+ public async Task EnsureConnectedAsync(CancellationToken ct)
+ {
+ if (_disposed) return false;
+ if (_ready && _socket.IsConnected()) return true;
+
+ await _connectLock.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (_disposed) return false;
+ if (_ready && _socket.IsConnected()) return true;
+ return await ConnectAsync(ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ _connectLock.Release();
+ }
+ }
+
+ private async Task ConnectAsync(CancellationToken ct)
+ {
+ _ready = false;
+ _handshake = NewTcs();
+ SafeDisconnect();
+
+ // eConnect blockiert und hängt nach einem TWS-Neustart auch schon mal minutenlang ohne
+ // Antwort – deshalb auf einem Hintergrund-Thread mit Zeitlimit statt direkt.
+ var connect = Task.Run(() =>
+ {
+ try
+ {
+ _socket.eConnect(_host, _port, _clientId);
+ return _socket.IsConnected();
+ }
+ catch
+ {
+ // Die API meldet Socket-Fehler bereits über error(Exception); hier reicht das Ergebnis.
+ return false;
+ }
+ }, ct);
+
+ if (!await WaitAsync(connect, _connectTimeout, ct).ConfigureAwait(false) || !connect.Result)
+ {
+ _logger.Warn(LogModule, $"Keine Verbindung zu {_host}:{_port} (Client {_clientId}) – läuft TWS/IB Gateway?");
+ SafeDisconnect();
+ return false;
+ }
+
+ StartReader();
+
+ // Erst nextValidId bestätigt den Handshake; vorher sind keine Anfragen zulässig. Bleibt es
+ // aus, hat TWS die Verbindung nicht freigegeben (fehlende Trusted IP oder offenes Popup).
+ if (!await WaitAsync(_handshake.Task, _connectTimeout, ct).ConfigureAwait(false))
+ {
+ _logger.Warn(LogModule,
+ "TWS hat den Handshake nicht bestätigt (kein nextValidId). Trusted IP 127.0.0.1 prüfen " +
+ "und TWS neu starten – siehe docs/TWS-Setup-Checkliste.md.");
+ SafeDisconnect();
+ return false;
+ }
+
+ _ready = true;
+ _socket.reqMarketDataType(_marketDataType);
+ _logger.Info(LogModule,
+ $"Verbunden mit {_host}:{_port} (Client {_clientId}), Konto {_account ?? "unbekannt"}.");
+ return true;
+ }
+
+ private void StartReader()
+ {
+ var reader = new EReader(_socket, _signal);
+ reader.Start();
+
+ new Thread(() =>
+ {
+ while (_socket.IsConnected())
+ {
+ _signal.waitForSignal();
+ try
+ {
+ reader.processMsgs();
+ }
+ catch (Exception ex)
+ {
+ if (!_disposed)
+ _logger.Warn(LogModule, $"Nachrichten-Reader beendet: {ex.Message}");
+ break;
+ }
+ }
+ })
+ { IsBackground = true, Name = "IBKR-Reader" }.Start();
+ }
+
+ private void SafeDisconnect()
+ {
+ try { _socket.eDisconnect(); }
+ catch { /* Socket war nie offen oder ist bereits zu. */ }
+ }
+
+ // ─── Anfragen ─────────────────────────────────────────────────────────────
+
+ ///
+ /// Löst ein Symbol in einen vollständigen Kontrakt auf (inklusive ConId, damit Kurse und
+ /// Orders dasselbe Instrument treffen). Ergebnisse werden für die Laufzeit zwischengespeichert.
+ ///
+ public async Task ResolveContractAsync(string symbol, TimeSpan timeout, CancellationToken ct)
+ {
+ if (_contractCache.TryGetValue(symbol, out var cached)) return cached;
+
+ var id = NextRequestId();
+ var slot = new ContractSlot();
+ _contracts[id] = slot;
+ try
+ {
+ _socket.reqContractDetails(id, IbkrMapping.Stock(symbol));
+
+ if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
+ {
+ _logger.Warn(LogModule, $"Zeitüberschreitung bei der Kontraktsuche für {symbol}.");
+ return null;
+ }
+ if (slot.Error is not null)
+ {
+ _logger.Warn(LogModule, $"Kontrakt {symbol} nicht auflösbar: {slot.Error}");
+ return null;
+ }
+ if (slot.First is null)
+ {
+ _logger.Warn(LogModule, $"Kein Kontrakt für {symbol} gefunden.");
+ return null;
+ }
+
+ _contractCache[symbol] = slot.First;
+ return slot.First;
+ }
+ finally
+ {
+ _contracts.TryRemove(id, out _);
+ }
+ }
+
+ ///
+ /// Momentaufnahme des Kurses. Eine Zeitüberschreitung ist hier kein Fehler: TWS beendet den
+ /// Snapshot nicht immer sauber, die bis dahin gelieferten Ticks reichen meist für einen Kurs.
+ ///
+ public async Task RequestQuoteAsync(Contract contract, string symbol, TimeSpan timeout, CancellationToken ct)
+ {
+ var id = NextRequestId();
+ var slot = new QuoteSlot();
+ _quotes[id] = slot;
+ try
+ {
+ _socket.reqMktData(id, contract, "", true, false, null);
+ await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false);
+
+ if (slot.Error is not null)
+ _logger.Warn(LogModule, $"Kursabfrage {symbol}: {slot.Error}");
+
+ return IbkrMapping.BuildQuote(symbol, slot.Last, slot.Bid, slot.Ask, slot.Close);
+ }
+ finally
+ {
+ _quotes.TryRemove(id, out _);
+ }
+ }
+
+ public async Task RequestAccountAsync(TimeSpan timeout, CancellationToken ct)
+ {
+ var id = NextRequestId();
+ var slot = new AccountSlot();
+ _accounts[id] = slot;
+ try
+ {
+ _socket.reqAccountSummary(id, "All", "NetLiquidation,AvailableFunds");
+
+ if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
+ {
+ _logger.Warn(LogModule, "Zeitüberschreitung bei der Kontoabfrage.");
+ return null;
+ }
+ if (slot.Error is not null)
+ {
+ _logger.Warn(LogModule, $"Kontoabfrage fehlgeschlagen: {slot.Error}");
+ return null;
+ }
+
+ return new AccountState(
+ ReadDecimal(slot.Values, "NetLiquidation"),
+ ReadDecimal(slot.Values, "AvailableFunds"));
+ }
+ finally
+ {
+ _accounts.TryRemove(id, out _);
+ try { _socket.cancelAccountSummary(id); }
+ catch { /* Verbindung bereits weg. */ }
+ }
+ }
+
+ public async Task PlaceOrderAsync(OrderRequest request, Contract contract,
+ TimeSpan timeout, CancellationToken ct)
+ {
+ if (request.Type == OrderType.Limit && request.LimitPrice is null or <= 0)
+ return OrderResult.Fail($"Limit-Order für {request.Symbol} ohne gültigen Limitpreis – nicht platziert.");
+
+ var orderId = NextOrderId();
+ if (orderId < 0)
+ return OrderResult.Fail("Keine gültige Order-ID von TWS erhalten – Verbindung nicht bereit.");
+
+ var slot = new OrderSlot();
+ _orders[orderId] = slot;
+ try
+ {
+ _socket.placeOrder(orderId, contract, IbkrMapping.BuildOrder(request, orderId, _account));
+
+ if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
+ {
+ // Die Order ist übermittelt und liegt womöglich aktiv bei IBKR. Sie hier als
+ // reinen Fehlschlag zu buchen wäre falsch – deshalb Order-ID und letzter Status
+ // in die Meldung, damit der Betreiber sie in TWS wiederfindet.
+ var status = string.IsNullOrEmpty(slot.Status) ? "keine Rückmeldung" : slot.Status;
+ return OrderResult.Fail(
+ $"Order {orderId} ({request.Side} {request.Quantity}x {request.Symbol}) wurde übermittelt, " +
+ $"blieb aber {timeout.TotalSeconds:F0}s ohne Endstatus (zuletzt: {status}). " +
+ "Sie kann bei IBKR weiterhin aktiv sein und muss dort geprüft werden.");
+ }
+
+ if (slot.Error is not null)
+ return OrderResult.Fail($"Order {orderId} abgelehnt: {slot.Error}");
+
+ if (slot.Status == "Filled" && slot.Filled > 0)
+ return OrderResult.Filled(orderId.ToString(CultureInfo.InvariantCulture),
+ slot.Filled, (decimal)slot.AvgFillPrice);
+
+ return OrderResult.Fail($"Order {orderId} endete ohne Ausführung (Status {slot.Status}).");
+ }
+ finally
+ {
+ _orders.TryRemove(orderId, out _);
+ }
+ }
+
+ // ─── EWrapper-Callbacks (laufen auf dem Reader-Thread) ────────────────────
+
+ public override void nextValidId(int orderId)
+ {
+ Interlocked.Exchange(ref _nextOrderId, orderId);
+ _handshake.TrySetResult(true);
+ }
+
+ public override void managedAccounts(string accountsList) =>
+ _account = accountsList?.Split(',').FirstOrDefault(a => !string.IsNullOrWhiteSpace(a))?.Trim();
+
+ public override void contractDetails(int reqId, ContractDetails contractDetails)
+ {
+ if (_contracts.TryGetValue(reqId, out var slot))
+ slot.First ??= contractDetails.Contract;
+ }
+
+ public override void contractDetailsEnd(int reqId)
+ {
+ if (_contracts.TryGetValue(reqId, out var slot)) slot.Complete();
+ }
+
+ public override void tickPrice(int tickerId, int field, double price, TickAttrib attribs)
+ {
+ if (price <= 0 || !_quotes.TryGetValue(tickerId, out var slot)) return;
+
+ if (IbkrMapping.IsLastTick(field)) slot.Last = price;
+ else if (IbkrMapping.IsBidTick(field)) slot.Bid = price;
+ else if (IbkrMapping.IsAskTick(field)) slot.Ask = price;
+ else if (IbkrMapping.IsCloseTick(field)) slot.Close = price;
+ }
+
+ public override void tickSnapshotEnd(int tickerId)
+ {
+ if (_quotes.TryGetValue(tickerId, out var slot)) slot.Complete();
+ }
+
+ public override void accountSummary(int reqId, string account, string tag, string value, string currency)
+ {
+ if (_accounts.TryGetValue(reqId, out var slot)) slot.Values[tag] = value;
+ }
+
+ public override void accountSummaryEnd(int reqId)
+ {
+ if (_accounts.TryGetValue(reqId, out var slot)) slot.Complete();
+ }
+
+ public override void orderStatus(int orderId, string status, double filled, double remaining,
+ double avgFillPrice, int permId, int parentId, double lastFillPrice, int clientId,
+ string whyHeld, double mktCapPrice)
+ {
+ if (!_orders.TryGetValue(orderId, out var slot)) return;
+
+ slot.Status = status;
+ slot.Filled = (int)filled;
+ slot.AvgFillPrice = avgFillPrice;
+
+ if (IbkrMapping.IsTerminalStatus(status)) slot.Complete();
+ }
+
+ public override void error(int id, int errorCode, string errorMsg)
+ {
+ if (IbkrMapping.IsInformational(errorCode))
+ {
+ _logger.Info(LogModule, $"[{errorCode}] {errorMsg}");
+ return;
+ }
+
+ var text = $"[{errorCode}] {errorMsg}";
+ if (id >= 0 && FailPending(id, text)) return;
+
+ _logger.Warn(LogModule, text);
+ }
+
+ public override void error(Exception e)
+ {
+ // Ein abgelehnter Socket heißt schlicht: TWS läuft (noch) nicht. Das ist beim trägen
+ // Verbinden der Normalfall und wird von ConnectAsync bereits gemeldet – kein Stacktrace.
+ if (e is System.Net.Sockets.SocketException)
+ {
+ _logger.Info(LogModule, $"Socket nicht erreichbar: {e.Message}");
+ return;
+ }
+
+ _logger.Error(LogModule, "API-Ausnahme", e);
+ }
+
+ public override void error(string str) => _logger.Error(LogModule, str);
+
+ public override void connectionClosed()
+ {
+ _ready = false;
+ FailAllPending("Verbindung zur TWS wurde geschlossen.");
+ if (!_disposed) _logger.Warn(LogModule, "Verbindung zur TWS wurde geschlossen.");
+ }
+
+ // ─── Hilfsmittel ──────────────────────────────────────────────────────────
+
+ private int NextRequestId() => Interlocked.Increment(ref _nextRequestId);
+
+ ///
+ /// Vergibt die nächste Order-ID. Die erste Vergabe liefert genau die von TWS über
+ /// nextValidId gemeldete ID; ohne Handshake bleibt der Wert negativ und damit ungültig.
+ ///
+ private int NextOrderId() => Interlocked.Increment(ref _nextOrderId) - 1;
+
+ private bool FailPending(int id, string error)
+ {
+ if (_quotes .TryGetValue(id, out var q)) { q.Fail(error); return true; }
+ if (_accounts .TryGetValue(id, out var a)) { a.Fail(error); return true; }
+ if (_contracts.TryGetValue(id, out var c)) { c.Fail(error); return true; }
+ if (_orders .TryGetValue(id, out var o)) { o.Fail(error); return true; }
+ return false;
+ }
+
+ private void FailAllPending(string error)
+ {
+ foreach (var slot in _quotes.Values) slot.Fail(error);
+ foreach (var slot in _accounts.Values) slot.Fail(error);
+ foreach (var slot in _contracts.Values) slot.Fail(error);
+ foreach (var slot in _orders.Values) slot.Fail(error);
+ }
+
+ private static decimal ReadDecimal(IReadOnlyDictionary values, string tag) =>
+ values.TryGetValue(tag, out var raw) &&
+ decimal.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)
+ ? parsed
+ : 0m;
+
+ private static TaskCompletionSource NewTcs() =>
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ private static async Task WaitAsync(Task task, TimeSpan timeout, CancellationToken ct)
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ var finished = await Task.WhenAny(task, Task.Delay(timeout, cts.Token)).ConfigureAwait(false);
+ cts.Cancel(); // beendet den Verzögerungs-Task, wenn die Antwort zuerst da war
+ return finished == task;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _ready = false;
+ FailAllPending("Broker-Verbindung wird beendet.");
+ SafeDisconnect();
+ _connectLock.Dispose();
+ }
+
+ // ─── Antwort-Slots ────────────────────────────────────────────────────────
+
+ private abstract class Slot
+ {
+ public readonly TaskCompletionSource Done = NewTcs();
+ public string? Error;
+
+ public void Complete() => Done.TrySetResult(true);
+
+ public void Fail(string error)
+ {
+ Error = error;
+ Done.TrySetResult(false);
+ }
+ }
+
+ private sealed class QuoteSlot : Slot
+ {
+ public double Last, Bid, Ask, Close;
+ }
+
+ private sealed class AccountSlot : Slot
+ {
+ public readonly Dictionary Values = new(StringComparer.OrdinalIgnoreCase);
+ }
+
+ private sealed class ContractSlot : Slot
+ {
+ public Contract? First;
+ }
+
+ private sealed class OrderSlot : Slot
+ {
+ public string Status = "";
+ public int Filled;
+ public double AvgFillPrice;
+ }
+}
diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs
new file mode 100644
index 0000000..3c6b62c
--- /dev/null
+++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs
@@ -0,0 +1,113 @@
+using IBApi;
+
+namespace IBKRTrader.Core.Trading.Ibkr;
+
+///
+/// Abbildung zwischen Core-Modellen und den TWS-API-Objekten – ohne Socket, damit Kontrakt-,
+/// Order- und Portregeln ohne laufenden Gateway testbar bleiben.
+///
+internal static class IbkrMapping
+{
+ // IB Gateway
+ public const int GatewayPaperPort = 4002;
+ public const int GatewayLivePort = 4001;
+ // TWS Desktop
+ public const int TwsPaperPort = 7497;
+ public const int TwsLivePort = 7496;
+
+ public static bool IsLivePort (int port) => port is GatewayLivePort or TwsLivePort;
+ public static bool IsPaperPort(int port) => port is GatewayPaperPort or TwsPaperPort;
+
+ ///
+ /// Beschreibt einen Widerspruch zwischen Handelsmodus und Port, sonst null. Paper-Modus auf
+ /// einem Live-Port würde echtes Geld bewegen, obwohl der Betreiber einen Test erwartet;
+ /// nicht standardisierte Ports werden durchgelassen.
+ ///
+ public static string? ValidatePort(int port, TradingMode mode)
+ {
+ if (mode == TradingMode.Paper && IsLivePort(port))
+ return $"Handelsmodus ist Paper, Port {port} ist jedoch ein LIVE-Port.";
+ if (mode == TradingMode.Live && IsPaperPort(port))
+ return $"Handelsmodus ist Live, Port {port} ist jedoch ein Paper-Port.";
+ return null;
+ }
+
+ /// Der zum Handelsmodus passende Gateway-Port.
+ public static int DefaultPortFor(TradingMode mode) =>
+ mode == TradingMode.Live ? GatewayLivePort : GatewayPaperPort;
+
+ /// US-Aktie über die SMART-Route – der einzige Instrumententyp, den die Module handeln.
+ public static Contract Stock(string symbol) => new()
+ {
+ Symbol = symbol.Trim().ToUpperInvariant(),
+ SecType = "STK",
+ Exchange = "SMART",
+ Currency = "USD"
+ };
+
+ public static Order BuildOrder(OrderRequest request, int orderId, string? account)
+ {
+ var isLimit = request.Type == OrderType.Limit;
+
+ var order = new Order
+ {
+ OrderId = orderId,
+ Action = request.Side == TradeSide.Buy ? "BUY" : "SELL",
+ TotalQuantity = request.Quantity,
+ OrderType = isLimit ? "LMT" : "MKT",
+ Tif = "DAY",
+ Transmit = true
+ };
+
+ // Ohne Zuweisung bleibt LmtPrice auf dem „nicht gesetzt"-Marker der API (double.MaxValue).
+ // Eine 0 einzusetzen wäre ein Fehler – TWS läse sie als echten Limitpreis von 0.
+ if (isLimit && request.LimitPrice is { } limit)
+ order.LmtPrice = (double)limit;
+
+ if (!string.IsNullOrWhiteSpace(account))
+ order.Account = account;
+
+ return order;
+ }
+
+ ///
+ /// Baut aus den eingesammelten Ticks einen Kurs, oder null wenn kein brauchbarer Preis kam.
+ /// Reihenfolge: letzter Handelspreis, sonst Bid/Ask-Mitte, sonst Schlusskurs – Paper-Konten
+ /// ohne Realtime-Abo liefern häufig nur verzögerte Kurse oder den Schlusskurs.
+ ///
+ public static Quote? BuildQuote(string symbol, double last, double bid, double ask, double close)
+ {
+ var mid = bid > 0 && ask > 0 ? (bid + ask) / 2 : 0;
+ var price = last > 0 ? last : mid > 0 ? mid : close;
+ if (price <= 0) return null;
+
+ return new Quote(
+ symbol,
+ (decimal)price,
+ (decimal)(bid > 0 ? bid : price),
+ (decimal)(ask > 0 ? ask : price));
+ }
+
+ public static bool IsLastTick (int field) => field is TickType.LAST or TickType.DELAYED_LAST;
+ public static bool IsBidTick (int field) => field is TickType.BID or TickType.DELAYED_BID;
+ public static bool IsAskTick (int field) => field is TickType.ASK or TickType.DELAYED_ASK;
+ public static bool IsCloseTick(int field) => field is TickType.CLOSE or TickType.DELAYED_CLOSE;
+
+ /// Endzustände von orderStatus – erst hier steht das Ergebnis der Order fest.
+ public static bool IsTerminalStatus(string status) =>
+ status is "Filled" or "Cancelled" or "ApiCancelled" or "Inactive";
+
+ /// Kein Marktdaten-Abo, TWS liefert stattdessen verzögerte Kurse.
+ public const int DelayedDataNotice = 10167;
+
+ ///
+ /// TWS meldet Verbindungs- und Statushinweise über denselben Callback wie echte Fehler:
+ /// 1100–1102 sind Verbindungsmeldungen, 2100–2169 Warnungen (u. a. „Datenzentrum OK").
+ ///
+ /// 10167 gehört ausdrücklich dazu: Der Hinweis kündigt verzögerte Kurse an, die Ticks folgen
+ /// danach noch. Als Fehler behandelt würde die Kursanfrage abgebrochen, bevor sie ankommen –
+ /// bei einem Paper-Konto ohne Datenabo also bei jedem Symbol.
+ ///
+ public static bool IsInformational(int errorCode) =>
+ errorCode is >= 2100 and <= 2169 or >= 1100 and <= 1102 or DelayedDataNotice;
+}
diff --git a/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs b/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs
new file mode 100644
index 0000000..8775d16
--- /dev/null
+++ b/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs
@@ -0,0 +1,198 @@
+using FluentAssertions;
+using IBKRTrader.Core.Trading;
+using IBKRTrader.Core.Trading.Ibkr;
+
+namespace IBKRTrader.Tests.Trading;
+
+///
+/// Prüft die Abbildung zwischen Core-Modellen und TWS-API – der Teil des Broker-Adapters,
+/// der ohne laufenden Gateway testbar ist. Verbindung, Kurse und Orders bleiben manuelle
+/// Verifikation gegen das Paper-Konto (siehe docs/TWS-Setup-Checkliste.md).
+///
+[Trait("cat", "unit")]
+public class IbkrMappingTests
+{
+ // ─── Port-Validierung (Schutz vor Handel auf dem falschen Konto) ──────────
+
+ [Theory]
+ [InlineData(4001)] // IB Gateway Live
+ [InlineData(7496)] // TWS Live
+ public void PaperMode_OnLivePort_IsRejected(int port)
+ {
+ var problem = IbkrMapping.ValidatePort(port, TradingMode.Paper);
+
+ problem.Should().NotBeNull().And.Contain("LIVE");
+ }
+
+ [Theory]
+ [InlineData(4002)] // IB Gateway Paper
+ [InlineData(7497)] // TWS Paper
+ public void LiveMode_OnPaperPort_IsRejected(int port)
+ {
+ var problem = IbkrMapping.ValidatePort(port, TradingMode.Live);
+
+ problem.Should().NotBeNull().And.Contain("Paper-Port");
+ }
+
+ [Theory]
+ [InlineData(4002, TradingMode.Paper)]
+ [InlineData(7497, TradingMode.Paper)]
+ [InlineData(4001, TradingMode.Live)]
+ [InlineData(7496, TradingMode.Live)]
+ public void MatchingPortAndMode_IsAccepted(int port, TradingMode mode) =>
+ IbkrMapping.ValidatePort(port, mode).Should().BeNull();
+
+ [Fact]
+ public void NonStandardPort_IsAccepted_ForAnyMode()
+ {
+ IbkrMapping.ValidatePort(4999, TradingMode.Paper).Should().BeNull();
+ IbkrMapping.ValidatePort(4999, TradingMode.Live).Should().BeNull();
+ }
+
+ // ─── Kontrakt ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Stock_BuildsSmartRoutedUsEquity()
+ {
+ var contract = IbkrMapping.Stock(" aapl ");
+
+ contract.Symbol.Should().Be("AAPL");
+ contract.SecType.Should().Be("STK");
+ contract.Exchange.Should().Be("SMART");
+ contract.Currency.Should().Be("USD");
+ }
+
+ // ─── Order ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void BuildOrder_MarketBuy_MapsToMktWithoutLimit()
+ {
+ var request = new OrderRequest
+ {
+ Symbol = "AAPL", Side = TradeSide.Buy, Quantity = 10, Type = OrderType.Market
+ };
+
+ var order = IbkrMapping.BuildOrder(request, orderId: 42, account: "DUR371528");
+
+ order.OrderId.Should().Be(42);
+ order.Action.Should().Be("BUY");
+ order.OrderType.Should().Be("MKT");
+ order.TotalQuantity.Should().Be(10);
+ // double.MaxValue ist der „nicht gesetzt"-Marker der TWS-API. Eine 0 wäre hier ein Fehler:
+ // TWS würde sie als echten Limitpreis von 0 lesen.
+ order.LmtPrice.Should().Be(double.MaxValue);
+ order.Tif.Should().Be("DAY");
+ order.Transmit.Should().BeTrue();
+ order.Account.Should().Be("DUR371528");
+ }
+
+ [Fact]
+ public void BuildOrder_LimitSell_CarriesLimitPrice()
+ {
+ var request = new OrderRequest
+ {
+ Symbol = "MSFT", Side = TradeSide.Sell, Quantity = 5,
+ Type = OrderType.Limit, LimitPrice = 123.45m
+ };
+
+ var order = IbkrMapping.BuildOrder(request, orderId: 7, account: null);
+
+ order.Action.Should().Be("SELL");
+ order.OrderType.Should().Be("LMT");
+ order.LmtPrice.Should().Be(123.45);
+ order.Account.Should().BeNullOrEmpty();
+ }
+
+ [Fact]
+ public void BuildOrder_LimitWithoutPrice_LeavesPriceUnset()
+ {
+ // Eine 0 würde TWS als Limitpreis von 0 lesen; der Marker double.MaxValue heißt „nicht gesetzt".
+ var request = new OrderRequest
+ {
+ Symbol = "MSFT", Side = TradeSide.Buy, Quantity = 1, Type = OrderType.Limit, LimitPrice = null
+ };
+
+ IbkrMapping.BuildOrder(request, orderId: 1, account: null)
+ .LmtPrice.Should().Be(double.MaxValue);
+ }
+
+ // ─── Kurs-Ableitung ───────────────────────────────────────────────────────
+
+ [Fact]
+ public void BuildQuote_PrefersLastTradedPrice()
+ {
+ var quote = IbkrMapping.BuildQuote("AAPL", last: 100, bid: 98, ask: 102, close: 95);
+
+ quote!.Last.Should().Be(100m);
+ quote.Bid.Should().Be(98m);
+ quote.Ask.Should().Be(102m);
+ }
+
+ [Fact]
+ public void BuildQuote_WithoutLast_UsesBidAskMid()
+ {
+ var quote = IbkrMapping.BuildQuote("AAPL", last: 0, bid: 98, ask: 102, close: 95);
+
+ quote!.Last.Should().Be(100m);
+ }
+
+ [Fact]
+ public void BuildQuote_WithOnlyClose_UsesClose()
+ {
+ // Paper-Konten ohne Datenabo liefern außerhalb der Handelszeiten oft nur den Schlusskurs.
+ var quote = IbkrMapping.BuildQuote("AAPL", last: 0, bid: 0, ask: 0, close: 95);
+
+ quote!.Last.Should().Be(95m);
+ quote.Bid.Should().Be(95m);
+ quote.Ask.Should().Be(95m);
+ }
+
+ [Fact]
+ public void BuildQuote_WithoutAnyPrice_ReturnsNull() =>
+ IbkrMapping.BuildQuote("AAPL", last: 0, bid: 0, ask: 0, close: 0).Should().BeNull();
+
+ // ─── Tick- und Status-Klassifizierung ─────────────────────────────────────
+
+ [Theory]
+ [InlineData(4, true)] // LAST
+ [InlineData(68, true)] // DELAYED_LAST
+ [InlineData(1, false)] // BID
+ public void IsLastTick_CoversRealtimeAndDelayed(int field, bool expected) =>
+ IbkrMapping.IsLastTick(field).Should().Be(expected);
+
+ [Theory]
+ [InlineData(1, true)] // BID
+ [InlineData(66, true)] // DELAYED_BID
+ [InlineData(2, false)] // ASK
+ public void IsBidTick_CoversRealtimeAndDelayed(int field, bool expected) =>
+ IbkrMapping.IsBidTick(field).Should().Be(expected);
+
+ [Theory]
+ [InlineData("Filled", true)]
+ [InlineData("Cancelled", true)]
+ [InlineData("ApiCancelled", true)]
+ [InlineData("Inactive", true)]
+ [InlineData("Submitted", false)]
+ [InlineData("PreSubmitted", false)]
+ public void IsTerminalStatus_OnlyForFinalStates(string status, bool expected) =>
+ IbkrMapping.IsTerminalStatus(status).Should().Be(expected);
+
+ [Theory]
+ [InlineData(2104, true)] // Marktdatenzentrum OK
+ [InlineData(2106, true)] // HMDS-Datenzentrum OK
+ [InlineData(2158, true)] // Sec-def-Datenzentrum OK
+ [InlineData(1100, true)] // Verbindung verloren
+ [InlineData(200, false)] // Kontrakt nicht gefunden
+ [InlineData(201, false)] // Order abgelehnt
+ [InlineData(354, false)] // Marktdaten nicht abonniert
+ public void IsInformational_SeparatesStatusFromRealErrors(int code, bool expected) =>
+ IbkrMapping.IsInformational(code).Should().Be(expected);
+
+ [Fact]
+ public void IsInformational_TreatsDelayedDataNoticeAsStatus()
+ {
+ // 10167 kündigt verzögerte Kurse an – die Ticks folgen danach noch. Als Fehler behandelt
+ // scheiterte auf einem Paper-Konto ohne Datenabo jede einzelne Kursabfrage.
+ IbkrMapping.IsInformational(IbkrMapping.DelayedDataNotice).Should().BeTrue();
+ }
+}