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(); + } +}