From b96a20788caf0531af0c5ba7870d86808a20e830 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 6 Aug 2026 10:51:51 +0200 Subject: [PATCH] R10: Lesender Bestandsabgleich (IBrokerPortfolioReader) + Datenlage-Konzepte Eigener Seam neben IBrokerClient: Wer handelt, braucht ihn nicht; wer die eigene Buchfuehrung gegen den Broker abstimmt, braucht nur ihn. Zuteilung und Verfall aendern Positionen ohne Order von uns - ohne Abgleich laeuft das Managementbuch zwangslaeufig auseinander. - IBrokerPortfolioReader mit GetPositionsAsync/GetExecutionsAsync; implementiert von IbkrBrokerClient und NullBrokerClient (DI registriert beide Rollen auf derselben Instanz). - IbkrConnection: reqAccountUpdates statt reqPositions (nur dieser Weg liefert Marktwert und unrealisierten G/V), reqExecutions inkl. Zuordnung der verspaetet eintreffenden commissionReport-Callbacks ueber die ExecId. - BrokerPosition/BrokerExecution als Broker-Wahrheit neben Position; IbkrMapping: ParseSide, ParseExecutionTime, FormatExecutionFilterTime (UTC wegen TWS-Warnung 2174) - mit Unit-Tests. - Verifiziert gegen Paper-Konto DUR371528: 2 Positionen, 2 Ausfuehrungen inkl. Kommissionen. Doku: Kapital- und Buchmodell (drei Wahrheiten, Kapitalzuteilung), KONZEPT-Datenlage-und-Strategien (gemessen, was die API auf diesem Konto liefert). Options-Wheel: Greeks bei verzoegerten Daten funktionieren (Feld 83); Earnings-Termine sind ueber die TWS API nicht erreichbar (Fehler 10358) - Behelf ueber IV-Filter statt Fremddatenquelle. Co-Authored-By: Claude Opus 5 --- Program.cs | 13 +- docs/ARCHITECTURE.md | 1 + docs/IBKR-Integration.md | 5 + docs/Kapital-und-Buchmodell.md | 475 ++++++++++++++++++ .../KONZEPT-Datenlage-und-Strategien.md | 206 ++++++++ docs/konzepte/KONZEPT-Modul-OptionsWheel.md | 18 +- .../Trading/IBrokerPortfolioReader.cs | 23 + .../Trading/Ibkr/IbkrBrokerClient.cs | 17 +- .../Trading/Ibkr/IbkrConnection.cs | 171 ++++++- .../Trading/Ibkr/IbkrMapping.cs | 30 ++ .../Trading/NullBrokerClient.cs | 8 +- src/IBKRTrader.Core/Trading/TradingModels.cs | 50 ++ .../Trading/IbkrMappingTests.cs | 42 ++ 13 files changed, 1039 insertions(+), 20 deletions(-) create mode 100644 docs/Kapital-und-Buchmodell.md create mode 100644 docs/konzepte/KONZEPT-Datenlage-und-Strategien.md create mode 100644 src/IBKRTrader.Core/Trading/IBrokerPortfolioReader.cs diff --git a/Program.cs b/Program.cs index dd10d8e..7b2f9b8 100644 --- a/Program.cs +++ b/Program.cs @@ -125,10 +125,19 @@ internal static class Program services.AddSingleton(); services.AddSingleton(); // Echter TWS-Broker nur, wenn ausdrücklich aktiviert – sonst der NullBroker, der nie handelt. + // Beide Rollen (Handel + lesender Bestandsabgleich) bedient dieselbe Instanz. if (settingsService.Settings.IBKR.UseTwsApi) - services.AddSingleton(); + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + } else - services.AddSingleton(); + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + } // Core-Worker/Services services.AddSingleton(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8311e6c..c6789db 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -128,6 +128,7 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin - [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** +- [x] **`IBrokerPortfolioReader`** (Bestand + Ausführungen beim Broker) – eigener Seam neben `IBrokerClient`, Grundlage für den Abgleich der eigenen Buchführung; gegen DUR371528 verifiziert (2 Positionen, 2 Ausführungen inkl. Kommissionen) - [ ] **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 dbc1843..e346287 100644 --- a/docs/IBKR-Integration.md +++ b/docs/IBKR-Integration.md @@ -97,6 +97,11 @@ kommen verzögerte Daten (siehe Marktdaten unten). Modul-Konzept: > **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. +Welche Daten die API auf diesem Konto tatsächlich liefert – und welche Strategien das trägt – +steht gemessen in [konzepte/KONZEPT-Datenlage-und-Strategien.md](konzepte/KONZEPT-Datenlage-und-Strategien.md). +Kurz: Kurshistorie (30 Jahre), Volatilitätshistorie, Optionsketten und Griechen ja; +Fundamentaldaten und Marktscanner nein (Abo nötig). + ### Historie: welcher Gateway? Aktuell laufen die Marktdaten-Worker über die **Client Portal Web API** (`IBKRGatewayService`). Später zu entscheiden: diff --git a/docs/Kapital-und-Buchmodell.md b/docs/Kapital-und-Buchmodell.md new file mode 100644 index 0000000..ff0da18 --- /dev/null +++ b/docs/Kapital-und-Buchmodell.md @@ -0,0 +1,475 @@ +# Kapital- und Buchmodell + +> **Status: Konzept (2026-08-03) – noch nicht implementiert.** +> Dieses Dokument beschreibt, wie mehrere Strategien gleichzeitig auf *einem* IBKR-Konto mit *einem* +> Guthaben arbeiten können, ohne sich gegenseitig zu stören, und wie das gegen die steuerliche +> Buchführung abgegrenzt ist. Es dient als Referenz, gegen die die spätere Umsetzung geprüft wird. + +## 0. Problemstellung und Abgrenzung + +TWS lässt sich pro Rechner nur einmal betreiben – wir sind an **ein** Konto mit **einem** Guthaben +gebunden. Trotzdem sollen mehrere Strategie-Module parallel handeln. Daraus folgen vier Anforderungen: + +1. Jedes Modul bekommt eine **klar begrenzte Kapitalmenge**, die es binden darf. +2. Jede Position hat einen **eindeutigen Eigentümer**. Kein Modul darf die Position eines anderen + Moduls oder eine manuell angelegte Position anfassen. +3. Das Konto darf durch Modul-Handel **niemals ins Minus oder in Margin** laufen. +4. Die **steuerliche Buchführung** ist davon vollständig unabhängig und muss zu 100 % stimmen. + +Nicht Gegenstand dieses Dokuments: Strategielogik, Signalerzeugung, Marktdatenversorgung. + +--- + +## 1. Die drei Wahrheiten + +Es gibt drei Datenquellen mit unterschiedlicher Autorität. Sie dürfen sich nicht vermischen. + +| Ebene | Quelle | Autoritativ für | Latenz | +|---|---|---|---| +| **Steuerbuch** (`acc_`) | Flex Query | Geld, Steuer, GuV – alles gegenüber der Steuerbehörde | T+1 | +| **Depot-Ist** | TWS-API-Snapshot | Was *jetzt* real im Konto liegt | Sekunden | +| **Managementbuch** (`core_`) | eigene Order-Events + Abgleich | Zuordnung von Positionen zu Büchern | live | + +**Harte Regel:** Das Managementbuch beeinflusst das Steuerbuch **niemals** – weder korrigierend noch +ergänzend. Der Accounting-Ingest liest Flex Query, klassifiziert und bucht; ob dabei eine `BookId` +bekannt ist, ist ihm gleichgültig. Die Modulzuordnung ist eine *optionale, nicht-autoritative* +Beistelltabelle (Flex-Trade-ID ↔ BookId). Fehlt sie oder ist sie falsch, ändert sich am Steuerergebnis +exakt nichts. + +Umgekehrt gilt: Das Managementbuch **bezieht** Korrekturen aus dem Steuerbuch (Gebühren, Dividenden, +Splits), nie andersherum. + +--- + +## 2. Bücher (Books) + +Ein **Buch** ist ein Kapitaltopf mit zugeordneten Positionen. Bücher sind: + +| Buch | Bedeutung | Handelt | Limits gelten | +|---|---|---|---| +| je Strategie (`CT`, …) | ein Modul, ggf. ein Parametersatz | ja | ja | +| `MANUAL` | von Hand in TWS/IBKR angelegt | nein (nur extern) | **nein**, zählt aber mit | +| `UNASSIGNED` | am Broker gefunden, keinem Buch zugeordnet | nein | zählt mit | +| `HOUSE` | nicht allokierte Reserve, FX, Ausgleichsposten | nein | – | + +**`BookId` ist ein eigenes Konzept, nicht der Modulname.** Default `BookId == Modulname`, aber die +Trennung erlaubt später mehrere Bücher desselben Moduls und macht `MANUAL`/`UNASSIGNED`/`HOUSE` +sauber modellierbar. Jetzt kostenlos, später teuer nachzurüsten. + +### Eigentumsregeln (im Core erzwungen, nicht per Konvention) + +1. Ein Verkaufssignal von Buch *b* kann **nur Positionen von *b*** reduzieren, gedeckelt auf + `Menge(b, Symbol)`. Darüber hinaus wird gekappt – **niemals** wird ein Short erzeugt. +2. `MANUAL` und `UNASSIGNED` senden keine Signale und empfangen keine. +3. Ein Modul kann seine `BookId` **nicht selbst wählen**. Sie stammt aus der Modul-Registrierung und + wird serverseitig gesetzt; `TradeSignal.SourceModule` aus dem Modul ist nicht vertrauenswürdig. +4. Module bekommen `IBrokerClient` **nicht** per DI – ausschließlich `IExecutionService`. + Sonst ist die gesamte Buchführung umgehbar. +5. Ein Kill-Switch/Supervisor darf alles liquidieren – als explizite Core-Autorität mit eigener + Konfiguration, standardmäßig unter Ausschluss von `MANUAL`. + +--- + +## 3. Kapitalmodell: ein Pool, Obergrenzen + +**Es gibt keinen Kapitaltransfer zwischen Büchern.** Kein Buch-Cash, keine Rebalancing-Läufe, keine +Ausgleichsforderungen. Stattdessen ein gemeinsamer Cash-Pool und pro Buch eine Obergrenze: + +``` +Verteilbar = FreiesCash(gehaircuttet) + Σ Modul-Positionswert − HouseReserve + +Cap(b) = konfigurierter Anteil × Verteilbar (Default: Gleichverteilung) +Gebunden(b) = Marktwert der Positionen von b + offene Reservierungen von b +Headroom(b) = min( Cap(b) − Gebunden(b), freies Pool-Cash ) +``` + +### Eigenschaften, die bewusst so sind + +- **Gewinne verteilen sich von selbst.** Realisiert ein Buch einen Gewinn, wächst der gemeinsame Pool; + da alle Caps aus `Verteilbar` abgeleitet werden, steigt der Headroom **aller** Bücher. Diversifikation + ohne einen einzigen Zwangsverkauf. +- **Ein Buch darf über seinem Cap liegen.** Steigen die Positionen im Wert, passiert nichts – außer + dass nicht mehr nachgekauft werden darf. Der Zustand „über Cap" ist **legal**, wird angezeigt und + gemeldet, aber **nie geheilt**. Es wird niemals eine Position geschlossen, nur um eine Zielquote + herzustellen. +- **`Σ Caps ≤ 100 % − Reserve` wird beim Start validiert.** Sonst könnte ein schnelles Buch ein + langsames aushungern („wer zuerst kommt"). +- **Die Modul-Welt sieht nie den NAV**, sondern nur ihr eigenes Kapital plus freies Cash. Manuelle + Positionen – auch kreditfinanzierte – sind eine Black Box, die nur über das freie Cash wirkt. + +### Kapitalführung ≠ Performance-Messung + +Die Kapitalführung ist ein Pool plus Caps. Die GuV **pro Buch** ist eine reine *Auswertung* über die +Trade-Historie (für Supervisor und Reporting) und steuert nichts. Diese Entkopplung ist beabsichtigt. + +--- + +## 4. Keine Margin – die Sperre + +**Modul-Handel ist strikt cash-only. Manueller Handel darf Margin nutzen.** Der Kontotyp ist +Cash-Konto; brokerseitig kann trotzdem Margin verfügbar sein, deshalb ist die Software-Sperre +zwingend und testpflichtig. + +### Schicht A – richtige Bemessungsgrundlage + +`BuyingPower` und `AvailableFunds` sind auf einem Margin-Konto bereits gehebelt gerechnet und als +Basis unbrauchbar. Maßgeblich ist: + +``` +FreiesCash = min(TotalCashValue, AvailableFunds) − HouseReserve − Σ offene Reservierungen +``` + +`TotalCashValue` ist das echte Bargeld. Eröffnest du manuell eine Margin-Position, sinkt es, und die +Modul-Kaufkraft schrumpft automatisch mit. Bei `TotalCashValue ≤ 0` ist die Modul-Kaufkraft null – +ohne Sonderlogik. Module können einen manuellen Kredit strukturell nicht ausweiten. + +### Schicht B – kein Short + +Verkäufe werden auf die Buchmenge gekappt. Shorts sind implizit Margin. + +### Schicht C – Währung + +Ein Kauf in Währung X darf **nur aus Cash in Währung X** finanziert werden. Andernfalls entsteht ein +Sollsaldo in X – ein Margin-Kredit, auch wenn das Konto in Summe positiv aussieht. Buch-Cash und +Reservierungen werden deshalb **pro Währung** geführt (siehe Abschnitt 7). + +### Schicht D – Watchdog + +Ein Hosted Service prüft Cash und Margin-Kennzahlen laufend. Bei Unterschreitung: **globaler +Kauf-Stopp + Alarm** – und **niemals automatisches Verkaufen** zur Heilung. Ein +Selbstheilungs-Amoklauf ist gefährlicher als der Zustand, den er beheben soll. + +--- + +## 5. Reservierungen (Order-Lebenszyklus) + +Verhindert, dass zwei Module gleichzeitig dasselbe Geld ausgeben. Persistent in der DB, nicht im RAM. + +``` +INTENT → RESERVED → SUBMITTED → FILLED | PARTIAL | CANCELLED | REJECTED | EXPIRED +``` + +- **Reservierungshöhe:** `Menge × Referenzpreis × (1 + Puffer)` + geschätzte Kommission. + Puffer klein bei Limit-Orders (das Limit *ist* die Obergrenze), größer bei Market (Slippage), + zusätzlich FX-Puffer bei währungsübergreifenden Vorgängen. +- **Atomar** gegen Buch-Headroom *und* Pool-Cash, in einer DB-Transaktion hinter einem **globalen + Order-Lock**. Bei einer TWS-Verbindung reicht ein einfacher `SemaphoreSlim`; feingranulare Locks + wären hier nur eine zusätzliche Fehlerquelle. +- **Freigabe** beim Terminalzustand; Differenz zwischen Reservierung und echtem Fill fließt zurück. +- **TTL zwingend.** Erreicht eine Reservierung ohne Terminalzustand ihr Ablaufdatum oder den + Handelsschluss → **Alarm, keine stille Freigabe**. Eine hängende Order, deren Reservierung + freigegeben wird, gibt das Geld zweimal aus. +- **Idempotenz** über `SignalId` bzw. eine Client-Order-ID, damit ein Reconnect keine Doppelorder + erzeugt. +- **Crash-Recovery:** beim Start alle nicht-terminalen Reservierungen gegen die offenen Broker-Orders + abgleichen; Waisen → Quarantäne + Alarm. + +--- + +## 6. Limits und Feedback an die Module + +### Konzentrationslimit pro Einzelwert + +``` +SymbolExposure(s) = Σ_b Marktwert(b, s) ← über ALLE Bücher, inkl. MANUAL +Prüfung: SymbolExposure(s) + geplanter Kauf ≤ MaxSymbolPercent × NAV +``` + +- **NAV als Nenner**, nicht der Positionswert – sonst wird das Limit bei viel Cash absurd eng und bei + wenig Cash absurd weit. +- **`MANUAL` ist von der Prüfung befreit, zählt aber voll in den Zähler.** Hältst du 50 % AAPL von + Hand, sind die Module bei AAPL vollständig gesperrt. So gewollt. +- **Verkäufe sind nie limitiert.** Ein Limit darf niemals das Schließen einer Position verhindern. + +### Feedback-Kontrakt + +Das Gate ist **hart**. Das Modul entscheidet nur, *wie es auf die Ablehnung reagiert* – nicht, ob das +Limit gilt. Ein erneut gesendetes zu großes Signal wird wieder abgelehnt. Der Core trimmt nicht +selbstständig; die Entscheidung liegt auf Strategieebene. + +```csharp +record LimitBreach( + LimitKind Kind, // SymbolConcentration | BookCap | CashAvailable + // | FxBuffer | CurrencyNotAllowed | VenueNotAllowed + string Scope, // "AAPL" | "CT" | "USD" + decimal Current, + decimal Limit, + int MaxQuantity, // was jetzt noch ginge + decimal MaxNotional); +``` + +Ergänzend eine Vorab-Query `GetHeadroom(book, symbol)`, damit ein gut gebautes Modul gar nicht erst +gegen die Wand fährt und seine Order gleich richtig dimensioniert. + +**Drossel:** Ein Modul, das dasselbe Signal wiederholt gegen dieselbe Wand schickt, wird nach N +Versuchen pro `SignalId` gedämpft und protokolliert. Sonst produziert eine schlecht geschriebene +Strategie Logfluten und TWS-Last. + +--- + +## 7. Währungen und Handelsplätze + +**Kontobasiswährung:** konfigurierbar, bei Ersteinrichtung festgelegt, danach gesperrt +(siehe Abschnitt 11 – eine Änderung nach dem ersten Trade macht alle historischen Bewertungen, +Caps und GuV-Zahlen ungültig). + +- Produktion (US LLC): `USD` +- Test/Paper (EU): `EUR` + +### Contract-Auflösung ist Pflicht + +Eine Order mit nacktem Symbol-String über `SMART` kann bei mehrdeutigen Tickern still an einer Börse +in einer anderen Währung landen. Deshalb: + +- Vor jeder Order wird der Contract über `reqContractDetails` aufgelöst; `currency`, `exchange`, + `primaryExchange` und `conId` werden geprüft. +- **Whitelist auf zwei Achsen:** Währung ∈ `AllowedCurrencies` **und** Handelsplatz ∈ konfigurierter + Liste. Verstoß → harte Ablehnung, unabhängig vom Modul. +- Das Ergebnis wird gecacht; **die `conId` wird in der Position gespeichert.** Sie ist IBKRs + eindeutige Instrumenten-ID und deutlich sicherer als ein Ticker. + +### FX-Marge als Sicherheitspuffer, nicht als Exposure-Deckel + +Bei überwiegendem Handel in Kontobasiswährung wäre ein FX-Exposure-Limit meist verletzt und damit +nutzlos. Sinnvoll wirkt die Marge an drei anderen Stellen: + +1. **Bewertungs-Haircut:** Fremdwährungs-Cash geht mit Abschlag in `Verteilbar` ein → das eigene + Kapital wird nie überschätzt. +2. **Reservierungspuffer** bei währungsübergreifenden Käufen, zusätzlich zum Slippage-Puffer. +3. **FX-Exposure als Kennzahl mit Alarmschwelle** – sichtbar und meldepflichtig, ohne Handelssperre. + +### Kein Auto-FX im Order-Pfad + +Fehlt Cash in der Zielwährung, scheitert der Modul-Kauf mit `LimitKind.CashAvailable`. Konvertiert +würde der Order-Pfad selbst, könnte ein Modul indirekt FX-Kosten und FX-Timing auslösen, und die +Reservierungslogik müsste zwei Währungen gleichzeitig sperren. FX bleibt ein expliziter Vorgang +auf `HOUSE`-Ebene. + +--- + +## 8. Abgleich (Reconciliation) + +Weil jederzeit manuell in TWS eingegriffen werden kann, ist der Abgleich kein Zusatz, sondern das +Fundament. Zwei Stufen: + +**Stufe 1 – Ledger ↔ TWS (minütlich):** Handelssicherheit. Stimmen Stückzahlen und Cash? + +| Befund | Bedeutung | Reaktion | +|---|---|---| +| Broker > Ledger | manuell gekauft, oder ein Fill kam nach | Überschuss → `UNASSIGNED` | +| Broker < Ledger | manuell verkauft / Corporate Action | **Break:** betroffene Bücher und Symbol für neue Orders sperren, Alarm | +| Cash weicht ab | Gebühren, Zinsen, FX | Differenz gegen `HOUSE`, ab Schwelle Break | + +Bei „Broker < Ledger" wird bewusst **nicht** automatisch korrigiert. Anteiliges Wegkürzen zerstört +die Zurechenbarkeit – Halt-and-alert, der Mensch entscheidet. + +**Stufe 2 – Ledger ↔ Flex Query (täglich, nach Ingest):** Qualitätssicherung. Hier kommen Gebühren, +Dividenden, Quellensteuer, FX-Differenzen und Splits an – Dinge, die die TWS-API gar nicht oder +schlecht liefert. + +**Gebühren gehören dem verursachenden Buch.** Sonst subventioniert `HOUSE` die vieltradenden Module +und die Performance-Zahlen lügen. Beim Fill wird geschätzt, beim Flex-Abgleich exakt nachjustiert. + +--- + +## 9. Unzugeordnete Positionen und Eskalation + +Zustände: `New → Notified → Acknowledged → Assigned(Book)`, mit `DetectedAt`-Zeitstempel. + +- Ein Watchdog meldet alles, was **länger als 30 Minuten** unbearbeitet liegt. +- **Backoff:** nach 30 min, dann 2 h, dann täglich – sonst wird der Kanal unbrauchbar. +- **Abgestufte Sperrwirkung:** Eine `UNASSIGNED`-Position **zählt voll** in Exposure und + Konzentrationslimit, bremst die Module also automatisch, **sperrt aber nicht hart**. Hart gesperrt + wird nur der gefährliche Fall aus Abschnitt 8 (Broker < Ledger). + +--- + +## 10. Benachrichtigungen + +Im Code existiert derzeit **kein** Benachrichtigungssystem (die Treffer zu „Notification" im +Supervisor-MCP sind JSON-RPC-Notifications, etwas anderes). Sauberer Start. + +``` +INotificationSink Name, SendAsync(Notification, ct) +Notification Severity, Category, Title, Body, DedupKey, Data +NotificationService Fan-out über alle registrierten Sinks +``` + +Drei Eigenschaften müssen von Anfang an drin sein, weil sie später schwer nachzurüsten sind: + +- **Outbox in der DB** (`core_notification`): erst persistieren, dann zustellen, Retry bei Fehler. + Solange kein Zielkanal existiert, sammeln sich Meldungen sichtbar an und werden zugestellt, sobald + ein Sink da ist. Nichts geht verloren. +- **Dedup/Throttling** über `DedupKey` (z. B. `unassigned:AAPL`). +- **Severity-Routing:** Info → Log/UI, Warning → Chat, Critical → alle Kanäle. + +Ausbaureihenfolge: `LogSink` + `UiSink` sofort → `MatrixSink`, sobald der Server steht → +`TelegramSink` optional als Backup. + +**Matrix** ist reines HTTP, keine Library nötig: +`PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` mit dem Access-Token eines +Bot-Users. Die `txnId` ist Matrix' eingebauter Idempotenz-Schlüssel und passt exakt auf die +Outbox-ID. Das Token gehört in `SecretProtection`. + +--- + +## 11. Steuerbuch: Jurisdiktionsprofil + +**Pro Deployment gibt es genau eine Jurisdiktion, und sie wechselt nie.** Ein Kontowechsel zwischen +Ländern findet nicht statt – die Option „später auch in Deutschland" bedeutet ausdrücklich: anderer +Server, andere Datenbank, anderes IBKR-Konto. Daraus folgt: keine Migration, keine +Mehrmandantenfähigkeit, kein Umschalten zur Laufzeit. + +Das Profil ist **kein Währungs-Flag**, sondern ein Austausch der gesamten Lot- und +Klassifikationslogik: + +| Aspekt | US LLC (Hauptziel) | DE privat (Option) | DE GmbH (Option) | +|---|---|---|---| +| Berichtswährung | USD, keine Umrechnung | EUR, Umrechnung pro Vorgang | EUR, Umrechnung pro Vorgang | +| Lot-Matching | FIFO Standard, Specific ID wählbar | FIFO zwingend | Bilanzierung | +| Wash Sale (30 Tage) | **ja**, Basis-Anpassung | kein Äquivalent | kein Äquivalent | +| Haltefrist | short/long term ab 1 Jahr | irrelevant | irrelevant | +| Verlustverrechnung | Carryforward mit Deckel | getrennte Töpfe | wieder anders | +| Quellensteuer | – | Anrechnung nach DBA | Anrechnung | +| Ausgabe | 1099-B-Abgleich / K-1 | Anlage KAP | Bilanz/GuV | + +Die **Wash-Sale-Regel** ist die einschneidendste: sie verändert Anschaffungskosten rückwirkend. Eine +Engine, die nur FIFO kann, lässt sich dafür nicht nachrüsten. Deshalb muss die Lot-Verwaltung von +Anfang an lot-basiert sein und **Basis-Anpassungen als eigene Buchungsart** kennen – auch wenn das +erste Profil sie noch nicht nutzt. + +> Die konkreten steuerlichen Ausprägungen (u. a. ob die LLC als Disregarded Entity oder Partnership +> behandelt wird, was das Reporting ändert) sind mit dem Steuerberater abzustimmen. Hier steht nur, +> was die Software mechanisch abbilden können muss. + +### Profile und Umgebungen + +``` +Test/Paper (EU) BaseCurrency EUR TaxProfile None +Produktion (US) BaseCurrency USD TaxProfile UsLlc +Option DE BaseCurrency EUR TaxProfile DePrivat | DeGmbh +``` + +Das Profil **`None`/`Paper`** führt vollständig Buch, rechnet aber keine Steuer und ist in UI und +allen Exporten sichtbar als solches markiert. Deutsche Steuerregeln auf Paper-Trades zu rechnen wäre +sinnlos und produziert Zahlen, die jemand später für echt halten könnte. + +### Wo der Lock hingehört + +**Nicht in `appsettings.json`.** Eine Datei lässt sich editieren, und dann rechnet die Engine ab +morgen nach anderen Regeln über denselben Bestand – der schlimmstmögliche Fehler, weil er sich still +auswirkt. + +Der Lock gehört **in die Datenbank, neben die Daten, die er regiert**: eine Stempelzeile im +Accounting-Schema mit Jurisdiktion, Berichtswährung, Kontobasiswährung und Einrichtungszeitpunkt. +Beim Start wird die Konfiguration dagegen geprüft; bei Abweichung startet das Accounting-Modul nicht, +sondern meldet einen Konflikt. Gesetzt wird der Stempel bei der Ersteinrichtung, geändert nur durch +eine leere Datenbank. + +### Was deshalb *nicht* gebaut wird + +Die EZB-Kursanbindung (Kursquelle, Stichtagsregeln, Kursarchivierung) ist für das Hauptziel nicht +nötig: USD-Konto, USD-Reporting, keine Umrechnung. Also nur die **Naht** bauen (`IFxRateSource`, im +US-Profil eine No-Op), nicht die Implementierung. Wird die DE-Option je gezogen, geschieht das +ohnehin auf einem anderen Server – dann wird sie dort ergänzt. + +Falls doch: EZB-Referenzkurse autoritativ, IBKR-Bewertungskurse nur als Kontrollgröße; **verwendeter +Kurs, Quelle, Stichtag und Abrufzeitpunkt werden mitgespeichert**, nicht nur der umgerechnete Betrag. + +--- + +## 12. Datenmodell-Deltas + +| Heute | Delta | +|---|---| +| `core_position` (PK `Module`+`Symbol`) | `Module` → `BookId`; **`ConId`, `Currency`, `Exchange` ergänzen** | +| `core_budget` (Total/Used/MaxPerTrade) | ersetzen durch `core_book`: Cap-Regel, Flags (ReadOnly, Active), Mindestordergröße | +| – | `core_reservation` (Zustandsmaschine, TTL, Währung) | +| – | `core_broker_snapshot` + `core_reconciliation_break` | +| – | `core_notification` (Outbox) | +| – | `core_instrument` (aufgelöste Contracts, `conId`-Cache, Whitelist-Status) | +| – | `acc_jurisdiction` (Stempel, Abschnitt 11) | +| ~~Buch-Cash, Transfers, Rebalance-Lauf~~ | **entfällt** – ein Pool plus Caps (Abschnitt 3) | +| `RiskContext.NetLiquidation` | → `Verteilbar` und `Headroom(b)`, **nicht** NAV | +| `IbkrConnection`: `NetLiquidation,AvailableFunds` | + `TotalCashValue`, `SettledCash`, Margin-Kennzahlen, **pro Währung** | +| `IBrokerClient` | + `GetPositionsAsync`, + `ResolveContractAsync` | +| `ExecutionService` (Check-then-Act ohne Lock) | Order-Gateway davor: Serialisierung + Reservierung | +| `ExecutionResult.Reason` (string) | + strukturiertes `LimitBreach` (Abschnitt 6) | + +--- + +## 13. Invarianten und Testkatalog + +Die No-Margin-Bedingung wird als **eine ausführbare Invariante** formuliert, die an drei Stellen +dieselbe Codebahn nimmt: + +``` +∀ Währung c: FreiesCash(c) ≥ 0 + ∧ Σ offene Reservierungen(c) ≤ FreiesCash(c) +∀ Buch b, Symbol s: Menge(b, s) ≥ 0 +∀ Symbol s: Σ_b Menge(b, s) = BrokerMenge(s) +``` + +1. als Assert am Ende **jedes** Trading-Tests (gemeinsamer Testhelfer, nicht pro Test neu geschrieben) +2. als Property-Test über zufällige Sequenzen aus Reservierung / Fill / Teilfill / Cancel / Slippage / + manuellem Eingriff +3. als Laufzeitprüfung im Watchdog – **identische Implementierung**, damit Test und Produktion nicht + auseinanderlaufen + +### Pflichtfälle + +| Fall | Erwartung | +|---|---| +| Zwei parallele Reservierungen, zusammen > freies Cash | zweite abgelehnt | +| N nebenläufige Tasks (Stresstest) | Σ Reservierungen nie > freies Cash | +| Fill teurer als reserviert, innerhalb Puffer | ok, Restfreigabe korrekt | +| Fill jenseits des Puffers | Alarm, Cash bleibt ≥ 0 | +| Manuelle Margin-Position taucht auf, Cash < Reserve | alle Modul-Käufe gesperrt | +| Cash in Zielwährung = 0, andere Währung vorhanden | abgelehnt – **kein** stiller Fremdwährungskredit | +| Verkaufssignal über Buchmenge hinaus | gekappt, kein Short | +| Reservierung erreicht TTL | Alarm, **keine** stille Freigabe | +| Neustart mit offenen Reservierungen | aus DB rekonstruiert, keine Doppelausgabe | +| Modul setzt fremde `BookId` im Signal | serverseitig überschrieben | +| Verkaufssignal gegen `MANUAL` | abgelehnt | +| Contract löst auf fremde Währung/Börse auf | abgelehnt vor Ordersendung | + +--- + +## 14. Bewusst verschoben + +- **Self-Cross-Netting** (Modul A kauft, Modul B verkauft dasselbe Symbol gleichzeitig). Bei der + erwarteten Handelsfrequenz unrealistisch und allenfalls eine Ausnahmeerscheinung. + **Billige Vorstufe jetzt:** Ist im Order-Gateway eine gegenläufige Order für dasselbe Symbol + pending, wird das protokolliert und gewarnt. Der Lock ist ohnehin da – das kostet fast nichts und + liefert Daten darüber, ob das Problem je real wird. +- **Corporate Actions** (Splits, Spin-offs) in der Buchzuordnung – vorerst über den Flex-Abgleich als + Break sichtbar, manuelle Zuordnung. +- **Echte Sub-Accounts** bei IBKR (Advisor/Family-Struktur) als brokerseitige Trennung. Erwogen und + verworfen: erfordert Kontotypwechsel und feste Vorabaufteilung des Kapitals, deutlich unflexibler + als virtuelle Bücher. + +--- + +## 15. Offene Punkte + +- **Mindestordergröße** pro Buch: ein Wert in Kontobasiswährung, Fremdwährungsorders per Tageskurs + dagegen geprüft. Konkreter Wert beim Bauen festzulegen. +- **Konkrete Limitwerte** (`MaxSymbolPercent`, `HouseReserve`, FX-Haircut, Slippage-Puffer, TTL) + – Startwerte beim Bauen festzulegen und in `settings.example.json` dokumentieren. +- **Handelsplatz-Whitelist**: konkrete Börsenliste je Währung. +- **Umsetzungsreihenfolge** – noch nicht besprochen. + +--- + +## Zusammenfassung der getroffenen Entscheidungen + +| # | Entscheidung | +|---|---| +| 1 | Cash-Konto; Modul-Handel strikt cash-only, manueller Handel darf Margin nutzen | +| 2 | Kein Kapitaltransfer zwischen Büchern – ein Pool plus Obergrenzen; **niemals** aktives Schließen zum Balancieren | +| 3 | Limit-Verletzung: hartes Gate, strukturiertes Feedback; die Reaktion entscheidet die Strategie | +| 4 | Kontobasiswährung USD (Produktion) / EUR (Test), bei Ersteinrichtung gesetzt und danach gesperrt | +| 5 | Nur USD und EUR handelbar; Contract-Auflösung mit Währungs- und Handelsplatz-Whitelist ist Pflicht | +| 6 | Steuerbuch strikt getrennt, Flex Query als einzige Quelle, Jurisdiktionsprofil einmalig und in der DB verankert | +| 7 | Unzugeordnete Positionen: Quarantäne + Eskalation nach 30 Minuten mit Backoff | +| 8 | Benachrichtigungen über Outbox + Sink-Abstraktion; Matrix als Zielkanal, Telegram optional | diff --git a/docs/konzepte/KONZEPT-Datenlage-und-Strategien.md b/docs/konzepte/KONZEPT-Datenlage-und-Strategien.md new file mode 100644 index 0000000..2521ce3 --- /dev/null +++ b/docs/konzepte/KONZEPT-Datenlage-und-Strategien.md @@ -0,0 +1,206 @@ +# Analyse: Datenlage über die TWS API – und welche Strategien sie trägt + +> Stand: 2026-08-04. **Alle Angaben in Abschnitt 1 und 2 sind gegen das laufende Paper-Gateway +> gemessen** (Konto DUR371528, TWS API 9.76.1, `MarketDataType = 4`), nicht aus der IBKR-Doku +> übernommen. Wo etwas nur plausibel, aber ungeprüft ist, steht es ausdrücklich dabei. +> +> Zweck: entscheiden, welche Strategien wir **ohne zusätzliche Datenanbieter** bauen können – +> und welche wir uns sparen, weil die Datengrundlage fehlt. + +--- + +## 0. Kurzfassung + +Die TWS API deckt **Preis-, Volatilitäts- und Optionsdaten sehr gut ab** und **Fundamentaldaten +gar nicht**. Zwei Grenzen bestimmen den Zuschnitt jeder Strategie: + +1. **Kein Screening.** Der Marktscanner ist gesperrt (Realtime-Abo nötig). Wir können den Markt + nicht nach Kandidaten durchsuchen – jede Strategie muss auf einer **fest gepflegten Watchlist** + arbeiten. Das deckt sich mit der Festlegung im [OptionsWheel-Konzept](KONZEPT-Modul-OptionsWheel.md). +2. **Nur verzögerte Kurse (~15 Min).** Alles, was auf Intraday-Reaktion beruht, fällt weg. + Entscheidungen auf Tages- oder Wochenbasis sind davon **nicht** betroffen. + +Innerhalb dieser Grenzen ist die Lage gut: 30 Jahre Kurshistorie, dividendenbereinigte Serien, +Volatilitätshistorie, vollständige Optionsketten und **funktionierende Griechen auch mit +verzögerten Daten**. Das trägt Prämienstrategien und Trendfolge auf Tagesbasis ohne jeden +externen Anbieter. + +**Empfehlung:** OptionsWheel als erstes Modul weiterbauen – es ist die Strategie mit dem besten +Verhältnis von vorhandener Datengrundlage zu Ertragserwartung, und die Voraussetzungen sind +inzwischen alle geprüft. + +--- + +## 1. Was die API liefert (gemessen) + +### 1.1 Kurshistorie – die stärkste Säule + +| Auflösung | Verfügbarer Zeitraum | Gemessen an AAPL | +|---|---|---| +| Monatsbars | **30 Jahre** | 361 Bars ab 1996-08 | +| Wochenbars | 15 Jahre | 783 Bars ab 2011-08 | +| Tagesbars | 10 Jahre | 2.511 Bars ab 2016-08 | +| Stundenbars | 2 Jahre | 3.488 Bars | +| Minutenbars | 30 Tage | 11.453 Bars | +| Einzelticks | `reqHistoricalTicks` | 351 Ticks für 2 Tage zurück | + +Zusätzlich: +- **`ADJUSTED_LAST`** – dividendenbereinigte Tageskurse. Wichtig: unbereinigte Serien erzeugen bei + jeder Ausschüttung ein Scheinsignal in Momentum- und Mean-Reversion-Rechnungen. +- **`HISTORICAL_VOLATILITY`** – realisierte Volatilität als Zeitreihe (122 Tage je Abruf). +- **`OPTION_IMPLIED_VOLATILITY`** – **IV-Historie des Basiswerts** (122 Tage je Abruf). + Längere Reihen lassen sich durch wiederholte Abrufe mit gesetztem `endDateTime` zusammensetzen. + +Die letzten beiden sind der eigentliche Schatz: aus ihnen lässt sich **IV-Rank / IV-Perzentil** +rechnen – die zentrale Kennzahl für jede Prämienstrategie (verkaufe Volatilität, wenn sie relativ +zu ihrer eigenen Geschichte teuer ist). + +### 1.2 Optionen + +| Datenart | Ergebnis | +|---|---| +| Optionskette (`reqSecDefOptParams`) | 24 Verfallstermine, 127 Strikes, Multiplier 100, TradingClass | +| Griechen (`tickOptionComputation`) | **IV, Delta, Gamma, Vega, Theta + Basiswertkurs** | +| Handelsberechtigung | vorhanden (What-If-Order angenommen) | + +**Wichtigster Einzelbefund:** Die Griechen kommen **auch ohne Realtime-Abo**. Gemessen an +`AAPL 20260821 C305`: IV 0,2730 / Delta 0,5443 / Gamma 0,0220 / Vega 0,2626 / Theta −0,2304. +Sie laufen über die verzögerten Tick-Felder 80–83, wobei **Feld 83 (Modell) die relevante Größe** +für eine delta-basierte Strike-Wahl ist. + +Damit ist der offene Punkt 2 aus dem [OptionsWheel-Konzept](KONZEPT-Modul-OptionsWheel.md) +beantwortet: Die geplante delta-basierte Strike-Wahl im Zielband 0,15–0,30 ist umsetzbar, die +Ersatzlösung über prozentualen Abstand wird nicht gebraucht. Verzögerung heißt: Der Delta-Wert ist +~15 Minuten alt – für die Auswahl eines Strikes mit 30–45 Tagen Restlaufzeit ist das belanglos. + +### 1.3 Stammdaten, Nachrichten, Sonstiges + +| Datenart | Ergebnis | +|---|---| +| Kontraktstammdaten | Branche/Kategorie/Unterkategorie (AAPL: Technology / Computers / Computers), Langname, **Handelszeiten**, Zeitzone, MinTick, gültige Börsen | +| Symbolsuche (`reqMatchingSymbols`) | Fuzzy-Suche, liefert auch Indizes | +| Nachrichten | 3 Anbieter: **BRFG** (Briefing.com Markt), **BRFUPDN** (Analystenaktionen), **DJNL** (Dow Jones Newsletters) | +| Historische Nachrichten | funktioniert, Schlagzeilen mit Zeitstempel und Anbieter | +| Konto, Bestand, Ausführungen | siehe [IBKR-Integration.md](../IBKR-Integration.md) | + +Die Branchenklassifikation ist brauchbar für **Klumpenrisiko-Prüfungen** ("nicht drei Positionen +im selben Sektor"). Die Handelszeiten sind operativ wichtig: Market-Orders außerhalb der RTH +bleiben ohne Fill hängen. + +`BRFUPDN` ist bemerkenswert – die Schlagzeilen sind maschinell auswertbar strukturiert: + +``` +2026-04-17 12:00:57 [BRFUPDN] !BNP Paribas Exane upgraded Apple (AAPL) to Outperform +2026-04-14 14:58:42 [BRFUPDN] !BofA Securities reiterated Apple (AAPL) coverage with Buy and target $325 +``` + +--- + +## 2. Was die API nicht liefert + +| Datenart | Fehler | Bedeutung | +|---|---|---| +| **Fundamentaldaten**, alle Reports (`ReportSnapshot`, `ReportRatios`, `ReportsFinStatements`, `RESC`, `CalendarReport`) | 10358 „Fundamentaldaten nicht zulässig" | Kein Refinitiv-Abo auf dem Konto | +| **Fundamentalkennzahlen-Tick 258** (KGV, Marktkapitalisierung, Dividendenrendite, Beta) | 10358 | dito | +| **Marktscanner-Ausführung** (alle getesteten scanCodes) | 492 „zusätzliche Berechtigungen" | Realtime-Marktdatenabo nötig | +| **Realtime-Kurse** | 10167 / 10091 | nur verzögerte Daten (~15 Min) | +| **Histogramm** (`reqHistogramData`) | leer | ungeklärt, vermutlich abo-abhängig | + +Die **Scanner-Parameter** (762 scanCodes) lassen sich zwar abrufen, aber nur als Metadaten – jeder +Scanner-Lauf wird abgelehnt. + +### Drei Konsequenzen, die den Zuschnitt bestimmen + +1. **Keine fundamentale Titelauswahl.** Kennzahlenbasierte Ansätze (Value, Quality, Growth) sind + ohne Zusatzquelle nicht baubar. Das ist verkraftbar, weil unsere beiden geplanten Module + (CongressTrading, OptionsWheel) ihre Kandidaten ohnehin anders bestimmen. +2. **Keine Termine für Quartalszahlen.** `CalendarReport` ist gesperrt. Damit ist der offene + Punkt 4 des OptionsWheel-Konzepts („Earnings-Sperre") **nicht** über die TWS API lösbar – er + braucht eine externe Quelle oder eine manuell gepflegte Liste. Das ist die einzige Stelle, an + der uns eine externe Abhängigkeit ernsthaft fehlt. + Behelf ohne externe Quelle: Ein **IV-Anstieg** vor Quartalszahlen ist messbar. Ein Filter + „keine neuen Legs, wenn die IV des Basiswerts stark über ihrem 30-Tage-Mittel liegt" fängt + Earnings indirekt mit ab – unschärfer, aber ohne Fremddaten. +3. **Kein Universum-Screening.** Strategien müssen mit einer gepflegten Watchlist auskommen. + +--- + +## 3. Strategiebewertung + +### 3.1 Gut umsetzbar – Datenlage vollständig + +| Strategie | Benötigte Daten | Status | +|---|---|---| +| **Options-Wheel** (Cash-Secured Put → Zuteilung → Covered Call) | Kette, Griechen, IV-Historie, Bestand, Berechtigung | alles vorhanden, [Konzept steht](KONZEPT-Modul-OptionsWheel.md) | +| **Covered Calls auf Bestand** | wie oben, ohne Put-Seite | Teilmenge des Wheel | +| **IV-Rank-gesteuerter Prämienverkauf** | `OPTION_IMPLIED_VOLATILITY` + `HISTORICAL_VOLATILITY` | vorhanden; liefert das Timing-Kriterium für das Wheel | +| **Trendfolge auf Tagesbasis** (gleitende Durchschnitte, Ausbrüche, ATR-Stops) | `ADJUSTED_LAST` Tagesbars, 10 J | vorhanden | +| **Relative Stärke / Dual Momentum** über die Watchlist | Tagesbars mehrerer Titel | vorhanden | +| **Mean Reversion** (RSI, Bollinger, Abstand zum gleitenden Mittel) | Tagesbars | vorhanden | +| **Paar-Handel** korrelierter Titel | Tagesbars, lange Historie | vorhanden | +| **Saisonalität / Kalendereffekte** | 30 Jahre Monatsbars | vorhanden | +| **Sektor-Klumpenrisiko-Prüfung** | Branchenklassifikation | vorhanden; gehört in den `RiskService` | + +Allen gemeinsam: Sie entscheiden **auf Schlusskursen oder mit Stunden-/Tagesbezug**. Die +15-Minuten-Verzögerung ist dabei irrelevant, weil die Signale ohnehin aus abgeschlossenen Bars +kommen. Genau deshalb passen sie zu unserer Datenlage. + +### 3.2 Bedingt umsetzbar + +| Strategie | Einschränkung | +|---|---| +| **Analystenaktionen als Filter oder Signal** (`BRFUPDN`) | Schlagzeilen sind strukturiert und auswertbar. **Ungeprüft**: ob `reqNewsArticle` den Volltext liefert und wie weit die Historie zurückreicht. Als *Risikofilter* („keine neue Position kurz nach einer Abstufung") wertvoller denn als Einstiegssignal. | +| **Volatilitäts-Ausbruch intraday** | Minutenbars gibt es (30 Tage), aber nur verzögert. Rückrechnung möglich, Live-Handel nicht. | +| **Gap-Strategien auf Eröffnung** | Eröffnungskurs kommt verzögert; die Ausführung träfe den Markt 15 Minuten zu spät. Nur mit Realtime-Abo sinnvoll. | + +### 3.3 Nicht umsetzbar + +| Strategie | Grund | +|---|---| +| Fundamentales Screening (Value, Quality, Growth) | keine Fundamentaldaten | +| Earnings-Strategien (Straddle vor Zahlen, Post-Earnings-Drift) | keine Termine für Quartalszahlen | +| Marktweite Anomalie-Suche / Screener-getriebene Auswahl | Scanner gesperrt | +| Daytrading, Scalping, Orderbuch-Strategien | verzögerte Daten, keine Markttiefe geprüft | +| Nachrichten-Sentiment in der Breite | nur 3 Anbieter, Schlagzeilen | + +--- + +## 4. Empfehlung + +**Reihenfolge:** + +1. **OptionsWheel** wie im [bestehenden Konzept](KONZEPT-Modul-OptionsWheel.md) bauen. Alle + Voraussetzungen sind jetzt geprüft: Berechtigung, Kette, Griechen mit verzögerten Daten, + Bestandsabgleich. Der einzige offene Punkt ist die Earnings-Sperre – dafür zunächst den + IV-Behelf aus Abschnitt 2 einsetzen und die Entscheidung über eine externe Quelle vertagen. +2. **IV-Rank als Core-Baustein** ziehen, nicht als Modul-Interna. Die Kennzahl ist für jede + Prämienstrategie nötig und gehört neben die Kurshistorie in die Datenschicht. +3. **Trendfolge/Momentum auf Tagesbasis** als zweites Modul, wenn ein zweites Standbein gewünscht + ist. Datenlage ist komfortabel, das Risiko liegt in der Strategie, nicht in den Daten. + +**Was ein Zusatzabo ändern würde** (Reihenfolge nach Nutzen je Euro): + +| Abo | Schaltet frei | Für uns relevant? | +|---|---|---| +| US-Aktien-Realtime (NYSE/AMEX/NASDAQ) | Scanner, Realtime-Kurse | Nur wenn wir Screening oder Intraday wollen. Für Tages- und Prämienstrategien **nicht nötig**. | +| OPRA (Optionen-Realtime) | Realtime-Optionskurse und -Griechen | Verbessert die Ausführungsqualität beim Wheel; für die Strike-Auswahl nicht erforderlich. | +| Refinitiv-Fundamentaldaten | Kennzahlen, Bilanzen, **Termine für Quartalszahlen** | Löst die Earnings-Sperre und öffnet fundamentale Ansätze. Der Kandidat mit dem größten qualitativen Sprung. | + +Alle drei sind **Erweiterungen, keine Voraussetzungen**. Der aktuelle Stand trägt die geplanten +Strategien. + +--- + +## 5. Offen / ungeprüft + +1. **`reqNewsArticle`** (Volltext zu einer Schlagzeile) – nicht getestet. Entscheidet, ob + `BRFUPDN` mehr als ein Ereignis-Marker sein kann. +2. **Wie weit die Nachrichtenhistorie zurückreicht** – im Test kamen Meldungen bis 2026-03, + abgefragt waren 30 Tage. Die Zeitfilter-Semantik ist offenbar anders als angenommen und + sollte vor produktiver Nutzung geklärt werden. +3. **Markttiefe** (`reqMktDepth`) – nicht getestet, für unsere Strategien vermutlich unnötig. +4. **`reqHistogramData`** liefert leer – Ursache ungeklärt (Abo oder Parameter). +5. **Wie zuverlässig verzögerte Griechen außerhalb der Handelszeiten sind** – gemessen wurde + während der US-Handelszeit. Außerhalb liefert TWS ggf. eingefrorene Werte. +6. **Ratenbegrenzung** bei Historienabrufen (IBKR drosselt `reqHistoricalData` bei zu vielen + Anfragen). Für einen nächtlichen Watchlist-Abruf relevant, im Test nicht ausgereizt. diff --git a/docs/konzepte/KONZEPT-Modul-OptionsWheel.md b/docs/konzepte/KONZEPT-Modul-OptionsWheel.md index fe86d9c..5c50e07 100644 --- a/docs/konzepte/KONZEPT-Modul-OptionsWheel.md +++ b/docs/konzepte/KONZEPT-Modul-OptionsWheel.md @@ -228,13 +228,19 @@ W-0 bis W-3 sind Core-Arbeit und nützen auch den anderen Modulen; erst ab W-4 e 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. +2. ~~**Greeks bei verzögerten Daten.**~~ → **erledigt am 2026-08-04, funktioniert.** Gemessen an + `AAPL 20260821 C305`: IV 0,2730 / Delta 0,5443 / Gamma 0,0220 / Vega 0,2626 / Theta −0,2304. + Die Werte kommen über die verzögerten Tick-Felder 80–83; **Feld 83 (Modell)** ist die für die + Strike-Wahl maßgebliche Variante. Die delta-basierte Wahl im Band 0,15–0,30 ist damit umsetzbar, + die Ersatzlösung über prozentualen Abstand wird nicht gebraucht (der `StrikeSelector` behält sie + trotzdem als Rückfalllinie). Details: [KONZEPT-Datenlage-und-Strategien.md](KONZEPT-Datenlage-und-Strategien.md). 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. +4. **Earnings-Sperre**: keine neuen Legs über Quartalszahlen hinweg. **Geprüft am 2026-08-04: über + die TWS API nicht lösbar** – `CalendarReport` und alle übrigen Fundamentaldaten sind auf dem + Konto gesperrt (Fehler 10358, Refinitiv-Abo nötig). Das ist die einzige Stelle, an der uns eine + externe Quelle ernsthaft fehlt. **Behelf ohne Fremddaten:** ein IV-Filter – keine neuen Legs, + wenn die implizite Volatilität des Basiswerts deutlich über ihrem 30-Tage-Mittel liegt. Fängt + den Earnings-Anstieg indirekt mit ab, unschärfer, aber ohne Abhängigkeit. 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. diff --git a/src/IBKRTrader.Core/Trading/IBrokerPortfolioReader.cs b/src/IBKRTrader.Core/Trading/IBrokerPortfolioReader.cs new file mode 100644 index 0000000..1788690 --- /dev/null +++ b/src/IBKRTrader.Core/Trading/IBrokerPortfolioReader.cs @@ -0,0 +1,23 @@ +namespace IBKRTrader.Core.Trading; + +/// +/// Lesender Zugriff auf Bestand und Ausführungen beim Broker. +/// +/// Bewusst getrennt von : dort geht es um das Auslösen von Handel, +/// hier um den Abgleich der eigenen Buchführung mit dem, was der Broker tatsächlich führt. +/// Wer nur handelt, braucht das nicht; wer abstimmt, braucht nur das. +/// +public interface IBrokerPortfolioReader +{ + /// Alle offenen Positionen des Kontos, inklusive Bewertung. Leer, wenn nicht erreichbar. + Task> GetPositionsAsync(CancellationToken ct = default); + + /// + /// Ausführungen ab (null = alles, was der Broker liefert). + /// + /// Grenze der TWS-API: sie liefert nur den **aktuellen Handelstag**, unabhängig vom + /// gewünschten Zeitraum. Für ältere Trades bleibt der Flex-Query-Ingest des Accounting-Moduls + /// die richtige Quelle. + /// + Task> GetExecutionsAsync(DateTime? since = null, CancellationToken ct = default); +} diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs index 952b547..682fac5 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs @@ -15,7 +15,7 @@ namespace IBKRTrader.Core.Trading.Ibkr; /// 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 +public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IDisposable { private const string LogModule = "IBKR"; @@ -91,6 +91,21 @@ public sealed class IbkrBrokerClient : IBrokerClient, IDisposable return result; } + public async Task> GetPositionsAsync(CancellationToken ct = default) + { + if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty(); + + return await _connection!.RequestPositionsAsync(_requestTimeout, ct).ConfigureAwait(false); + } + + public async Task> GetExecutionsAsync(DateTime? since = null, + CancellationToken ct = default) + { + if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty(); + + return await _connection!.RequestExecutionsAsync(since, _requestTimeout, ct).ConfigureAwait(false); + } + private static AccountState Empty => new(0m, 0m); private async Task IsReadyAsync(CancellationToken ct) diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs index 477d569..9d93dd7 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs @@ -32,8 +32,12 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable 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 readonly ConcurrentDictionary _orders = new(); + private readonly ConcurrentDictionary _executions = new(); + private readonly ConcurrentDictionary _contractCache = new(StringComparer.OrdinalIgnoreCase); + + // Portfolio-Abruf ist ein kontoweites Abonnement, keine reqId-Anfrage – daher nur ein Slot. + private PortfolioSlot? _portfolio; private TaskCompletionSource _handshake = NewTcs(); private volatile bool _ready; @@ -256,6 +260,85 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable } } + /// + /// Bestand samt Bewertung. Nutzt reqAccountUpdates statt reqPositions, weil nur + /// dieser Weg Marktwert und unrealisierten G/V mitliefert. Es ist ein Abonnement – wir melden + /// uns nach dem ersten vollständigen Stand wieder ab. + /// + public async Task> RequestPositionsAsync(TimeSpan timeout, CancellationToken ct) + { + var account = _account; + if (string.IsNullOrWhiteSpace(account)) + { + _logger.Warn(LogModule, "Kontonummer unbekannt – Positionen nicht abrufbar."); + return Array.Empty(); + } + + var slot = new PortfolioSlot(); + if (Interlocked.CompareExchange(ref _portfolio, slot, null) is not null) + { + _logger.Warn(LogModule, "Es läuft bereits eine Positionsabfrage."); + return Array.Empty(); + } + + try + { + _socket.reqAccountUpdates(true, account); + + if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false)) + { + _logger.Warn(LogModule, "Zeitüberschreitung bei der Positionsabfrage."); + return Array.Empty(); + } + + return slot.Positions; + } + finally + { + try { _socket.reqAccountUpdates(false, account); } + catch { /* Verbindung bereits weg. */ } + Interlocked.Exchange(ref _portfolio, null); + } + } + + /// + /// Ausführungen. Kommissionen kommen über einen eigenen Callback und treffen oft erst nach + /// execDetailsEnd ein – deshalb die kurze Nachlauffrist, bevor zusammengeführt wird. + /// + public async Task> RequestExecutionsAsync(DateTime? since, + TimeSpan timeout, CancellationToken ct) + { + var id = NextRequestId(); + var slot = new ExecutionSlot(); + _executions[id] = slot; + try + { + var filter = new ExecutionFilter(); + if (since is { } from) filter.Time = IbkrMapping.FormatExecutionFilterTime(from); + + _socket.reqExecutions(id, filter); + + if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false)) + { + _logger.Warn(LogModule, "Zeitüberschreitung bei der Abfrage der Ausführungen."); + return Array.Empty(); + } + + await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); + + return slot.Items + .Select(e => slot.Commissions.TryGetValue(e.ExecId, out var c) + ? e with { Commission = c.Amount, CommissionCurrency = c.Currency } + : e) + .OrderByDescending(e => e.Time) + .ToList(); + } + finally + { + _executions.TryRemove(id, out _); + } + } + public async Task PlaceOrderAsync(OrderRequest request, Contract contract, TimeSpan timeout, CancellationToken ct) { @@ -346,6 +429,60 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable if (_accounts.TryGetValue(reqId, out var slot)) slot.Complete(); } + public override void updatePortfolio(Contract contract, double position, double marketPrice, + double marketValue, double averageCost, double unrealizedPNL, double realizedPNL, string accountName) + { + // Glattgestellte Positionen meldet TWS mit Menge 0 weiter – die gehören nicht in den Bestand. + if (_portfolio is not { } slot || position == 0) return; + + slot.Positions.Add(new BrokerPosition + { + Symbol = contract.Symbol, + SecType = contract.SecType, + Currency = contract.Currency, + ConId = contract.ConId, + Quantity = (decimal)position, + AvgCost = (decimal)averageCost, + MarketPrice = (decimal)marketPrice, + MarketValue = (decimal)marketValue, + UnrealizedPnl = (decimal)unrealizedPNL + }); + } + + public override void accountDownloadEnd(string account) => _portfolio?.Complete(); + + public override void execDetails(int reqId, Contract contract, Execution execution) + { + if (!_executions.TryGetValue(reqId, out var slot)) return; + + slot.Items.Add(new BrokerExecution + { + ExecId = execution.ExecId, + Time = IbkrMapping.ParseExecutionTime(execution.Time) ?? DateTime.MinValue, + Symbol = contract.Symbol, + SecType = contract.SecType, + Side = IbkrMapping.ParseSide(execution.Side), + Quantity = (decimal)execution.Shares, + Price = (decimal)execution.Price, + Exchange = execution.Exchange ?? "", + OrderId = execution.OrderId + }); + } + + public override void execDetailsEnd(int reqId) + { + if (_executions.TryGetValue(reqId, out var slot)) slot.Complete(); + } + + public override void commissionReport(CommissionReport report) + { + // Der Callback trägt keine reqId; die ExecId ordnet ihn der Ausführung zu. + if (report.Commission is <= 0 or >= 1e100) return; + + foreach (var slot in _executions.Values) + slot.Commissions[report.ExecId] = ((decimal)report.Commission, report.Currency ?? ""); + } + 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) @@ -407,19 +544,22 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable 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; } + 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; } + if (_executions.TryGetValue(id, out var e)) { e.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); + 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); + foreach (var slot in _executions.Values) slot.Fail(error); + _portfolio?.Fail(error); } private static decimal ReadDecimal(IReadOnlyDictionary values, string tag) => @@ -486,4 +626,15 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable public int Filled; public double AvgFillPrice; } + + private sealed class PortfolioSlot : Slot + { + public readonly List Positions = new(); + } + + private sealed class ExecutionSlot : Slot + { + public readonly List Items = new(); + public readonly ConcurrentDictionary Commissions = new(); + } } diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs index 3c6b62c..d20c135 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs @@ -1,3 +1,4 @@ +using System.Globalization; using IBApi; namespace IBKRTrader.Core.Trading.Ibkr; @@ -88,6 +89,35 @@ internal static class IbkrMapping (decimal)(ask > 0 ? ask : price)); } + /// Ausführungsseite laut TWS: "BOT" = gekauft, "SLD" = verkauft. + public static TradeSide ParseSide(string side) => + side.Trim().ToUpperInvariant() is "SLD" or "SELL" ? TradeSide.Sell : TradeSide.Buy; + + /// + /// Zeitstempel einer Ausführung. TWS liefert je nach Aufruf "yyyyMMdd HH:mm:ss" (mit doppeltem + /// Leerzeichen) oder zusätzlich eine Zeitzone ("20260804 17:52:56 Europe/Berlin"). Die Zeitzone + /// wird verworfen – der Wert bleibt Ortszeit der Börse, wie ihn TWS meldet. + /// + public static DateTime? ParseExecutionTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + + var parts = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length < 2) return null; + + return DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss", + CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) + ? parsed + : null; + } + + /// + /// Zeitfilter für reqExecutions. TWS warnt (2174) bei Zeitangaben ohne Zeitzone und + /// kündigt an, das Format zu entfernen – deshalb ausdrücklich UTC. + /// + public static string FormatExecutionFilterTime(DateTime since) => + since.ToUniversalTime().ToString("yyyyMMdd-HH:mm:ss", CultureInfo.InvariantCulture); + 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; diff --git a/src/IBKRTrader.Core/Trading/NullBrokerClient.cs b/src/IBKRTrader.Core/Trading/NullBrokerClient.cs index f9ff32c..8709084 100644 --- a/src/IBKRTrader.Core/Trading/NullBrokerClient.cs +++ b/src/IBKRTrader.Core/Trading/NullBrokerClient.cs @@ -7,12 +7,18 @@ namespace IBKRTrader.Core.Trading; /// Wird registriert, bis der echte IBKR-Adapter angebunden und gegen den /// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen. /// -public sealed class NullBrokerClient : IBrokerClient +public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader { private readonly LoggingService _logger; public NullBrokerClient(LoggingService logger) => _logger = logger; + public Task> GetPositionsAsync(CancellationToken ct = default) + => Task.FromResult>(Array.Empty()); + + public Task> GetExecutionsAsync(DateTime? since = null, CancellationToken ct = default) + => Task.FromResult>(Array.Empty()); + public Task GetQuoteAsync(string symbol, CancellationToken ct = default) => Task.FromResult(null); diff --git a/src/IBKRTrader.Core/Trading/TradingModels.cs b/src/IBKRTrader.Core/Trading/TradingModels.cs index 7de0db6..9aa3fdd 100644 --- a/src/IBKRTrader.Core/Trading/TradingModels.cs +++ b/src/IBKRTrader.Core/Trading/TradingModels.cs @@ -78,6 +78,56 @@ public sealed record Position(string Module, string Symbol, int Quantity, decima public decimal Notional => Quantity * AvgPrice; } +/// +/// Position, wie der Broker sie führt – die maßgebliche Wahrheit. Weicht bewusst von +/// ab: der Broker kennt kein Modul, dafür Bewertung und Instrumententyp. +/// Zuteilung und Verfall (Optionen) ändern Positionen ohne Order von uns; ohne Abgleich läuft +/// die eigene Buchführung deshalb zwangsläufig auseinander. +/// +public sealed record BrokerPosition +{ + public required string Symbol { get; init; } + /// "STK", "OPT", "FUT" … + public required string SecType { get; init; } + public required string Currency { get; init; } + /// IBKR-Kontrakt-ID – eindeutiger als das Symbol. + public required int ConId { get; init; } + /// Negativ bei Short-Positionen. + public required decimal Quantity { get; init; } + public required decimal AvgCost { get; init; } + // Achtung: Kurs, Wert und G/V stehen in der Währung der Position (), + // nicht in der Kontobasiswährung. Positionen verschiedener Währungen dürfen deshalb nicht + // einfach aufsummiert werden – dafür ist NetLiquidation aus AccountState zuständig. + public decimal MarketPrice { get; init; } + public decimal MarketValue { get; init; } + public decimal UnrealizedPnl { get; init; } + + public override string ToString() => + $"{Symbol} ({SecType}) {Quantity:N0} @ {AvgCost:N2} {Currency}"; +} + +/// Einzelne Ausführung (Teil-Fill) laut Broker. +public sealed record BrokerExecution +{ + /// Eindeutige IBKR-Ausführungs-ID – geeignet als Idempotenzschlüssel beim Import. + public required string ExecId { get; init; } + public required DateTime Time { get; init; } + public required string Symbol { get; init; } + public required string SecType { get; init; } + public required TradeSide Side { get; init; } + public required decimal Quantity { get; init; } + public required decimal Price { get; init; } + public string Exchange { get; init; } = ""; + /// 0 bei Orders, die nicht über diese API platziert wurden (z. B. manuell in TWS). + public int OrderId { get; init; } + /// Kommt über einen eigenen Callback und kann fehlen, wenn er verspätet eintrifft. + public decimal? Commission { get; init; } + public string? CommissionCurrency { get; init; } + + public override string ToString() => + $"{Time:yyyy-MM-dd HH:mm:ss} {Side} {Quantity:N0}x {Symbol} @ {Price:N2}"; +} + /// Kontext für die Risikobewertung eines Signals. public sealed record RiskContext { diff --git a/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs b/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs index 8775d16..b36c724 100644 --- a/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs +++ b/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs @@ -151,6 +151,48 @@ public class IbkrMappingTests public void BuildQuote_WithoutAnyPrice_ReturnsNull() => IbkrMapping.BuildQuote("AAPL", last: 0, bid: 0, ask: 0, close: 0).Should().BeNull(); + // ─── Ausführungen ───────────────────────────────────────────────────────── + + [Theory] + [InlineData("BOT", TradeSide.Buy)] + [InlineData("SLD", TradeSide.Sell)] + [InlineData("sld", TradeSide.Sell)] + [InlineData("SELL", TradeSide.Sell)] + public void ParseSide_MapsTwsExecutionSides(string raw, TradeSide expected) => + IbkrMapping.ParseSide(raw).Should().Be(expected); + + [Fact] + public void ParseExecutionTime_HandlesDoubleSpaceFormat() + { + // So liefert TWS es bei execDetails. + IbkrMapping.ParseExecutionTime("20260804 17:39:18") + .Should().Be(new DateTime(2026, 8, 4, 17, 39, 18)); + } + + [Fact] + public void ParseExecutionTime_IgnoresTrailingTimeZone() + { + IbkrMapping.ParseExecutionTime("20260804 17:52:56 Europe/Berlin") + .Should().Be(new DateTime(2026, 8, 4, 17, 52, 56)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("20260804")] + [InlineData("Unsinn")] + public void ParseExecutionTime_ReturnsNullForUnusableInput(string raw) => + IbkrMapping.ParseExecutionTime(raw).Should().BeNull(); + + [Fact] + public void FormatExecutionFilterTime_UsesUtcWithExplicitFormat() + { + // TWS warnt (2174) bei Zeitangaben ohne Zeitzone und entfernt das Format künftig. + var since = new DateTime(2026, 8, 4, 12, 0, 0, DateTimeKind.Utc); + + IbkrMapping.FormatExecutionFilterTime(since).Should().Be("20260804-12:00:00"); + } + // ─── Tick- und Status-Klassifizierung ───────────────────────────────────── [Theory]