R9: Echter IbkrBrokerClient über die TWS API

Broker-Adapter gegen TWS/IB Gateway, aktivierbar über IBKRSettings.UseTwsApi;
NullBrokerClient bleibt Default. TradingEnabled bleibt als zweite, unabhängige
Sicherung bestehen – ohne ihn platziert der ExecutionService keine Order.

Aufteilung (src/IBKRTrader.Core/Trading/Ibkr/):
- IbkrMapping      – reine Abbildung Core <-> TWS (Kontrakt, Order, Kurs, Port-
                     und Statusregeln), vollständig unit-getestet
- IbkrConnection   – Socket-Lebenszyklus, Reader-Thread, reqId-Korrelation über
                     TaskCompletionSource
- IbkrBrokerClient – implementiert IBrokerClient, übersetzt Fehler in leere
                     Ergebnisse (Konto 0 lässt die Risikoprüfung alles ablehnen)

Bewusste Entscheidungen:
- Träges Verbinden mit Wiederholung statt Verbindungsaufbau beim Start: TWS ist
  nach einem Neustart minutenlang nicht bereit.
- Port wird gegen den Handelsmodus geprüft; Paper-Modus auf Live-Port (oder
  umgekehrt) lässt den Broker inaktiv, statt auf dem falschen Konto zu handeln.
- MarketDataType Default 4: Paper-Konten ohne Datenabo bekommen sonst keine Kurse.
- Fehlercode 10167 ist ein Statushinweis (verzögerte Daten folgen), kein Fehler.
  Als Fehler behandelt scheiterte jede einzelne Kursabfrage.

Verifiziert gegen Paper-Konto DUR371528: Verbindung, Konto (100.105,50 EUR),
Kurse (AAPL/MSFT/NVDA, verzögert), Fehlerpfade. Orderpfad bis zur Broker-Annahme
per What-If-Order geprüft (Aktie + Option, ohne Ausführung); dabei zugleich die
Optionsberechtigung des Kontos bestätigt. Offen: echte Ausführung (Fill ->
Buchung) und asynchrone Fill-Verfolgung – beides in IBKR-Integration.md notiert.

Doku: TWS-Setup-Checkliste.md (Einstellungen für Neuinstallation) neu,
IBKR-Integration.md / ARCHITECTURE.md / README.md nachgezogen.

154/154 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-04 17:40:00 +02:00
co-authored by Claude Opus 5
parent 2a312ca035
commit 80afcd49c1
14 changed files with 1427 additions and 45 deletions
+2
View File
@@ -18,6 +18,8 @@
<package pattern="Dapper" />
<package pattern="HtmlAgilityPack" />
<package pattern="Newtonsoft.Json" />
<!-- Offizielle TWS-C#-API (NuGet-Mirror) für den IBKR-Broker-Adapter -->
<package pattern="IB.TWS.CSharpApi" />
<!-- EF Core / Pomelo (MySQL/MariaDB) -->
<package pattern="Microsoft.EntityFrameworkCore.*" />
<package pattern="Pomelo.*" />
+6 -2
View File
@@ -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<IRiskService, RiskService>();
services.AddSingleton<IPortfolioService, PortfolioService>();
services.AddSingleton<IExecutionService, ExecutionService>();
// Sicherer Standard-Broker: handelt nicht, bis der echte IBKR-Adapter verifiziert ist.
services.AddSingleton<IBrokerClient, NullBrokerClient>();
// Echter TWS-Broker nur, wenn ausdrücklich aktiviert sonst der NullBroker, der nie handelt.
if (settingsService.Settings.IBKR.UseTwsApi)
services.AddSingleton<IBrokerClient, IbkrBrokerClient>();
else
services.AddSingleton<IBrokerClient, NullBrokerClient>();
// Core-Worker/Services
services.AddSingleton<BackupWorker>();
+9 -6
View File
@@ -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 (R1R7) abgeschlossen.
- **Accounting**- und **Supervisor**-Modul (inkl. Core-Datenfundament S-0) ergänzt; Live-Abruf (IBKR
Flex / OpenRouter-Key) und Steuerschicht sind bewusst noch offen (Stubs/Platzhalter).
- **Nächster Meilenstein:** IBKR-Broker über die **TWS API / IB Gateway** (blockiert bis Paper-Zugang)
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.
+4 -1
View File
@@ -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
---
+103 -33
View File
@@ -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<int, TCS>` 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 (5600), 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.
+119
View File
@@ -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 |
+241
View File
@@ -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,150,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<OptionChain?> GetOptionChainAsync(string underlying, DateOnly from, DateOnly to, CancellationToken ct);
Task<OptionQuote?> GetOptionQuoteAsync(OptionSpec spec, CancellationToken ct); // + Delta, IV, OpenInterest
Task<IReadOnlyList<BrokerPosition>> 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 | 3045 | Bestes Verhältnis Zeitwertverfall/Handelskosten |
| Ziel-Delta | 0,20 (Band 0,150,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.
+7 -2
View File
@@ -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",
@@ -14,6 +14,9 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.4" />
<!-- Offizielle TWS-C#-API (NuGet-Mirror). Das Paket zielt auf .NET Framework, ist aber reiner
managed Code ohne Framework-Abhängigkeiten und läuft auf net10 NU1701 deshalb unterdrückt. -->
<PackageReference Include="IB.TWS.CSharpApi" Version="9.76.1" NoWarn="NU1701" />
<!-- EF Core / Pomelo (MariaDB 11.8.6). EF 8 laeuft auf net10. -->
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
+30 -1
View File
@@ -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) ────────────────────────────────────
@@ -0,0 +1,103 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Trading.Ibkr;
/// <summary>
/// Echter Broker-Adapter über die TWS API (IB Gateway bzw. TWS, Socket-Verbindung).
///
/// Wird nur registriert, wenn <c>IBKRSettings.UseTwsApi</c> gesetzt ist sonst bleibt der
/// <see cref="NullBrokerClient"/> aktiv. Der globale Handelsschalter
/// (<c>TradingSettings.TradingEnabled</c>) bleibt davon unberührt: ohne ihn platziert der
/// <see cref="ExecutionService"/> 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.
/// </summary>
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<Quote?> 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<AccountState> GetAccountStateAsync(CancellationToken ct = default)
{
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Empty;
return await _connection!.RequestAccountAsync(_requestTimeout, ct).ConfigureAwait(false) ?? Empty;
}
public async Task<OrderResult> 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<bool> IsReadyAsync(CancellationToken ct)
{
if (_connection is null) return false;
return await _connection.EnsureConnectedAsync(ct).ConfigureAwait(false);
}
public void Dispose() => _connection?.Dispose();
}
@@ -0,0 +1,489 @@
using System.Collections.Concurrent;
using System.Globalization;
using IBApi;
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.Trading.Ibkr;
/// <summary>
/// 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 <see cref="TaskCompletionSource{TResult}"/> 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.
/// </summary>
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<int, QuoteSlot> _quotes = new();
private readonly ConcurrentDictionary<int, AccountSlot> _accounts = new();
private readonly ConcurrentDictionary<int, ContractSlot> _contracts = new();
private readonly ConcurrentDictionary<int, OrderSlot> _orders = new();
private readonly ConcurrentDictionary<string, Contract> _contractCache = new(StringComparer.OrdinalIgnoreCase);
private TaskCompletionSource<bool> _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);
}
/// <summary>Kontonummer, die TWS beim Verbinden gemeldet hat (z. B. "DUR371528").</summary>
public string? Account => _account;
// ─── Verbindung ───────────────────────────────────────────────────────────
public async Task<bool> 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<bool> 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 ─────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
public async Task<Contract?> 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 _);
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<Quote?> 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<AccountState?> 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<OrderResult> 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);
/// <summary>
/// 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.
/// </summary>
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<string, string> values, string tag) =>
values.TryGetValue(tag, out var raw) &&
decimal.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0m;
private static TaskCompletionSource<bool> NewTcs() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
private static async Task<bool> 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<bool> 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<string, string> 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;
}
}
@@ -0,0 +1,113 @@
using IBApi;
namespace IBKRTrader.Core.Trading.Ibkr;
/// <summary>
/// Abbildung zwischen Core-Modellen und den TWS-API-Objekten ohne Socket, damit Kontrakt-,
/// Order- und Portregeln ohne laufenden Gateway testbar bleiben.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Der zum Handelsmodus passende Gateway-Port.</summary>
public static int DefaultPortFor(TradingMode mode) =>
mode == TradingMode.Live ? GatewayLivePort : GatewayPaperPort;
/// <summary>US-Aktie über die SMART-Route der einzige Instrumententyp, den die Module handeln.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Endzustände von orderStatus erst hier steht das Ergebnis der Order fest.</summary>
public static bool IsTerminalStatus(string status) =>
status is "Filled" or "Cancelled" or "ApiCancelled" or "Inactive";
/// <summary>Kein Marktdaten-Abo, TWS liefert stattdessen verzögerte Kurse.</summary>
public const int DelayedDataNotice = 10167;
/// <summary>
/// TWS meldet Verbindungs- und Statushinweise über denselben Callback wie echte Fehler:
/// 11001102 sind Verbindungsmeldungen, 21002169 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.
/// </summary>
public static bool IsInformational(int errorCode) =>
errorCode is >= 2100 and <= 2169 or >= 1100 and <= 1102 or DelayedDataNotice;
}
@@ -0,0 +1,198 @@
using FluentAssertions;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Trading.Ibkr;
namespace IBKRTrader.Tests.Trading;
/// <summary>
/// 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).
/// </summary>
[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();
}
}