4 Commits
Author SHA1 Message Date
RichardandClaude Opus 5 9f66183f1c Eine Roadmap statt sieben Konzepte; Quelldokumente ins Archiv
Build & Test / build (ubuntu-latest) (push) Waiting to run
Build & Test / build (windows-latest) (push) Waiting to run
Der offene Stand lag ueber sieben Konzepte, zwei Referenzdokumente und die
Phasen-Checkliste der Architektur verteilt. Dieselbe Aufgabe stand teils
doppelt unter zwei Namen - die asynchrone Fill-Verfolgung etwa als "bekannte
Grenze" in IBKR-Integration.md und zugleich als W-2 im OptionsWheel-Konzept.
Wer wissen wollte, was als Naechstes ansteht, musste alles neun lesen.

docs/ROADMAP.md fuehrt das zusammen:
  - Fuenf Stufen in Abhaengigkeitsreihenfolge, von "Fundament schliessen" bis
    zum OptionsWheel, dazu vier laufende Bahnen (Auslieferung, Accounting,
    Supervisor, technische Schulden).
  - Jede Zeile traegt ihre Herkunft (W-2, P5, Kapitalmodell 5, ...), damit die
    Herleitung im Archiv auffindbar bleibt.
  - Eigene Abschnitte fuer Zurueckgestelltes und Verworfenes. Zurueckgestellte
    Ideen nennen ausdruecklich, WAS sie wieder aktuell macht; verworfene nennen
    den Grund, damit sie nicht in sechs Monaten erneut vorgeschlagen werden.
  - Erledigtes bleibt als Zeile mit Datum stehen statt zu verschwinden.

Archiv (git erkennt alle sieben als Umbenennung, Historie bleibt):
  docs/konzepte/*         -> docs/archiv/
  docs/Kapital-und-Buchmodell.md -> docs/archiv/
Jedes archivierte Dokument bekommt oben einen Vermerk, warum es erhalten bleibt
und wo der lebende Stand steht. Inhaltlich ist keines veraendert.

Weiter gepflegt werden ARCHITECTURE.md (Aufbau + Phasen-Historie),
IBKR-Integration.md (Adapter-Design und Grenzen) und die TWS-Setup-Checkliste -
das sind Referenzen, keine Planung. Ihre eigenen Offen-Listen verweisen jetzt
mit Roadmap-Kennung dorthin, statt einen zweiten Stand zu fuehren.

Alle 63 relativen Markdown-Links geprueft, keiner tot. Fuenf Pfadverweise in
Code, csproj und systemd-Unit mitgezogen; die Unit zeigte auf die
Portierungsanalyse, die ausdruecklich den Stand VOR dem Umbau beschreibt - jetzt
auf ARCHITECTURE.md. Build 0 Warnungen, 198/198 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:14:51 +02:00
RichardandClaude Opus 5 e1546bd1b1 Fruehjahrsputz: toter Code, ungenutzte Symbole, Dokumentenstand
Alles Entfernte war nachweislich ohne Aufrufer. Build, 198/198 Tests, Smoke-UI
und Daemon-Prueflauf sind vor und nach jedem Schritt gruen.

Code:
  - AIModelService: Platzhalter, der immer 0.5 lieferte. Im DI registriert,
    aber nie irgendwo injiziert. Ordner Core/AI faellt mit weg.
  - CtApiWrapper + CtMeta: JSON-Modelle fuer einen {data,meta}-Umschlag, den
    CapitolTrades nicht mehr liefert. Der Scraper deserialisiert seit laengerem
    direkt List<CtTrade>.
  - IBKRGatewayService: DisconnectAsync, InitBrokerageSessionAsync und
    SearchStocksBySymbolAsync. Der Dienst selbst bleibt - er versorgt
    Instrument-Sync, Kurshistorie und den Watchdog-Heartbeat.
  - Je eine Methode ohne Aufrufer: BudgetService.GetAvailableBudgetAsync,
    TradeHistoryService.GetRecentTradesAsync, CongressRepository.
    GetAllTradeIdsAsync und .ResetHistoryImportAsync, IbkrMapping.DefaultPortFor,
    SecretProtection.IsEncrypted.
  - CongressRepository bekam damit einen LoggingService injiziert, den es nicht
    mehr benutzt - Abhaengigkeit samt Konstruktorparameter raus.

Ressourcen:
  - 17 Symbole der WinForms-Oberflaeche entfernt. Das Wildcard-Muster im csproj
    nahm sie in die Binaerdatei auf, ViewIcons.cs bildet aber nur sieben
    Schluessel ab. Resources/ enthaelt jetzt genau die sieben.

NuGet-Allowlist:
  - Dapper und HtmlAgilityPack sind seit R3 bzw. R1 aus dem Projekt raus,
    Microsoft.WindowsDesktop.* seit L5. Muster entfernt.
  - MySqlConnector und Newtonsoft.Json stehen NUR transitiv in den
    Projektdateien und wurden zuerst mitentfernt - ein Restore in einen leeren
    Paket-Ordner scheiterte darauf mit NU1100. Beide wieder aufgenommen, jetzt
    mit Begruendung, damit der naechste Aufraeumlauf nicht dieselbe Falle tritt.

Dokumente an den tatsaechlichen Stand angeglichen:
  - ARCHITECTURE: R2 fuehrte die Umstellung auf IHostedService als offen, obwohl
    R4 sie erledigt hat. L6 und die Deploymentcenter-Phase fehlten ganz.
  - DC-Konzept: Schritte 0-8 standen auf "dieser Durchlauf", sind aber umgesetzt.
    Jetzt je Schritt der wirkliche Stand - inklusive der beiden Halbfertigen:
    Update-PRUEFUNG laeuft, das Anwenden hat keinen Aufrufer; die
    Release-Pipeline steht, ist aber nie gelaufen. P5 ist eingetreten.
  - Accounting und Supervisor trugen keinen Umsetzungsvermerk, obwohl beide
    Module gebaut sind. Vermerk nach dem Muster des Linux-Konzepts ergaenzt,
    mit dem, was jeweils offen bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:25:03 +02:00
RichardandClaude Opus 5 0a72ea7e28 Deploymentcenter-Integration (Schritte 0-8 des Konzepts)
Bindet Lizenz, Watchdog, Fehler-Stream, UpdateService und Erstinstallation an
das Deploymentcenter 2.5.1 an. Einbauort ist IBKRTrader.Hosting - den Host
teilen sich Shell und Daemon.

Projekt-Befunde aus dem Konzept vorab bereinigt:
  P1  Echte DB-Zugangsdaten als Vorgabewerte in AppSettings -> Platzhalter.
      Das alte Passwort steht weiterhin in der Git-Historie und ist als
      kompromittiert zu behandeln (Rotation ist Nutzer-Aktion).
  P2  AppPaths fiel unter Windows auf /etc/ibkrtrader zurueck, was .NET zu
      C:\etc\ibkrtrader aufloest. Jetzt %ProgramData%\IBKRTrader.
  P3  Globale Ausnahmebehandler (AppDomain / TaskScheduler) - vorher gab es
      keinen Logeintrag, wenn der Prozess unbehandelt wegbrach.
  P4  Version einmal zentral in Directory.Build.props statt zweimal hartkodiert.

Bewusste Abweichungen vom DC-Leitfaden, beide fuer ein handelndes System:
  - Lizenz-Urteil fuehrt zum Sperrbetrieb (TradingEnabled=false) statt zu
    Environment.Exit(1). Keine neuen Einstiege, aber Risiko-, Exit- und
    Buchhaltungslogik laufen weiter.
  - exitCurrentApp bleibt immer false; der Aufrufer beendet geordnet.

Das SDK haengt als Cross-Repo-ProjectReference am Schwester-Repo
Deploymentcenter (Interim, siehe Konzept 2.2). Damit ist P5 offen: die
Gitea-CI checkt das Schwester-Repo nicht aus und wird rot, bis der Bezug auf
ein NuGet-Paket umgestellt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:44:56 +02:00
RichardandClaude Opus 5 87194bfc48 IBKR: Zeitkontext einer Verbindung nachvollziehbar machen
ResolveExecutionTime liefert neben dem UTC-Zeitpunkt jetzt die Herkunft der
verwendeten Zeitzone (gemeldet / angenommen / unbekannt / unlesbar). Bisher war
im Nachhinein nicht unterscheidbar, ob ein Buchungszeitpunkt von TWS stammte
oder eine Annahme war - genau der Fehler, der beim Umzug zwischen EU- und
US-Host lautlos entsteht.

IbkrConnection schreibt beim Verbinden einmalig Betriebszeitzone, Systemzeitzone
und den Versatz zur TWS-Serverzeit ins Log; ab 5 s Abweichung gilt die Uhr des
Hosts als verstellt.

TWS-Setup-Checkliste um den Linux-Abschnitt ergaenzt (Betrieb und Umgebung
unterscheiden sich, das Protokoll nicht).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:44:41 +02:00
67 changed files with 3048 additions and 207 deletions
+4
View File
@@ -47,3 +47,7 @@ Logs/
## Local Claude / agent config ## Local Claude / agent config
.claude/settings.local.json .claude/settings.local.json
## Deploymentcenter Release-Pipeline (siehe scripts/release.ps1 / release.sh)
.dc-tools/
artifacts/
+10
View File
@@ -0,0 +1,10 @@
<Project>
<!--
Zentrale Versionsangabe fuer alle Projekte der Solution. pack-and-deploy (Deploymentcenter)
liest die Version aus der Hauptassembly und bricht bei einer Abweichung zum uebergebenen
Versionsparameter ab - deshalb steht sie hier genau einmal statt in einzelnen csproj-Dateien.
-->
<PropertyGroup>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
+5 -4
View File
@@ -13,10 +13,12 @@
</packageSources> </packageSources>
<packageSourceMapping> <packageSourceMapping>
<packageSource key="nuget.org"> <packageSource key="nuget.org">
<!-- App-Pakete --> <!-- App-Pakete. Beide stehen NUR transitiv in den Projektdateien: MySqlConnector kommt ueber
Pomelo, Newtonsoft.Json ueber die Testplattform. Ohne Eintrag scheitert ein Restore aus
leerem Cache mit NU1100 - auf einem Entwicklungsrechner unsichtbar, weil dort gecacht.
Am 2026-08-23 geprueft, indem beide entfernt und der Restore in einen leeren
Paket-Ordner wiederholt wurde; er schlug fehl. Nicht entfernen. -->
<package pattern="MySqlConnector" /> <package pattern="MySqlConnector" />
<package pattern="Dapper" />
<package pattern="HtmlAgilityPack" />
<package pattern="Newtonsoft.Json" /> <package pattern="Newtonsoft.Json" />
<!-- Offizielle TWS-C#-API (NuGet-Mirror) für den IBKR-Broker-Adapter --> <!-- Offizielle TWS-C#-API (NuGet-Mirror) für den IBKR-Broker-Adapter -->
<package pattern="IB.TWS.CSharpApi" /> <package pattern="IB.TWS.CSharpApi" />
@@ -55,7 +57,6 @@
<package pattern="Microsoft.Extensions.*" /> <package pattern="Microsoft.Extensions.*" />
<package pattern="Microsoft.NET.*" /> <package pattern="Microsoft.NET.*" />
<package pattern="Microsoft.NETCore.*" /> <package pattern="Microsoft.NETCore.*" />
<package pattern="Microsoft.WindowsDesktop.*" />
<package pattern="Microsoft.AspNetCore.*" /> <package pattern="Microsoft.AspNetCore.*" />
<package pattern="Microsoft.Bcl.*" /> <package pattern="Microsoft.Bcl.*" />
<package pattern="Microsoft.Win32.*" /> <package pattern="Microsoft.Win32.*" />
+15 -9
View File
@@ -26,7 +26,7 @@ die Fenster registriert die Shell zentral in `Shell/ModuleViews.cs`.
über die TWS API aktivierbar mit `IBKR.UseTwsApi`. über die TWS API aktivierbar mit `IBKR.UseTwsApi`.
- **Analyse-Datenfundament**: `core_decision_journal` (jede Entscheidung + ReasonCode), `core_order_events`, - **Analyse-Datenfundament**: `core_decision_journal` (jede Entscheidung + ReasonCode), `core_order_events`,
`SignalId`-Korrelation, JSONL-Log-Sink (`Logs/{yyyy-MM-dd}.jsonl`) speist den Supervisor. `SignalId`-Korrelation, JSONL-Log-Sink (`Logs/{yyyy-MM-dd}.jsonl`) speist den Supervisor.
- Details: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). - Details: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · Offener Stand: [docs/ROADMAP.md](docs/ROADMAP.md).
## Build & Test ## Build & Test
```bash ```bash
@@ -67,19 +67,25 @@ powershell -File scripts/provision-db.ps1
- **Accounting** von der Trading-DB unabhängige Buchführung aus dem IBKR-Kontoauszug (Activity Flex - **Accounting** von der Trading-DB unabhängige Buchführung aus dem IBKR-Kontoauszug (Activity Flex
Query) → append-only Ledger `acc_*`, Periodenabrechnung/BWA, FX (USD/EUR), CSV/PDF-Export. Kein Handel. Query) → append-only Ledger `acc_*`, Periodenabrechnung/BWA, FX (USD/EUR), CSV/PDF-Export. Kein Handel.
Live-Abruf hinter Interfaces (Offline-Null-Stubs); Steuerschicht bewusst offen. Konzept: Live-Abruf hinter Interfaces (Offline-Null-Stubs); Steuerschicht bewusst offen. Konzept:
[docs/konzepte/KONZEPT-Modul-Accounting.md](docs/konzepte/KONZEPT-Modul-Accounting.md). [docs/archiv/KONZEPT-Modul-Accounting.md](docs/archiv/KONZEPT-Modul-Accounting.md).
- **Supervisor** read-only KI-Analyse/Forensik über alle Module (OpenRouter-Agent + read-only - **Supervisor** read-only KI-Analyse/Forensik über alle Module (OpenRouter-Agent + read-only
Tool-Registry, Dossier-Browser, optional MCP-Light). Stützt sich auf das Core-Datenfundament Tool-Registry, Dossier-Browser, optional MCP-Light). Stützt sich auf das Core-Datenfundament
(`core_decision_journal`, `core_order_events`, `SignalId`, JSONL-Logs). Konzept: (`core_decision_journal`, `core_order_events`, `SignalId`, JSONL-Logs). Konzept:
[docs/konzepte/KONZEPT-Modul-Supervisor.md](docs/konzepte/KONZEPT-Modul-Supervisor.md). [docs/archiv/KONZEPT-Modul-Supervisor.md](docs/archiv/KONZEPT-Modul-Supervisor.md).
## Status / Nächstes ## Status / Nächstes
- Kurskorrektur auf das PolytraderSharp-Konzept (R1R7) abgeschlossen.
- **Accounting**- und **Supervisor**-Modul (inkl. Core-Datenfundament S-0) ergänzt; Live-Abruf (IBKR **➡️ Was noch zu tun ist, steht vollständig in der [Roadmap](docs/ROADMAP.md).** Sie ist seit dem
Flex / OpenRouter-Key) und Steuerschicht sind bewusst noch offen (Stubs/Platzhalter). 2026-08-23 das einzige Dokument, das den offenen Stand führt einschließlich der Ideen, die wir
- **IBKR-Broker über die TWS API / IB Gateway** ist implementiert (Paper-Konto steht, Verbindung bewusst zurückstellen (🧊) und derer, die wir geprüft und verworfen haben (❌). Die früheren
verifiziert) Design und offene Punkte: [docs/IBKR-Integration.md](docs/IBKR-Integration.md), Konzepte liegen unverändert unter [docs/archiv/](docs/archiv/) und tragen die Herleitung.
TWS-Einstellungen: [docs/TWS-Setup-Checkliste.md](docs/TWS-Setup-Checkliste.md).
Kurzfassung:
- Gebaut: R1R7 (Kurskorrektur auf das PolytraderSharp-Konzept), Accounting und Supervisor inkl.
Core-Datenfundament S-0, Linux-Portierung L0L6, Deploymentcenter-Anbindung.
- **Es hat noch nie eine echte Order gegeben.** Der TWS-Adapter ist gegen das Paper-Konto
verifiziert (Verbindung, Konto, Kurse, Optionskette, Greeks, What-If-Order), aber
`PlaceOrderAsync` mit echter Ausführung steht aus daran hängt alles Weitere.
- **Sicherheit:** DB-Passwort rotieren (liegt in der Git-Historie, Commit `ebeb035`). - **Sicherheit:** DB-Passwort rotieren (liegt in der Git-Historie, Commit `ebeb035`).
## Sicherheitshinweis ## Sicherheitshinweis
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

+1 -1
View File
@@ -16,7 +16,7 @@
# #
[Unit] [Unit]
Description=IBKRTrader (kopfloser Handelsdienst) Description=IBKRTrader (kopfloser Handelsdienst)
Documentation=file:///opt/ibkrtrader/docs/konzepte/KONZEPT-Linux-Portierung.md Documentation=file:///opt/ibkrtrader/docs/ARCHITECTURE.md
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
+47 -9
View File
@@ -8,6 +8,15 @@
Ziel: modulares C#-Trading-Framework für Interactive-Brokers-Aktien, strukturell wie PolytraderSharp, Ziel: modulares C#-Trading-Framework für Interactive-Brokers-Aktien, strukturell wie PolytraderSharp,
nur dass statt Polymarket über IBKR gehandelt wird. nur dass statt Polymarket über IBKR gehandelt wird.
> **Was dieses Dokument ist und was nicht.** Abschnitt 1 beschreibt den **heutigen Aufbau** und ist
> die lebende Architektur-Referenz. Abschnitt 3 ist die **Historie**: die Phasen-Checklisten, an denen
> nachlesbar ist, was wann und warum gebaut wurde. Beides bleibt gepflegt.
>
> **Der offene Stand steht seit dem 2026-08-23 nicht mehr hier, sondern in der
> [Roadmap](ROADMAP.md).** Die wenigen offenen Kästchen unten sind mit ihrer Roadmap-Kennung
> versehen, damit die beiden Listen nicht auseinanderlaufen. Neue Aufgaben gehören ausschließlich
> in die Roadmap.
--- ---
## 1. Ziel-Architektur (nach PolytraderSharp) ## 1. Ziel-Architektur (nach PolytraderSharp)
@@ -16,7 +25,7 @@ nur dass statt Polymarket über IBKR gehandelt wird.
> Plattformbindung. Die WinForms-Shell ist entfernt; der letzte Stand liegt im Tag > Plattformbindung. Die WinForms-Shell ist entfernt; der letzte Stand liegt im Tag
> `winforms-final`. Einstiegspunkte sind jetzt `IBKRTrader.App` (mit Oberfläche) und > `winforms-final`. Einstiegspunkte sind jetzt `IBKRTrader.App` (mit Oberfläche) und
> `IBKRTrader.Daemon` (kopflos, systemd); beide bauen ihren Host über `IBKRTrader.Hosting`. > `IBKRTrader.Daemon` (kopflos, systemd); beide bauen ihren Host über `IBKRTrader.Hosting`.
> Analyse und Vorgehen: [konzepte/KONZEPT-Linux-Portierung.md](konzepte/KONZEPT-Linux-Portierung.md). > Analyse und Vorgehen: [archiv/KONZEPT-Linux-Portierung.md](archiv/KONZEPT-Linux-Portierung.md).
``` ```
src/IBKRTrader.App (WinExe, net10.0) Oberfläche: Launcher, Shell, Core-Views, Modul-Fenster src/IBKRTrader.App (WinExe, net10.0) Oberfläche: Launcher, Shell, Core-Views, Modul-Fenster
@@ -97,7 +106,7 @@ src/IBKRTrader.Hosting (classlib, net10.0) AppHostBuilder + RunStartu
- [x] `ShellUiHost` + `LauncherForm` (View-Buttons + gemeinsames Fenster-Menü); Core-Views (Workers/Logs/Settings) als eigene Fenster - [x] `ShellUiHost` + `LauncherForm` (View-Buttons + gemeinsames Fenster-Menü); Core-Views (Workers/Logs/Settings) als eigene Fenster
- [x] CongressTrading auf neuen `IModule`-Vertrag; Modul-Worker als `IWorker` registriert - [x] CongressTrading auf neuen `IModule`-Vertrag; Modul-Worker als `IWorker` registriert
- [x] `--smoke-ui` Headless-Test (alle Views + Launcher konstruieren) → grün - [x] `--smoke-ui` Headless-Test (alle Views + Launcher konstruieren) → grün
- [ ] **Offen (R4/R5):** Worker von `WorkerEngine`/`IWorker` auf `IHostedService` umstellen (aktuell noch WorkerEngine) - [x] Worker von `WorkerEngine`/`IWorker` auf `IHostedService` umgestellt — **in R4 erledigt**, die Zeile stand hier bis zum 2026-08-23 faelschlich noch offen
### R3 Persistenz auf EF Core ### R3 Persistenz auf EF Core
**Festgelegt:** EF-Migrationen **extern** (wie PolytraderSharp) Schema per `dotnet ef database update`, **Festgelegt:** EF-Migrationen **extern** (wie PolytraderSharp) Schema per `dotnet ef database update`,
@@ -131,7 +140,7 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin
- [x] `ConfigureSecretProtection` + TLS-Warnung (`SslMode`) beim Start; `master.key` gitignored - [x] `ConfigureSecretProtection` + TLS-Warnung (`SslMode`) beim Start; `master.key` gitignored
- [x] Connection-String in `appsettings.Local.json` (gitignored) - [x] Connection-String in `appsettings.Local.json` (gitignored)
- [x] **5 SecretProtection-Tests** (Round-Trip, Idempotenz, Passthrough, Tamper/Key-Fehler) → 56/56 grün - [x] **5 SecretProtection-Tests** (Round-Trip, Idempotenz, Passthrough, Tamper/Key-Fehler) → 56/56 grün
- [ ] **Offen (Nutzer-Aktion):** geleaktes DB-Passwort rotieren (liegt in Git-Historie via `grundregeln.md`, Commit `ebeb035`); EF-Schema per `dotnet ef database update` auf die DB anwenden - [ ] **Offen → Roadmap [B1](ROADMAP.md) / [B3](ROADMAP.md):** geleaktes DB-Passwort rotieren (liegt in Git-Historie via `grundregeln.md`, Commit `ebeb035`); EF-Schema per `dotnet ef database update` auf die DB anwenden
### R7 Feinschliff ✅ ### R7 Feinschliff ✅
- [x] Core-**Dashboard-View** (Gesamtüberblick: Trading-Modus, aggregierte Kennzahlen, geladene Module) + Icon - [x] Core-**Dashboard-View** (Gesamtüberblick: Trading-Modus, aggregierte Kennzahlen, geladene Module) + Icon
@@ -141,11 +150,11 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin
- [x] Gegen Paper-Konto DUR371528 verifiziert: Verbindung, Konto (NetLiquidation 100.105,50 EUR), Kurse (AAPL/MSFT/NVDA, verzögert) und Fehlerpfade - [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] 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) - [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) - [ ] **Offen → Roadmap [H1](ROADMAP.md) / [H2](ROADMAP.md):** `PlaceOrderAsync` mit echter Ausführung verifizieren (Fill → Buchung); asynchrone Fill-Verfolgung (Orders ohne sofortige Ausführung)
- [ ] IBKR-Account-Credentials mit `EncryptedStringConverter` speichern - [ ] **Offen → Roadmap [H3](ROADMAP.md):** IBKR-Account-Credentials mit `EncryptedStringConverter` speichern
### L0L5 Linux-Portierung: Avalonia statt WinForms ✅ (2026-08-07) ### L0L5 Linux-Portierung: Avalonia statt WinForms ✅ (2026-08-07)
Analyse und Begründung: [konzepte/KONZEPT-Linux-Portierung.md](konzepte/KONZEPT-Linux-Portierung.md). Analyse und Begründung: [archiv/KONZEPT-Linux-Portierung.md](archiv/KONZEPT-Linux-Portierung.md).
Rückfallpunkt für den letzten WinForms-Stand: Tag `winforms-final`. Rückfallpunkt für den letzten WinForms-Stand: Tag `winforms-final`.
- [x] **L0** `NuGet.config` repariert drei Pakete hatten kein `packageSourceMapping`-Muster; ein frischer Klon konnte nicht wiederherstellen (auf dem Entwicklungsrechner unsichtbar, weil gecacht) - [x] **L0** `NuGet.config` repariert drei Pakete hatten kein `packageSourceMapping`-Muster; ein frischer Klon konnte nicht wiederherstellen (auf dem Entwicklungsrechner unsichtbar, weil gecacht)
@@ -160,8 +169,37 @@ Rückfallpunkt für den letzten WinForms-Stand: Tag `winforms-final`.
und Oberfläche (32 MB) ohne eine einzige Windows-Abhängigkeit. Der Smoke-UI-Lauf braucht kein und Oberfläche (32 MB) ohne eine einzige Windows-Abhängigkeit. Der Smoke-UI-Lauf braucht kein
Anzeigegerät mehr und ist damit erstmals Teil der CI. Anzeigegerät mehr und ist damit erstmals Teil der CI.
**Offen:** IB Gateway kopflos betreiben (IBC + Xvfb) eigene Baustelle, unabhängig vom Code; **Offen → Roadmap [H4](ROADMAP.md) / [T3](ROADMAP.md):** IB Gateway kopflos betreiben (IBC + Xvfb)
LiveCharts2 kommt mit den neuen Modulen (Avalonia deshalb auf der 11er-Linie gepinnt). eigene Baustelle, unabhängig vom Code; LiveCharts2 kommt mit den neuen Modulen (Avalonia deshalb
auf der 11er-Linie gepinnt).
### L6 Namensgebung bereinigt ✅ (2026-08-07)
- [x] `IBKRTrader.App.Avalonia`**`IBKRTrader.App`**. Das Suffix gab es nur, solange daneben eine
WinForms-`IBKRTrader.App` stand; seit L5 ist die weg. Assembly, Wurzel-Namensraum und die
`avares://`-Ressourcen-URI sind mitgezogen, Git erkennt alles als Umbenennung.
- [x] Die `global::Avalonia`-Qualifizierungen entfallen sie waren nur nötig, weil der Namensraum
`IBKRTrader.App.Avalonia` das gleichnamige Paket verdeckte.
- [x] **Inhaltlich falsch gewordene Aussagen berichtigt** das waren die eigentlichen Überbleibsel,
nicht die Kommentare: `.agents/rules/grundregeln.md` schrieb weiterhin „C# .NET 10 WinForms",
RichTextBox-Logging, `LauncherForm` und `PropertyGrid` vor und hätte die Portierung Stück für
Stück rückgängig gemacht. Dazu Core-Kommentare (`LogEntry`, `IWorker`, `ModuleView`) und Doku.
### DC Deploymentcenter-Integration ✅ (2026-08-23)
Konzept und Begründung: [archiv/KONZEPT-Deploymentcenter-Integration.md](archiv/KONZEPT-Deploymentcenter-Integration.md).
Schritte 08 der dortigen Reihenfolge sind umgesetzt; der Stand je Schritt steht in §9 dieses Konzepts.
- [x] Lizenz (Sperrbetrieb statt Abbruch), Watchdog-Heartbeat, Fehler-Stream inkl. globaler
Ausnahmebehandler, Update-Prüfung mit `ReleaseCredentials`, `setup.json` + Release-Pipeline.
Einbauort ist `IBKRTrader.Hosting/Deploymentcenter/` den Host teilen sich Shell und Daemon.
- [x] Vier Projekt-Befunde vorab bereinigt: Zugangsdaten aus `AppSettings` (P1), `AppPaths`-Rückfall
unter Windows (P2), globale Handler (P3), Version zentral in `Directory.Build.props` (P4).
- [ ] **Offen (P5) → Roadmap [D1](ROADMAP.md):** Die Gitea-CI checkt das Schwester-Repo `Deploymentcenter` nicht aus. Solange
das SDK als Cross-Repo-`ProjectReference` hängt, ist der CI-Lauf rot. Behebt sich mit
Schritt 10 (SDK als NuGet-Paket in der Gitea-Registry).
- [ ] **Offen (Schritt 9) → Roadmap [D2](ROADMAP.md) / [D5](ROADMAP.md):** Bugtracker-Baustein setzt voraus, dass das Projekt `ibkrtrader` im
DC-WebUI angelegt ist und ein Token mit `bugtracker:report` vorliegt (serverseitige Handarbeit).
- [ ] **Offen → Roadmap [D3](ROADMAP.md):** Das Anwenden eines gefundenen Updates ist nicht verdrahtet. `DcUpdateService.LaunchAgent`
ist fertig und dokumentiert, es fehlt der Aufrufer, der danach geordnet herunterfährt.
--- ---
@@ -169,7 +207,7 @@ LiveCharts2 kommt mit den neuen Modulen (Avalonia deshalb auf der 11er-Linie gep
IBKRTrader entspricht jetzt strukturell dem PolytraderSharp-Konzept: Multi-Projekt (Core + Modul + App + Tests), IBKRTrader entspricht jetzt strukturell dem PolytraderSharp-Konzept: Multi-Projekt (Core + Modul + App + Tests),
Generic Host + `IHostedService`, `IConfiguration`, `IModule`/`ModuleView`/`ShellUiHost`, EF Core (extern migriert), Generic Host + `IHostedService`, `IConfiguration`, `IModule`/`ModuleView`/`ShellUiHost`, EF Core (extern migriert),
Trading-Kern (Risk/Execution/Portfolio, `NullBroker`-Default), CongressTrading-Strategie, Security (Master-Key/AES-GCM). Trading-Kern (Risk/Execution/Portfolio, `NullBroker`-Default), CongressTrading-Strategie, Security (Master-Key/AES-GCM).
**Offen für später:** echte IBKR-Broker-Anbindung (Paper-Gateway), DB-Passwort-Rotation, EF-Schema anwenden. **Offen für später:** siehe [Roadmap](ROADMAP.md) die IBKR-Broker-Anbindung ist inzwischen gebaut, DB-Passwort-Rotation (B1) und EF-Schema (B3) stehen weiterhin aus.
--- ---
+15 -6
View File
@@ -92,13 +92,13 @@ komplette Prüfkette inklusive Handelsberechtigung und verwirft die Orde
Fehlte die Berechtigung, hätte IBKR die What-If-Order mit einem Berechtigungsfehler abgelehnt statt 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 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: kommen verzögerte Daten (siehe Marktdaten unten). Modul-Konzept:
[konzepte/KONZEPT-Modul-OptionsWheel.md](konzepte/KONZEPT-Modul-OptionsWheel.md). [archiv/KONZEPT-Modul-OptionsWheel.md](archiv/KONZEPT-Modul-OptionsWheel.md).
> **What-If als Testwerkzeug:** Damit lässt sich der gesamte Orderpfad bis zur Broker-Annahme prüfen, > **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 > 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. > (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 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). steht gemessen in [archiv/KONZEPT-Datenlage-und-Strategien.md](archiv/KONZEPT-Datenlage-und-Strategien.md).
Kurz: Kurshistorie (30 Jahre), Volatilitätshistorie, Optionsketten und Griechen ja; Kurz: Kurshistorie (30 Jahre), Volatilitätshistorie, Optionsketten und Griechen ja;
Fundamentaldaten und Marktscanner nein (Abo nötig). Fundamentaldaten und Marktscanner nein (Abo nötig).
@@ -126,7 +126,16 @@ Später zu entscheiden:
[TWS-Setup-Checkliste](TWS-Setup-Checkliste.md), Abschnitt Verifikation. [TWS-Setup-Checkliste](TWS-Setup-Checkliste.md), Abschnitt Verifikation.
## Offen ## Offen
1. `PlaceOrderAsync` gegen das Paper-Konto verifizieren (Order → Fill → Buchung). Die offenen Punkte dieses Adapters werden seit dem 2026-08-23 in der
2. IBC für Auto-Login/Neustart einrichten (Server-Betrieb). [Roadmap](ROADMAP.md) gefuehrt, nicht mehr hier sie haengen mit Aufgaben aus anderen Konzepten
3. Asynchrone Fill-Verfolgung, siehe „Bekannte Grenze" oben. zusammen und standen deshalb doppelt. Es sind:
4. Entscheiden, ob die Marktdaten-Historie von der CP Web API auf `reqHistoricalData` wandert.
| Roadmap | Punkt |
|---|---|
| **H1** | `PlaceOrderAsync` gegen das Paper-Konto verifizieren (Order → Fill → Buchung) |
| **H2** | Asynchrone Fill-Verfolgung, siehe „Bekannte Grenze" oben zugleich Voraussetzung fuer OptionsWheel |
| **H4** | IBC fuer Auto-Login/Neustart einrichten (Server-Betrieb) |
| **T1** | Entscheiden, ob die Marktdaten-Historie von der CP Web API auf `reqHistoricalData` wandert |
Das **Design** und die **bekannten Grenzen** stehen weiterhin in diesem Dokument es bleibt die
technische Referenz des Adapters.
+235
View File
@@ -0,0 +1,235 @@
# IBKRTrader Roadmap
> **Dies ist das einzige Dokument, das sagt, was noch zu tun ist.** Bis zum 2026-08-23 war der
> offene Stand über sieben Konzepte, zwei Referenzdokumente und die Phasen-Checkliste der
> Architektur verteilt; dieselbe Aufgabe stand teils doppelt unter zwei Namen. Diese Roadmap führt
> alles zusammen. Die Quelldokumente bleiben vollständig erhalten und liegen unter
> [archiv/](archiv/) dort steht das **Warum** und die Herleitung, hier das **Was** und das **Wann**.
>
> Regel für die Zukunft: **Ein offener Punkt gehört hierher.** Steht er nur im Konzept, wird er
> vergessen. Wird er erledigt, bekommt er ein ✅ mit Datum die Zeile bleibt stehen, damit
> nachvollziehbar ist, wann etwas fertig wurde.
**Stand: 2026-08-23**
---
## Legende
| Zeichen | Bedeutung |
|---|---|
| ⬜ | Geplant, noch nicht angefangen |
| 🔶 | Angefangen, halbfertig der genaue Rest steht in der Zeile |
| ✅ | Fertig, mit Datum |
| 🧊 | **Zurückgestellt** bewusst nicht jetzt, Idee wird vorgehalten. Kein Versehen. |
| ❌ | **Verworfen** geprüft und entschieden. Nicht erneut vorschlagen, ohne den Grund zu entkräften. |
Die Spalte *Herkunft* nennt die ursprüngliche Kennung im Quelldokument, damit die Herleitung
auffindbar bleibt (z. B. `W-2` im OptionsWheel-Konzept, `P5` im Deploymentcenter-Konzept).
---
## Wo wir stehen
Gebaut und grün: Multi-Projekt-Aufbau nach PolytraderSharp, Generic Host, EF Core, Trading-Kern,
drei Module (CongressTrading, Accounting, Supervisor), Avalonia-Oberfläche auf Windows **und**
Linux, kopfloser Daemon, Deploymentcenter-Anbindung. 198 Tests, Build ohne Warnungen.
Nicht gebaut: **Es hat noch nie eine echte Order gegeben.** Der Broker-Adapter ist gegen das
Paper-Konto verifiziert Verbindung, Konto, Kurse, Optionskette, Greeks, What-If-Order aber
`PlaceOrderAsync` mit echter Ausführung steht aus. Alles, was danach kommt (Fill-Buchung,
Kapitalmodell, OptionsWheel), hängt an diesem einen Nachweis.
Zwei Sicherungen sind absichtlich getrennt und beide stehen auf „aus": `IBKR.UseTwsApi` schaltet
den Adapter, `TradingEnabled` den Handel.
---
## Stufe 1 Fundament schließen
*Diese Punkte blockieren oder gefährden alles Weitere. Sie kommen zuerst.*
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **B1** | **DB-Passwort rotieren.** Das echte Passwort der produktiven MariaDB stand als Vorgabewert in `AppSettings.cs` und liegt damit **in der Git-Historie** (ab Commit `ebeb035`). Der Quelltext trägt seit 2026-08-23 Platzhalter das ändert an der Historie nichts. Betriebsaktion, nur vom Betreiber ausführbar. | ⬜ | R6, DC-P1 |
| **B2** | **Gitea-Zugangstoken aus der Remote-URL nehmen.** `git remote -v` zeigt den Token im Klartext in der URL; er landet so in jedem Log, jeder Fehlermeldung und jedem Screenshot. Auf Credential-Helper oder SSH umstellen. | ⬜ | Befund 2026-08-23 |
| **B3** | **EF-Schema auf die Datenbank anwenden** (`dotnet ef database update`). Migrationen werden bewusst extern angewendet, nie zur Laufzeit. | ⬜ | R6 |
| **D1** | **CI wieder grün bekommen.** Das Deploymentcenter-SDK hängt als Cross-Repo-`ProjectReference` am Schwester-Repo; `.gitea/workflows/build.yml` checkt nur IBKRTrader aus, also ist der CI-Lauf seit dem 2026-08-23 rot. Das war die bewusst in Kauf genommene Folge der Interimslösung. Auflösung: `dotnet pack` im Deploymentcenter-Repo → Gitea-Registry → `PackageReference` statt Cross-Repo-Pfad → CI-Workflow anpassen. | ⬜ | DC-P5, DC-Schritt 10 |
| **H1** | **`PlaceOrderAsync` gegen das Paper-Konto verifizieren** echte Order, echter Fill, korrekte Buchung. Der einzige Teil des Broker-Adapters, der nie unter realen Bedingungen lief. | ⬜ | R7, IBKR-Integration §Offen 1 |
---
## Stufe 2 Den Handelspfad belastbar machen
*Ohne das ist kein unbeaufsichtigter Betrieb zu verantworten.*
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **H2** | **Asynchrone Order- und Fill-Verfolgung.** `PlaceOrderAsync` ist synchron gedacht: Ausführung oder Fehlschlag innerhalb `OrderTimeoutSeconds`. Eine Limit-Order im Buch oder eine Market-Order außerhalb der Handelszeiten gilt damit als Fehlschlag **ist bei IBKR aber weiter aktiv.** Nötig: Order-Zustand persistieren (`core_order_state`), `orderStatus`/`execDetails` dauerhaft mitschreiben, Fill nachbuchen, Zustand über einen App-Neustart hinweg rekonstruieren. Ändert den Seam. **Zugleich Voraussetzung für OptionsWheel (dort `W-2`)** die Aufgabe stand doppelt in zwei Konzepten. | ⬜ | IBKR-Integration, W-2 |
| **H3** | **IBKR-Zugangsdaten mit `EncryptedStringConverter` ablegen.** Der Konverter ist gebaut und getestet, die Credentials nutzen ihn noch nicht. | ⬜ | R7 |
| **H4** | **IBC/IBController einrichten** IBKR erzwingt 2FA und täglichen Neustart des Gateways. Ohne Auto-Login gibt es keinen unbeaufsichtigten Betrieb. Für Linux zusätzlich kopflos (IBC + Xvfb). Reine Betriebsarbeit, unabhängig vom Code. | ⬜ | L-Ergebnis, IBKR-Integration §Offen 2 |
| **T1** | **Entscheiden, welcher IBKR-Zugangsweg bleibt.** Es gibt zwei parallele: den TWS-API-Adapter (`Trading/Ibkr/`, der handelnde Pfad) und `IBKRGatewayService` (Client Portal REST, versorgt Instrument-Sync, Kurshistorie und den Watchdog-Heartbeat). Zwei Broker-APIs bedeuten zwei Fehlerbilder, zwei Authentifizierungen und zwei Betriebsvoraussetzungen. Entweder die Historie auf `reqHistoricalData` umziehen und den REST-Weg aufgeben oder die Doppelung ausdrücklich begründen. | ⬜ | IBKR-Integration §Offen 4, Befund 2026-08-23 |
---
## Stufe 3 Options-Fundament im Core
*Reine Core-Arbeit. Nützt allen Modulen, nicht nur dem Wheel. Der Aktienpfad bleibt unverändert
neue Felder sind optional, `Kind = Stock` ist der Default.*
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **O1** | `InstrumentKind`/`OptionSpec`, `IbkrMapping.Option`, Positionen mit Multiplikator. Heute rechnet `Position.Notional` als `Quantity × AvgPrice` bei Optionen um Faktor 100 falsch, womit **jedes Risikolimit wirkungslos wäre**. | ⬜ | W-0 |
| **O2** | Optionskette + Greeks im Core (`reqSecDefOptParams`, `tickOptionComputation`). Gegen das Paper-Gateway bereits nachgemessen: 24 Verfallstermine, 127 Strikes, Greeks auch mit **verzögerten** Daten über die Tick-Felder 8083 (Feld 83 = Modell ist die maßgebliche Variante). | ⬜ | W-1 |
| **O3** | *(= **H2**, siehe Stufe 2 dieselbe Aufgabe, hier als Wheel-Voraussetzung geführt)* | ⬜ | W-2 |
| **O4** | Positionsabgleich (Zuteilung & Verfall ändern Positionen **ohne** Order von uns) + `RiskService` um sell-to-open und Deckungsprüfung erweitern. Heute lehnt `EvaluateSell` einen Verkauf ohne Bestand grundsätzlich ab das ist die Kernoperation des Wheels. **Niemals nackt:** Short Call nur mit 100 freien Aktien je Kontrakt, Short Put nur mit reserviertem Cash über Strike × 100. Die Prüfung gehört in den Core, damit ein Modulfehler keine ungedeckte Option schreiben kann. | ⬜ | W-3 |
| **O5** | **IV-Rank als Core-Baustein**, nicht als Modul-Interna. Die Kennzahl braucht jede Prämienstrategie und sie gehört neben die Kurshistorie in die Datenschicht. | ⬜ | Datenlage §4 |
---
## Stufe 4 Kapital- und Buchmodell
*Wird scharf, sobald ein **zweites** Modul handelt. Heute handelt nur CongressTrading; mit dem
Wheel sind es zwei, die sich ein Konto und ein Guthaben teilen. Das vollständig ausgearbeitete
Konzept liegt im [Archiv](archiv/Kapital-und-Buchmodell.md) es ist Referenz, gegen die die
Umsetzung geprüft wird, und war nie umgesetzt.*
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **K1** | **Bücher + Eigentumsregeln.** Jede Position hat genau einen Eigentümer; kein Modul fasst die Position eines anderen oder eine manuell angelegte an. Im Core erzwungen, nicht per Konvention. | ⬜ | Kapitalmodell §2 |
| **K2** | **Ein Pool, Obergrenzen.** Kein Kapitaltransfer zwischen Büchern, niemals aktives Schließen zum Balancieren. Limit-Verletzung ist ein hartes Gate mit strukturiertem Feedback die Reaktion entscheidet die Strategie, nicht der Core. | ⬜ | Kapitalmodell §3, §6 |
| **K3** | **Margin-Sperre in vier Schichten:** richtige Bemessungsgrundlage (`min(TotalCashValue, AvailableFunds)` Hausreserve offene Reservierungen), kein Short, Währungstrennung, Watchdog. Modul-Handel ist strikt cash-only; manueller Handel darf Margin nutzen. | ⬜ | Kapitalmodell §4 |
| **K4** | **Reservierungen über den Order-Lebenszyklus** inkl. Crash-Recovery: beim Start alle nicht-terminalen Reservierungen gegen die offenen Broker-Orders abgleichen. Hängt an **H2**. | ⬜ | Kapitalmodell §5 |
| **K5** | **Abgleich (Reconciliation)** gegen den Broker: `Broker < Ledger` heißt Break betroffene Bücher und Symbol für neue Orders sperren, Alarm. Unzugeordnete Positionen in Quarantäne, Eskalation nach 30 Minuten mit Backoff. | ⬜ | Kapitalmodell §8, §9 |
| **K6** | **Benachrichtigungen** über Outbox + Sink-Abstraktion. Zielkanal Matrix, Telegram optional. | ⬜ | Kapitalmodell §10 |
| **K7** | **Startwerte festlegen** und in `settings.example.json` dokumentieren: `MaxSymbolPercent`, `HouseReserve`, FX-Haircut, Slippage-Puffer, Reservierungs-TTL, Mindestordergröße je Buch, Handelsplatz-Whitelist je Währung. Beim Bauen zu entscheiden. | ⬜ | Kapitalmodell §15 |
---
## Stufe 5 Modul OptionsWheel
*Erst ab hier entsteht das Modul selbst. Vollautomatisch von Anfang an Sicherungen sind Schalter
und Limits, keine Klick-Freigabe. Strike-Wahl delta-basiert im Band 0,150,30. Nur Watchlist,
kein Screening.*
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **W1** | Modul-Gerüst: `IModule`, `ow_`-DbContext + Migration, UI-Tabs, Watchlist. Abschluss: App startet, `--smoke-ui` grün, **kein Handel**. | ⬜ | W-4 |
| **W2** | Reine Strategie-Logik + Tests: `StrikeSelector`, Zustandsautomat je Ticker, `RollDecider`, `PremiumMath`, `CoverageCalculator`. Hohe Testabdeckung ohne Broker. | ⬜ | W-5 |
| **W3** | Verdrahtung + vollautomatischer **Paper**-Betrieb über mehrere Verfallszyklen. Abschluss: mindestens ein vollständiger Wheel-Durchlauf im Paper. | ⬜ | W-6 |
| **W4** | **Earnings-Sperre über den IV-Behelf.** Keine neuen Legs, wenn die IV des Basiswerts deutlich über ihrem 30-Tage-Mittel liegt. Die saubere Lösung (echte Termine für Quartalszahlen) ist über die TWS API **nicht** erreichbar Fehler 10358, Refinitiv-Abo nötig. Einzige Stelle, an der uns eine externe Quelle ernsthaft fehlt. | ⬜ | W-§7.4, Datenlage §2 |
| **W5** | **Accounting-Anschluss für Optionen:** eigene Buchungskategorien im `AccountingClassifier` für Prämien, Zuteilungen und Abrufe. Der `RealizedPnlEngine` (FIFO) kennt heute weder Multiplikator noch die Einstandsverschiebung durch Zuteilung. Arbeit im Accounting-Modul, nicht im Wheel. | ⬜ | W-§7.5 |
| **W6** | **Live-Freigabe.** Eigene Entscheidung nach W3, kein technischer Schritt. | ⬜ | W-7 |
---
## Laufende Bahnen
*Hängen an keiner Stufe und können jederzeit dazwischen laufen.*
### Auslieferung / Deploymentcenter
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **D2** | **Projekt `ibkrtrader` im DC-WebUI anlegen** + Token mit `watchdog:ping` und `bugtracker:report`. Serverseitige Handarbeit; ohne sie bleiben Heartbeat, Lizenz und Bugtracker wirkungslos. | ⬜ | DC-Schritt 9 |
| **D3** | **Update-Anwenden verdrahten.** Die Update-**Prüfung** läuft beim Start. `DcUpdateService.LaunchAgent` ist fertig und dokumentiert (`exitCurrentApp` bewusst immer `false`), aber **kein Aufrufer** fährt danach geordnet herunter. Gefundene Updates werden also nie eingespielt. | 🔶 | DC-Schritt 7 |
| **D4** | **Erstes echtes Release fahren.** Pipeline (`scripts/release.*`, `setup.json`) steht, ist aber nie gelaufen. Dabei entsteht `packager.config.json` unter `.dc-tools/`. **Achtung:** Die `preservePatterns` müssen beim *ersten* Release stimmen ein Update, das `settings.json` überschreibt, nimmt einer laufenden Installation Datenbank, Token und Flex-Zugang gleichzeitig. Ebenso muss der erste Build bereits den `licenseKey` mitgeben, sonst fällt die Tür hinter dem ersten Release zu (bei Predictalytics genau so passiert). | ⬜ | DC-Schritt 8, P6, D2 |
| **D5** | **Bugtracker-Baustein** nach `AGENTS.md` / `.agents/rules`. Braucht **D2**. | ⬜ | DC-Schritt 9 |
| **D6** | **Lizenzfenster für die Shell.** `LicenseGuard` läuft mit `allowPrompt: false` weder Shell noch Daemon dürfen auf eine Konsoleneingabe warten, die nie kommt. Ein eigenes Fenster für die Desktop-Shell war als Folgeschritt vorgesehen. | ⬜ | DC §5.2 |
### Accounting
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **A1** | **Live-IBKR-Flex-Abruf** (Token + Query-Id) und Balance-Anker. Heute liegen dort Offline-Null-Stubs: Das Modul läuft vollständig und bucht dabei korrekt nichts. Ohne diesen Schritt entstehen **keine echten Buchungen** das Modul ist lauffähig, aber nicht in Betrieb. | ⬜ | Accounting §6 |
| **A2** | **EZB-FX-Ingest** (`acc_fx_rates` füllen) für die EUR-Ansicht. USD als Basis ist sofort verfügbar. | ⬜ | Accounting §6 |
| **A3** | **Steuerschicht.** Jurisdiktion (DE-Kapitalertragsteuer / US Form 8949) ist **nicht festgelegt**. Der neutrale Ledger und die Abrechnung gelten unabhängig davon; die Steuer-Engine ist als klar abgetrennter Platzhalter angelegt. Das Jurisdiktionsprofil wird einmalig gesetzt und danach in der DB verankert. **Keine Steuerberatung.** | ⬜ | Accounting §6, Kapitalmodell §11 |
### Supervisor
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **S1** | **Counterfactual-Kursauflösung für Aktien** (späterer Kurs vs. Signalpreis) Interface und Stub sind vorhanden, die Auflösung fehlt. | ⬜ | Supervisor §offen |
| **S2** | **Externer Versand des Tagesberichts.** Heute nur Persistenz und Log. Sollte denselben Outbox-/Sink-Weg nehmen wie **K6**, statt einen zweiten zu bauen. | ⬜ | Supervisor §offen |
### Technische Schulden
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **T2** | **Log-Level ist nicht konfigurierbar.** `LoggingService.SetMinLevel` ist der einzige Setter und wird nie gerufen; `_minLevel` steht damit fest auf `Info`, und da `Info = 0` der kleinste Wert ist, filtert die Prüfung `level < _minLevel` nie etwas. Entweder an die Einstellungen anbinden oder den Filter aufgeben. | ⬜ | Befund 2026-08-23 |
| **T3** | **Es gibt kein einziges Diagramm.** LiveCharts2 ist in der NuGet-Allowlist vorgesehen und **Avalonia ist deswegen auf der 11er-Linie festgehalten** (11.3.19 / DataGrid 11.3.13). Diese Pinnung kostet uns Avalonia 12, ohne dass bisher ein Diagramm existiert. Entweder mit den neuen Modulen einlösen oder die Pinnung aufgeben. | ⬜ | L-Ergebnis, grundregeln |
---
## 🧊 Zurückgestellt vorgehalten, aber nicht jetzt
*Bewusste Entscheidungen, keine Versäumnisse. Jede Zeile nennt, **was sie wieder aktuell macht**.*
| Idee | Warum nicht jetzt | Wird aktuell, wenn … | Herkunft |
|---|---|---|---|
| **Trendfolge-/Momentum-Modul** auf Tagesbasis als zweites Standbein | Datenlage ist komfortabel (10 J Tagesbars, dividendenbereinigt), das Risiko liegt in der Strategie. Aber ein zweites handelndes Modul vor dem Kapitalmodell wäre fahrlässig. | Wheel im Paper läuft **und** Stufe 4 steht | Datenlage §4 |
| **Marktdaten-Abo US-Realtime** (NYSE/AMEX/NASDAQ) | Schaltet Scanner und Realtime frei. Für Tages- und Prämienstrategien **nicht nötig** unsere Signale kommen aus abgeschlossenen Bars, die 15-Minuten-Verzögerung ist dabei irrelevant. | wir Screening oder Intraday wollen | Datenlage §4 |
| **OPRA-Abo** (Optionen-Realtime) | Verbessert die Ausführungsqualität beim Wheel; für die Strike-Auswahl nachweislich nicht erforderlich (Greeks funktionieren verzögert). | die Ausführungsqualität im Paper-Betrieb messbar stört | W-§7.3 |
| **Refinitiv-Fundamentaldaten** | Der Kandidat mit dem größten qualitativen Sprung: löst die Earnings-Sperre und öffnet fundamentale Ansätze. Kostet aber Geld für ein System, das noch nie eine Order platziert hat. | der IV-Behelf (**W4**) sich als zu unscharf erweist | Datenlage §4 |
| **Self-Cross-Netting** (Modul A kauft, B verkauft dasselbe Symbol) | Bei unserer Handelsfrequenz unrealistisch. **Billige Vorstufe stattdessen:** ist im Order-Gateway eine gegenläufige Order für dasselbe Symbol pending, wird protokolliert und gewarnt der Lock ist ohnehin da. Das liefert Daten darüber, ob das Problem je real wird. | die Warnung tatsächlich anschlägt | Kapitalmodell §14 |
| **Corporate Actions** (Splits, Spin-offs) in der Buchzuordnung | Vorerst über den Flex-Abgleich als Break sichtbar, manuelle Zuordnung. | ein Break real auftritt | Kapitalmodell §14 |
| **Web-UI** über die interne REST-API | Die REST-API existiert, ein Web-UI ist reine Zusatzarbeit ohne Betriebsnutzen, solange Shell und Daemon reichen. | Fernzugriff nötig wird | grundregeln |
| **Desktop-Distribution der Shell** über die Release-Pipeline | Die Vorlage nimmt genau eine csproj für alle Runtimes, und das Skript bleibt laut Anleitung unverändert. Release-Kandidat ist deshalb `IBKRTrader.Daemon`. | die Shell auf fremden Rechnern laufen soll | release.config.json |
| **Ungeprüfte Datenfragen**: `reqNewsArticle` (Volltext), Reichweite der Nachrichtenhistorie, Markttiefe (`reqMktDepth`), leeres `reqHistogramData`, Verlässlichkeit verzögerter Greeks außerhalb der Handelszeiten, Ratenbegrenzung bei Historienabrufen | Keine dieser Fragen blockiert die geplanten Strategien. | eine Strategie sie braucht die Ratenbegrenzung schon bei einem nächtlichen Watchlist-Abruf | Datenlage §5 |
---
## ❌ Verworfen geprüft und entschieden
*Damit nichts davon in sechs Monaten erneut als gute Idee auftaucht.*
| Idee | Warum verworfen |
|---|---|
| **Echte IBKR-Sub-Accounts** (Advisor-/Family-Struktur) zur brokerseitigen Trennung der Bücher | Erfordert Kontotypwechsel und feste Vorabaufteilung des Kapitals deutlich unflexibler als virtuelle Bücher, die denselben Zweck erfüllen. |
| **`InvariantGlobalization=true`** für ein schlankes Linux-Image | In der Portierungsanalyse noch empfohlen, **wäre ein Fehler gewesen**: ohne ICU fällt die Auflösung von Windows-Zeitzonen-IDs aus und die feste `de-DE`-Formatierung des PDF-Exports kippt auf invariant. Beides lautlos. Der Daemon setzt es ausdrücklich auf `false`. |
| **Fundamentales Screening** (Value, Quality, Growth) | Keine Fundamentaldaten über die TWS API. |
| **Earnings-Strategien** (Straddle vor Zahlen, Post-Earnings-Drift) | Keine Termine für Quartalszahlen erreichbar (Fehler 10358). |
| **Marktweite Anomalie-Suche / Screener-getriebene Auswahl** | Der Marktscanner ist ohne Realtime-Abo gesperrt. Jede Strategie arbeitet auf einer gepflegten Watchlist. |
| **Daytrading, Scalping, Orderbuch-Strategien** | Verzögerte Kurse (~15 Min). Ausführung träfe den Markt zu spät. |
| **Gap-Strategien auf Eröffnung** | Eröffnungskurs kommt verzögert nur mit Realtime-Abo sinnvoll. |
| **Strike-Wahl über prozentualen Abstand** statt delta-basiert | Als Ersatzlösung gedacht, falls Greeks mit verzögerten Daten nicht funktionieren. Sie funktionieren (am 2026-08-04 gemessen). Bleibt im `StrikeSelector` nur als Rückfalllinie. |
| **Agent-zu-Agent-Orchestrierung im Supervisor** | Bewusst nicht: Profile sind System-Prompt + Tool-Subset über *einer* Infrastruktur. Einfacher und nachvollziehbarer. |
| **Modulzuordnung als Korrektur des Steuerbuchs** | Harte Regel: Das Managementbuch beeinflusst das Steuerbuch **niemals**. Die Zuordnung ist eine optionale, nicht-autoritative Beistelltabelle fehlt sie oder ist sie falsch, ändert sich am Steuerergebnis exakt nichts. |
---
## Fertig der Weg bis hierher
*Kurzfassung. Die vollständigen Phasen-Checklisten stehen weiterhin in [ARCHITECTURE.md](ARCHITECTURE.md).*
| Abschnitt | Inhalt | Fertig |
|---|---|---|
| **R1R7** | Kurskorrektur auf das PolytraderSharp-Konzept: Multi-Projekt, Generic Host, EF Core, Trading-Kern, CongressTrading-Strategie, Security, Dashboard, TWS-Broker-Adapter | 2026-07 |
| **S-0S-4** | Supervisor: Datenfundament (Entscheidungsjournal, Order-Events, `SignalId`, JSONL-Logs), Dossiers, OpenRouter-Agent mit read-only Tools, Berichte, MCP-Light | 2026-07-30 |
| **Accounting** | `acc_`-Schema, append-only Ingest, Klassifizierung, Abrechnung, FX, CSV-/PDF-Export mit Offline-Null-Stubs | 2026-07-30 |
| **L0L6** | Linux-Portierung: Avalonia statt WinForms, Betriebszeitzone, kopfloser Daemon, geteiltes Hosting, WinForms vollständig entfernt, Namensgebung bereinigt | 2026-08-07 |
| **DC 08** | Deploymentcenter: Lizenz mit Sperrbetrieb, Watchdog-Heartbeat, globale Ausnahmebehandler + Fehler-Stream, Update-Prüfung, `setup.json`, Release-Pipeline | 2026-08-23 |
| **Frühjahrsputz** | Toter Code entfernt, ungenutzte Symbole und Paketmuster raus, Dokumentenstand an die Wirklichkeit angeglichen | 2026-08-23 |
---
## Archiv
Die Quelldokumente sind **vollständig erhalten** und liegen unter [archiv/](archiv/). Sie werden
nicht mehr gepflegt der Stand steht hier , aber sie tragen die Herleitung, die Messwerte und die
Begründungen, die eine Roadmap nicht fassen kann.
| Dokument | Was darin steht, das hier fehlt |
|---|---|
| [Kapital-und-Buchmodell.md](archiv/Kapital-und-Buchmodell.md) | Die vollständige Spezifikation: die drei Wahrheiten, Eigentumsregeln, Reservierungs-Lebenszyklus, Invarianten und Testkatalog. Referenz für Stufe 4. |
| [KONZEPT-Modul-OptionsWheel.md](archiv/KONZEPT-Modul-OptionsWheel.md) | Zustandsautomat, Regelwerk mit Vorgabewerten, `ow_`-Datenmodell, Sicherungen. Referenz für Stufe 5. |
| [KONZEPT-Datenlage-und-Strategien.md](archiv/KONZEPT-Datenlage-und-Strategien.md) | **Gegen das laufende Paper-Gateway gemessen**, nicht aus der Doku übernommen: was die TWS API liefert und welche Strategien das trägt. Grundlage der Zurückgestellt- und Verworfen-Listen. |
| [KONZEPT-Deploymentcenter-Integration.md](archiv/KONZEPT-Deploymentcenter-Integration.md) | Die bewussten Abweichungen vom DC-Leitfaden (Sperrbetrieb statt Prozessende, `exitCurrentApp: false`) samt Begründung, die Befunde P1P6 und D1D7. |
| [KONZEPT-Linux-Portierung.md](archiv/KONZEPT-Linux-Portierung.md) | Die Analyse vor dem Umbau: Fundstellenverzeichnis, Aufwandsschätzung und was anders kam als geschätzt. |
| [KONZEPT-Modul-Accounting.md](archiv/KONZEPT-Modul-Accounting.md) | Leitprinzipien (unabhängige Quelle, Idempotenz, append-only), Datenbeschaffung über Flex Query. |
| [KONZEPT-Modul-Supervisor.md](archiv/KONZEPT-Modul-Supervisor.md) | S-0 bis S-4 im Detail, Tool-Registry, Sicherheitsgrenzen des Agenten. |
**Weiter gepflegt** werden sie sind Referenz, keine Planung:
[ARCHITECTURE.md](ARCHITECTURE.md) (Aufbau und Phasen-Historie),
[IBKR-Integration.md](IBKR-Integration.md) (Adapter-Design und Grenzen),
[TWS-Setup-Checkliste.md](TWS-Setup-Checkliste.md) (Einrichtung eines neuen Systems).
+100 -1
View File
@@ -68,7 +68,9 @@ der Adapter ab und bleibt inaktiv, statt auf dem falschen Konto zu handeln.
## 7. Verifikation ## 7. Verifikation
- [ ] Port erreichbar? `Test-NetConnection 127.0.0.1 -Port 4002``TcpTestSucceeded: True` - [ ] Port erreichbar?
Windows: `Test-NetConnection 127.0.0.1 -Port 4002``TcpTestSucceeded: True`
Linux: `ss -ltn '( sport = :4002 )'` bzw. `nc -zv 127.0.0.1 4002`
- [ ] API-Verbindung: Verbindungstest ausführen (Konto-ID, NetLiquidation, Positionen müssen - [ ] 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. kommen). Hängt der Handshake > 10 s → Trusted IP fehlt (Punkt 3) oder Popup offen.
@@ -108,6 +110,103 @@ bei jeder Kursanfrage der Normalfall, die Kurse kommen danach trotzdem.
- Einstellungsänderungen im API-Dialog immer mit **Übernehmen/OK** abschließen; solange der - Einstellungsänderungen im API-Dialog immer mit **Übernehmen/OK** abschließen; solange der
Dialog offen ist, gelten sie nicht. Dialog offen ist, gelten sie nicht.
## Unterschiede unter Linux
Gilt für TWS bzw. IB Gateway auf einem Linux-Host. **Am Protokoll ändert sich nichts**: dieselbe
Java-Anwendung, dieselben Ports, derselbe Einstellungsdialog, dieselbe API-Version. Der Adapter
(`IBKRTrader.Core/Trading/Ibkr/`) braucht keine Anpassung das TWS-API-Paket referenziert nur
`mscorlib`, `System` und `System.Core`, keine Windows-Assembly, und der Core baut fehlerfrei für
`linux-x64` (geprüft 2026-08-04).
Anders sind Betrieb und Umgebung:
| Thema | Windows | Linux |
|---|---|---|
| Installationsverzeichnis | `C:\Jts` | `~/Jts` |
| Einstellungen (inkl. Trusted IPs) | verschleiertes Unterverzeichnis je Login | genauso, unter `~/Jts` |
| Start | Desktop-Sitzung vorhanden | **X-Server nötig** headless: `Xvfb` |
| Auto-Login/Neustart | IBC als geplanter Task | IBC als **systemd**-Unit (der besser unterstützte Weg) |
| Port prüfen | `Test-NetConnection` | `ss -ltn` / `nc -zv` |
### Worauf konkret zu achten ist
- [x] **Grafische Sitzung.** TWS ist eine GUI-Anwendung und startet ohne Display nicht.
**Unser Aufbau:** Ubuntu-Desktop-VM mit gespiegelter Bildschirmfreigabe per RDP also eine
echte, dauerhaft laufende X-Sitzung. Damit entfällt die Xvfb-Frage, und der Einstellungsdialog
(Punkt 3) ist jederzeit erreichbar. **Wichtig:** gespiegelte Freigabe, **keine**
RDP-Remoteanmeldung eine eigene Anmeldesitzung startet einen zweiten Desktop, in dem das
laufende TWS nicht sichtbar ist und die Sitzung beim Abmelden mitgeht.
(Nur für einen echten headless Server wäre `Xvfb` + `x11vnc` nötig.)
- [ ] **Schriftarten installieren** (`fontconfig` plus z.B. `dejavu`). Fehlen sie, startet die
Java-Oberfläche gar nicht oder rendert leer die häufigste Stolperfalle bei schlanken Images.
- [ ] **Einstellungen neu setzen, nicht kopieren.** Die API-Einstellungen hängen am Login-Profil
unter `~/Jts`. Auf dem neuen Host Punkte 35 dieser Checkliste einmal komplett durchgehen.
- [ ] **Offline-Installer bevorzugen.** Der selbstaktualisierende Installer kann TWS unbemerkt auf
eine neue Version heben, die eine andere API-Serverversion spricht.
- [ ] **Zeitzone je Instanz setzen.** `Trading.ApplicationTimeZoneId` steuert die Betriebszeitzone
und ist **unabhängig** von der des Hosts. EU-Instanzen `Europe/Berlin`, US-Instanzen
`America/New_York`. TWS meldet Ausführungszeiten teils mit, teils ohne Zonenangabe ohne
Angabe greift dieser Wert als Rückfall. Wie sich das im Betrieb beobachten lässt, steht
unten unter „Zeitverhalten beobachten".
- [ ] **ICU im Image sicherstellen** (`libicu` / `icu-data-full`). Die Zonenauflösung nutzt
IANA-IDs (`US/Eastern`); ohne ICU wirft `FindSystemTimeZoneById`. Die Projekte setzen
deshalb bewusst `InvariantGlobalization=false` siehe `IBKRTrader.Daemon.csproj`.
- [ ] **Schreibrechte des Dienstbenutzers** auf `~/Jts` prüfen. Bei systemd mit eigenem `User=`
braucht dieser ein echtes Home-Verzeichnis. Für die App selbst regelt `AppPaths` die
Ablage bereits FHS-konform.
- [ ] **Java-Heap** in `tws.vmoptions` (im Installationsverzeichnis) prüfen, wenn viele
Instrumente abonniert werden gleiche Datei wie unter Windows, anderer Pfad.
- [ ] **Wenn TWS und App auf verschiedenen Rechnern laufen:** „Nur Verbindungen vom lokalen Host“
aus, IP der App-Maschine als Trusted IP eintragen und weil der API-Socket **unverschlüsselt**
ist, nur über VPN oder SSH-Tunnel, nie offen übers Netz.
> Diese Liste beruht auf Erfahrungswerten zum TWS-Betrieb, **nicht** auf einer Messung auf einem
> Linux-Host anders als der Rest dieser Checkliste. Beim ersten Aufsetzen entsprechend prüfen
> und die Punkte hier korrigieren.
## Zeitverhalten beobachten (Paper-Phase)
Zwischen einer EU- und einer US-Instanz ist die Zeitzone die gefährlichste Stelle: Ein falscher
Wert wirft keinen Fehler, er verschiebt nur Buchungszeiten. Damit das während der Paper-Phase
auffällt statt später im Echtbetrieb, schreibt der Adapter drei Dinge mit (Modul `IBKR`).
**1. Zeitkontext bei jedem Verbindungsaufbau** eine Info-Zeile, die den gesamten Rahmen festhält:
```
Zeitkontext: Betriebszeitzone Europe/Berlin, Systemzeitzone Europe/Berlin,
TWS-Serverzeit 2026-08-04 15:39:18Z, Uhrenversatz +0.2 s.
```
Damit lässt sich jeder spätere Zeitfehler an einer Zeile aufklären, statt im Nachhinein zu raten,
wie die Instanz konfiguriert war.
**2. Uhrenversatz gegen den TWS-Server.** Mehr als 5 s Abweichung ergeben eine **Warnung**. In
virtuellen Maschinen ist eine driftende Uhr ein häufiger Fehler besonders nach Snapshots oder
Pausieren der VM.
**3. Herkunft jedes Ausführungs-Zeitstempels.** Nach jedem Abruf steht im Log, wie viele Zeitpunkte
TWS **mit** Zonenangabe gemeldet hat und wie viele über die Betriebszeitzone **angenommen** wurden:
```
Zeitstempel von 2 Ausführung(en): 0 mit gemeldeter Zone, 2 über die Betriebszeitzone Europe/Berlin.
```
Beobachtungsstand 2026-08-04: `execDetails` lieferte die Zeit **ohne** Zonenangabe
(`"20260804 17:39:18"`), `reqCompletedOrders` dagegen **mit** (`"... Europe/Berlin"`). Der
Normalfall beim Ausführungsabruf ist also die Annahme genau deshalb wird sie gezählt.
### Worauf zu achten ist
| Logmeldung | Bedeutung | Reaktion |
|---|---|---|
| `Uhrenversatz` über 5 s | Host-Uhr läuft auseinander | Zeitsynchronisation der VM prüfen |
| `... wurden gegen die Betriebszeitzone X gerechnet, das System läuft aber auf Y` | Betriebs- und Systemzeitzone gehen auseinander | Prüfen, gegen welche Uhr TWS meldet; einmal mit dem TWS-Fenster gegenlesen |
| `Ausführung(en) mit unlesbarem Zeitstempel` | TWS-Format hat sich geändert | Defekt `IbkrMapping.ResolveExecutionTime` anpassen |
**Gegenprobe beim Aufsetzen einer Instanz:** Eine Ausführung im TWS-Fenster ansehen und die dort
angezeigte Uhrzeit mit der gebuchten vergleichen. Stimmen beide, ist die Zeitzone richtig gesetzt.
Das kostet zwei Minuten und ist die einzige verlässliche Probe alles andere ist Papier.
## Unterschiede Live-Betrieb (später) ## Unterschiede Live-Betrieb (später)
| Punkt | Paper | Live | | Punkt | Paper | Live |
@@ -1,3 +1,13 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es **gegen das laufende Paper-Gateway gemessen** ist und nicht aus der IBKR-Doku uebernommen.
> Die Zurueckgestellt- und Verworfen-Listen der Roadmap stuetzen sich auf diese Messwerte. Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# Analyse: Datenlage über die TWS API und welche Strategien sie trägt # 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 > Stand: 2026-08-04. **Alle Angaben in Abschnitt 1 und 2 sind gegen das laufende Paper-Gateway
@@ -0,0 +1,672 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) (Bahn „Auslieferung / Deploymentcenter") dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es die bewussten Abweichungen vom DC-Leitfaden begruendet Sperrbetrieb statt
> Prozessende, `exitCurrentApp: false` und die Befunde P1P6 und D1D7 nachweist. Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# KONZEPT: Deploymentcenter-Integration
> Stand: 2026-08-14 · Deploymentcenter-Version **2.5.1** · Quelle: `J:\Softwareprojekte\Deploymentcenter\docs`,
> `GET /api/updateservice/v1/changelog?since=2.4`
> Zielprojekt: IBKRTrader (App + Daemon), Projekt-Slug **`ibkrtrader`**
> SDK-Zielversion: `Deploymentcenter.Client` **2.5.1** (`DeploymentcenterSdk.Version` mitführen, damit
> künftige Anbindungen über `?since=2.5.1` direkt sehen, was sich seither geändert hat)
> **Nachtrag 2026-08-14:** Die ursprüngliche Fassung dieses Dokuments (Stand DC 2.4) enthielt sieben
> Befunde/Bedenken in §8, die dem Deploymentcenter-Entwickler mitgeteilt wurden. Alle sieben sind mit
> 2.5.0/2.5.1 behoben — Einzelheiten weiterhin in §8, jetzt mit Fix-Vermerk. Zwei Neuerungen aus
> derselben Runde gehen über die ursprünglichen Befunde hinaus und sind in §5.1, §7.2 und §2.2
> eingearbeitet: `exitCurrentApp` ist jetzt als harter Kill dokumentiert, und `BuildInfo.targets` liegt
> im NuGet-Paket selbst.
Das Deploymentcenter (`https://dc.mhdf.de`) bündelt fünf Dienste, die IBKRTrader bisher
alle nicht hat: **Lizenz**, **Watchdog**, **Error-Reporting**, **Bugtracker** und
**UpdateService inkl. Erstinstallation**. Dieses Dokument beschreibt, in welcher
Reihenfolge sie angebunden werden, was dabei am Projekt vorher aufgeräumt werden muss
und wo die vom Deploymentcenter vorgeschlagenen Muster für ein *handelndes* System
nicht ohne Weiteres passen.
---
## 0. Ausgangslage
| | Zustand heute |
|---|---|
| DC-Anbindung | **keine** — kein Treffer für `Deploymentcenter`, `dc.mhdf.de` oder `DC_TOKEN` im Quelltext |
| Version | Zeichenkette `"1.0.0"` in `App/Program.cs:34` und `Daemon/Program.cs:43`, keine `<Version>`, keine `Directory.Build.props` |
| Globale Fehlerbehandlung | **keine** — weder `AppDomain.UnhandledException` noch `TaskScheduler.UnobservedTaskException` |
| Auslieferung | `dotnet publish` von Hand, `deploy/README.md`, systemd-Unit |
| CI | Gitea Actions, Matrix `ubuntu-latest` / `windows-latest`, Checkout **nur** von IBKRTrader |
Referenzintegration ist **Predictalytics** (`src/Predictalytics.Hosting/Dc*.cs`,
`docs/BETRIEB-Deploymentcenter.md`). Sie liegt auf SDK-Stand 2.2 und ist damit
unvollständig — siehe [§8](#8-befunde-und-offene-punkte), Punkt D2. Übernommen wird
das Muster, nicht der Code eins zu eins.
Der Einbauort ist **`IBKRTrader.Hosting`**: den Host teilen sich Shell und Daemon
(`AppHostBuilder`), damit gilt jede Anbindung automatisch für beide Betriebsformen.
`IBKRTrader.Core` bleibt frei davon — der Core soll keine Netzabhängigkeit zu einem
Verwaltungsserver bekommen.
---
## 1. Voraussetzungen im Deploymentcenter (vor jedem Code)
Ohne diese Schritte läuft nichts von dem, was danach kommt:
1. **Projekt `ibkrtrader` anlegen** (WebUI → Projekte). Slug wird von Lizenz,
UpdateService, Fehler-Stream und Bugtracker gemeinsam benutzt.
2. **Offline-Gnadenfrist festlegen** (`default_cache_ttl_hours`). Vorgabe 168 h.
Für einen Handelsdienst, der auch bei gestörter Internetverbindung weiterlaufen
soll, ist das die entscheidende Stellschraube — siehe [§5](#5-lizenz).
3. **Lizenz für `ibkrtrader` ausstellen.** Ohne sie kommt die Anwendung nach dem
ersten Release nicht mehr an ihre Updates (Zugangsschutz seit 2.4).
4. **Master-Token** mit `watchdog:ping`, `bugtracker:report` — für die Anwendung.
Zweites Token mit `updateservice:publish` — für die Release-Pipeline, gehört
**nicht** in die Anwendung.
5. **Installationskonto** (Rolle `installer`) für die Erstinstallation.
6. **Evaluator-Cron prüfen.** `* * * * * php cli/tick.php --quiet`. Ohne ihn bleibt
ein abgestürzter Trading-Daemon im Dashboard dauerhaft grün — der Watchdog wäre
dekorativ. Derselbe Job erzeugt auch den Release-Zugangsschutz.
---
## 2. Build-Grundlage
### 2.1 `Directory.Build.props` mit `<Version>`
```xml
<Project>
<PropertyGroup>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
```
`pack-and-deploy` liest die Version aus der Hauptassembly und **bricht bei einer
Abweichung zu `--version` ab**. Der Grund ist unangenehm genug dafür: Wird `1.0.1`
als `1.0.2` veröffentlicht, aktualisiert die gesamte Installationsbasis, meldet
danach weiterhin `1.0.1`, hält das Release erneut für neu — und aktualisiert bei
jedem Start wieder.
Gleichzeitig entfallen die beiden hartkodierten `"1.0.0"` in den `Program.cs`;
sie werden durch `BuildInfo.Version` ersetzt.
### 2.2 SDK-Anbindung — entschieden, in zwei Stufen
`Deploymentcenter.Client` (`netstandard2.0;net8.0`) wird von `net10.0` problemlos
konsumiert. Seit SDK 2.5.0 liegt `BuildInfo.targets` im Paket selbst
(`build/`-Ordner, von NuGet automatisch importiert) — der frühere `<Import>` per
Cross-Repo-Pfad ist damit **nicht mehr nötig**. Das entscheidet die Frage
grundsätzlich zugunsten eines echten NuGet-Bezugs. Geprüft (2026-08-14): Weder
`https://dc.mhdf.de/nuget/` noch die Gitea-Package-Registry unter
`http://192.168.178.10:8418/api/packages/Richard/nuget/` führen aktuell ein
Paket `Deploymentcenter.Client` — es gibt also noch keine Quelle, gegen die eine
`PackageReference` auflösen könnte.
**Deshalb zweistufig:**
| Stufe | Weg | Zustand |
|---|---|---|
| **Jetzt (umgesetzt)** | Cross-Repo-`ProjectReference` (Muster Predictalytics), `Deploymentcenter.BuildInfo.targets` weiterhin per `<Import>` eingebunden — der pfadbasierte Import funktioniert laut Leitfaden unverändert weiter, auch wenn er nicht mehr nötig wäre | funktioniert sofort, keine neue Infrastruktur, keine Push-Entscheidung nötig |
| **Folgeschritt (offen)** | `dotnet pack` im Deploymentcenter-Repo, Push nach `.../api/packages/Richard/nuget/`, `NuGet.config` um Quelle `gitea` mit `packageSourceMapping` auf `Deploymentcenter.*` ergänzen, `<Import>` durch `<PackageReference Include="Deploymentcenter.Client" Version="2.5.1" />` ersetzen | braucht einen Push in ein fremdes Repo (Deploymentcenter) und Package-Registry-Zugangsdaten — bewusst nicht Teil dieses Durchlaufs |
Der Cross-Repo-Bezug bricht die Gitea-CI von IBKRTrader (der Runner checkt nur
dieses eine Repo aus) — siehe **P5**. Das ist mit der Wahl von Stufe 1 bewusst in
Kauf genommen, weil eine funktionierende lokale Integration wichtiger ist als eine
grüne CI, die eine noch nicht existierende Paketquelle voraussetzt.
### 2.3 `BuildInfo`
`Deploymentcenter.BuildInfo.targets` in die csproj von `IBKRTrader.Hosting`
importieren. Die Klasse entsteht in `$(RootNamespace)`, also `IBKRTrader.Hosting.BuildInfo`
— erreichbar ohne `using`. Liefert `Version`, `GitCommit`, `GitCommitShort`,
`BuildDateUtc`, `Channel`, `Summary`.
> **Erledigt mit 2.5.1** (vormals §8 D4): `BuildDateUtc` trug die volle Uhrzeit,
> `WriteOnlyWhenDifferent` griff dadurch nie, und jedes importierende Projekt wurde
> bei jedem Build neu übersetzt. Seit 2.5.1 ist das Feld tagesgenau — höchstens ein
> Neubau pro Tag. Vorbedingung: die Targets-Datei im Schwester-Repo muss auf 2.5.1
> oder neuer stehen (`git -C ../Deploymentcenter log -1 --oneline -- client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets`).
---
## 3. Konfiguration und gemeinsame Bausteine
### 3.1 `DcConfig` (kompiliert, nicht einstellbar)
```csharp
namespace IBKRTrader.Hosting.Deploymentcenter;
public static class DcConfig
{
public const string BaseUrl = "https://dc.mhdf.de"; // bewusst KEINE Einstellung
public const string ProductSlug = "ibkrtrader";
public const string MonitorGroup = "Trading";
public static string AppVersion => BuildInfo.Version;
public static string GitCommitShort => BuildInfo.GitCommitShort;
}
```
Die Basis-URL entscheidet, wohin die Lizenzprüfung geht und woher ausführbarer
Code kommt. Einstellbar wäre sie eine Einladung, die Anwendung auf einen
gefälschten Update-Server zu zeigen.
### 3.2 Neuer Abschnitt in `AppSettings`
Ergänzt `src/IBKRTrader.Core/Settings/AppSettings.cs` um
`DeploymentcenterSettings` (gleiches Muster wie `LoggingSettings`, mit
`Category`/`DisplayName`/`Description`, damit es in der Settings-Ansicht auftaucht):
| Feld | Vorgabe | Bedeutung |
|---|---|---|
| `ApiToken` | *(leer)* | `Authorization: Bearer`. Leer = kein Heartbeat, keine Fehlermeldung. `[PasswordPropertyText]` |
| `HeartbeatEnabled` | `true` | Dead-Man's-Switch |
| `MonitorSource` | *(Hostname)* | Monitorname im Dashboard |
| `MonitorInstance` | `default` | mehrere Instanzen (EU/US) desselben Dienstes |
| `HeartbeatIntervalSeconds` | `60` | `warning` nach 2×, `down` nach 4× |
| `ReportErrors` | `true` | Error/Fatal an den Fehler-Stream |
| `CheckUpdatesOnStart` | `true` | prüft nur, installiert nie von selbst |
| `UpdateChannel` | `prod` | `prod`, `beta`, `dev` |
| `LicenseKey` | *(leer)* | nur Rückfall; Regelfall ist der verschlüsselte Cache |
`ApiToken` und `LicenseKey` sind schutzbedürftig und laufen über
`SecretProtection` (AES-256-GCM at rest), wie die übrigen Geheimnisse des Projekts.
### 3.3 `DcApiClient`
Schlanker JSON-Client für Heartbeat und Fehler-Stream (Lizenz und Update bringen
ihren eigenen mit). Übernimmt von Predictalytics:
- `DcApiException` mit stabilem `error.code` aus dem Umschlag
`{"status":"error","error":{"code":"…"}}` — reagiert wird auf den Code, nie auf den Text.
- `IsPermanent` für `401`/`403`/`unauthorized`: danach wird nicht weiter versucht.
- Zeitgrenze 10 s. **Ein nicht erreichbares Deploymentcenter darf den Handel
niemals beeinflussen** — jeder Aufruf ist best effort.
---
## 4. Watchdog
Neuer Worker `DcHeartbeatWorker` in `IBKRTrader.Hosting`, registriert wie die
übrigen (`IWorker` + `AddHostedService`), damit er in der Worker-Ansicht
auftaucht und manuell auslösbar ist.
`POST /api/watchdog/v1/ping` alle *n* Sekunden mit `source`, `instance`,
`interval`, `os`, `version = BuildInfo.Version`, `group = "Trading"`.
### 4.1 `checks` — was IBKRTrader wirklich über sich weiß
Ein Heartbeat beweist nur, dass ein Timer läuft. Der klassische Fehlerfall dieses
Projekts ist ein anderer: der Prozess lebt, aber die TWS-Verbindung ist seit einer
Stunde tot und es kommen keine Kurse mehr.
| Check | Quelle | `ok` wenn |
|---|---|---|
| `db` | EF/`MySqlConnector`, `SELECT 1`, gedeckelt auf 5 s | Verbindung steht |
| `ibkr_gateway` | `IBKRGatewayService` | verbunden **oder** `UseTwsApi = false` (NullBroker ist ein gewollter Zustand, kein Fehler) |
| `market_data` | `IBKRMarketDataRepository` | letzter Tick jünger als *x* min — nur wenn TWS aktiv |
| `worker_engine` | `WorkerEngine` | kein Worker im Fehlerzustand |
Bewusst **kein** Check auf „Trading aktiv": `TradingEnabled = false` ist ein
gewollter Betriebszustand und würde den Monitor sonst dauerhaft auf `warning`
halten. Er wandert als Metrik mit.
### 4.2 `metrics`
`uptime_sec`, `trading_enabled` (0/1), `open_positions`, `orders_today`,
`account_value`, `workers_running`, `memory_mb`, `decision_journal_lag_sec`.
Serverseitig 14 Tage Verlauf mit Abweichung zum 7-Tage-Mittel desselben Monitors —
damit lässt sich „heute plötzlich 40 Orders statt 3" erkennen, ohne im
Deploymentcenter Schwellwerte für jedes Projekt zu pflegen.
### 4.3 Sauberes Beenden
Beim geordneten Herunterfahren (SIGTERM/systemd, Fenster schließen) geht ein Ping
mit `status: "stopped"` raus. Ohne ihn folgt wenige Minuten nach jedem
planmäßigen Neustart ein Fehlalarm. Der Aufruf gehört in `StopAsync`
**vor** das Beenden der übrigen Dienste und braucht eine eigene, kurze Zeitgrenze
(3 s), damit ein hängender Monitoring-Server das Herunterfahren nicht blockiert.
### 4.4 Hierarchie
Läuft der Daemon auf einem eigenen Host, gehört im WebUI unter
**WatchDog → System-Hierarchie** die *Übergeordnete Entität* auf diesen Host
gesetzt. Sonst erzeugt ein Maschinenausfall zwei Meldungen für ein Problem.
---
## 5. Lizenz
`LicenseGuard` in `IBKRTrader.Hosting`, aufgerufen aus `RunStartupChecks`
nach der Zeitzone (Logausgaben) und **vor** dem Start der Worker.
- Produkt-Slug `ibkrtrader`, Hardware-ID v2 (`2:win:…` / `2:lin:…`).
- `LicenseClient.DefaultAppVersion = BuildInfo.Version` einmalig beim Start,
sonst trägt jede Installation in der Aktivierungsliste dieselbe `1.0.0`.
- Revalidierung zur Laufzeit alle 12 h; Warnung, wenn die Gnadenfrist in unter
48 h abläuft.
- **`LicenseConfig.SetStorageDirectory(AppPaths.ConfigFile("license"))`** —
ausdrücklich setzen. Die Vorgabe des SDK löst unter Linux über `$XDG_CONFIG_HOME`
bzw. `$HOME` auf; ein systemd-Dienst mit `User=ibkrtrader` und ohne Heimatverzeichnis
landet im Rückfall auf `AppContext.BaseDirectory/license`, also unter `/opt`
dort ist nichts schreibbar, und es gäbe **keinen Offline-Cache**. Mit `AppPaths`
fügt sich die Ablage in die bereits vorhandene Verzeichnisauflösung des Projekts ein.
**Erledigt/entschärft mit 2.5.1** (vormals §8 D4): der Rückfall ohne `$HOME` zeigt
jetzt serverseitig auf `/var/lib/<slug>` statt ins Installationsverzeichnis — unser
eigenes explizites Setzen bleibt trotzdem die robustere Wahl und ist unabhängig vom
SDK-Rückfall.
⚠️ **Breaking Change 2.5.1:** `SetStorageDirectory` hängt jetzt selbst `<slug>/license`
an (vorher wurde der Pfad roh benutzt). Der Aufruf wird zu
`LicenseConfig.SetStorageDirectory(AppPaths.Config)`**ohne** das eigene
`"license"`-Suffix, sonst entstünde `…/license/ibkrtrader/license`.
### 5.1 Kopflose Schalter
Konvention aus dem Lizenz-Leitfaden, im Daemon umzusetzen (das SDK bringt keine CLI mit):
```
IBKRTrader.Daemon --license-status
IBKRTrader.Daemon --license-set-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
```
`--license-deactivate` **nicht** verdrahten: es verlangt den `shared_key` des
Servers, der gehört nicht in eine ausgelieferte Anwendung. Freigeben läuft über
das WebUI (Hardware-Liste → „Freigeben").
> **Geändert mit 2.5.0:** der `update-agent` nimmt den Lizenzschlüssel nicht mehr
> als `--license-key`-Argument entgegen — `ps` zeigt Prozessargumente jedem Nutzer
> der Maschine, dieselbe Begründung wie beim Crontab-Verzicht in UPGRADE §5. Der
> Weg ist jetzt die Umgebungsvariable `DC_LICENSE_KEY` (`LaunchUpdateAgent` setzt
> sie selbst, wenn `licenseKey` übergeben wird — kein eigener Code nötig). Unsere
> `--license-set-key`-Implementierung ruft also `LicenseClient.ValidateAsync`
> direkt auf und schreibt **nicht** `--license-key` in einen eigenen Aufruf des
> Agenten.
>
> Ebenfalls neu: `EnsureLicensedAsync()` auf `LicenseClient` — nimmt den
> zwischengespeicherten Schlüssel, fragt bei Bedarf über `ILicensePrompt` nach,
> prüft, fragt bei Ablehnung erneut. `allowPrompt:false` lehnt ohne Cache ab, statt
> im Daemon auf eine Eingabe zu warten, die nie kommt — **das ist für den Daemon
> die richtige Einstiegsmethode**, nicht das manuelle Zusammensetzen aus
> `TryGetCachedKey` + `ValidateAsync`.
### 5.2 Reaktion — hier weicht IBKRTrader bewusst vom Leitfaden ab
Die DC-Doku empfiehlt bei einem endgültigen Urteil `Environment.Exit(1)`.
**Für einen Dienst mit offenen Positionen ist das die falsche Reaktion.** Ein
abgelaufener Vertrag ist kein Grund, ein Portfolio unbeaufsichtigt zu lassen —
Stop-Loss- und Exit-Logik müssen weiterlaufen, sonst richtet die Lizenzdurchsetzung
mehr Schaden an als der unlizenzierte Betrieb.
Vorschlag — drei Stufen statt zwei:
| Ergebnis | Zustände | Reaktion |
|---|---|---|
| `IsValid` | `valid`, `valid_offline` | Normalbetrieb |
| `IsTransient` | `server_unavailable`, `cache_expired` | **kein Urteil, nur keine Auskunft.** Warnen, weiterlaufen, beim nächsten Durchlauf erneut fragen |
| Urteil | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | **Sperrbetrieb:** `TradingEnabled` hart auf `false`, keine neuen Einstiege, Risiko-/Exit-Worker und Buchhaltung laufen weiter, Meldung an Watchdog (`status: "warning"`) und Fehler-Stream. Beendet wird erst, wenn keine Position mehr offen ist — oder auf ausdrückliche Anweisung |
Bei der Desktop-Shell darf es beim Start weiterhin ein Lizenzfenster geben; der
**Daemon** darf niemals auf eine Eingabe warten.
---
## 6. Error-Reporting
Zwei Wege in `POST /api/errors/v1/report`, beide in `IBKRTrader.Hosting`:
1. **Senke am `LoggingService`.** `LoggingService.EntryWritten` existiert bereits
und ist genau dafür gedacht (die Oberfläche hängt schon dran). Ein
`DcErrorSink` filtert auf `AppLogLevel.Error` und meldet mit
`level: "error"`, `build: BuildInfo.Version`, `file`/`line` aus dem Stacktrace,
`context` mit `GitCommitShort` und `CorrelationId`/`SignalId`, wenn vorhanden.
Die `SignalId` mitzuschicken ist der eigentliche Gewinn: eine Fehlermeldung
lässt sich damit im Deploymentcenter direkt gegen das Entscheidungsjournal halten.
2. **Globale Handler**`AppDomain.UnhandledException` (`level: "fatal"`,
**blockierend** gesendet, der Prozess ist gleich weg) und
`TaskScheduler.UnobservedTaskException`. Beide fehlen im Projekt heute
vollständig; sie müssen sowieso her.
### 6.1 Bremse
Verpflichtend, sonst verbrennt ein Fehler in einer Handelsschleife das
serverseitige Rate-Limit binnen Sekunden:
- höchstens 20 Meldungen pro Minute,
- derselbe Fehler höchstens alle 5 Minuten,
- bei `401`/`403` dauerhaft abschalten,
- bei `429` Intervall verdoppeln.
Betriebsbedingtes Rauschen gehört nicht in diese Bremse, sondern in die
**Ignore-Regeln** des WebUI: dort wird weitergezählt, aber nicht gemeldet — und
eine Alarmschwelle schlägt an, wenn ein bekannter Fehler plötzlich hundertmal so
oft auftritt.
> **Nicht melden:** alles, was Kontodaten, Positionen oder Schlüsselmaterial im
> Text führen könnte. Der Fehler-Stream ist eine externe Ablage. Vor dem Senden
> wird der Text durch dieselbe Maskierung geschickt, die schon für die Logs gilt.
---
## 7. UpdateService, Erstinstallation und Release-Pipeline
### 7.1 Prüfen, nicht installieren
`DcUpdateService.CheckAsync` beim Start (`GET /api/updateservice/v1/check`).
**Mit Zugangsdaten**, sonst gibt es seit 2.4 nur noch `401`:
```csharp
var key = settings.Deploymentcenter.LicenseKey is { Length: > 0 } k
? k
: LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
var check = await new UpdateClient().CheckForUpdateAsync(
baseUrl: DcConfig.BaseUrl,
projectId: DcConfig.ProductSlug,
currentVersion: BuildInfo.Version,
channel: settings.Deploymentcenter.UpdateChannel,
credentials: ReleaseCredentials.FromLicenseKey(key),
cancellationToken: ct);
if (check.Unauthorized) { /* Lizenzproblem, KEIN Netzwerkfehler — eigene Meldung */ }
```
Gefunden wird nur, installiert wird nichts von selbst. Ein Trading-Dienst, der
sich unangekündigt neu startet, ist keine akzeptable Vorgabe.
- Kein Update → Info ins Log.
- Update verfügbar → Warnung + Hinweis in der Shell.
- Kritisch → zusätzlich Dialog (Shell) bzw. Watchdog-Event (Daemon).
### 7.2 Anwenden
Über `UpdateClient.LaunchUpdateAgent(..., licenseKey: key)`; der Agent bekommt
`--restart`, `--wait-for-pid`, `--platform` von selbst, den Lizenzschlüssel seit
2.5.0 **nicht mehr** als `--license-key`-Argument, sondern über die
Umgebungsvariable `DC_LICENSE_KEY` (siehe §5.1). Vorher geht ein
`status: "stopped"` an den Watchdog.
**Vorbedingung, die die Anwendung selbst prüfen muss:** kein offener Auftrag,
keine laufende Order. Ein Update, das den Prozess zwischen Order-Absendung und
Statusrückmeldung abräumt, hinterlässt eine Order bei IBKR ohne Gegenstück im
Journal. Das Update wird also erst nach dem Handelsschluss bzw. bei leerem
Auftragsbuch angeboten.
> **Verschärft mit 2.5.0:** `exitCurrentApp: true` ist jetzt ausdrücklich als
> **harter Kill** dokumentiert — laufende `finally`-Blöcke, `IHostApplicationLifetime`
> und Destruktoren kommen nicht mehr zum Zug. Für IBKRTrader heißt das: Solange
> die Vorbedingung oben nicht sicher geprüft ist (offenes Auftragsbuch, laufende
> Buchungstransaktion), muss `exitCurrentApp: false` gesetzt werden. Der Agent
> wartet über `--wait-for-pid` ohnehin auf das Prozessende; das eigene
> Herunterfahren läuft dann über `host.StopAsync(...)` wie in `App/Program.cs`
> bereits vorhanden — **nicht** über `LaunchUpdateAgent` selbst. Zusätzlich
> `waitTimeoutSeconds` heraufsetzen, falls das geordnete Beenden (EF-Flush,
> Kestrel, Log-Flush, DB-Backup-Worker) länger als die Vorgabe von 60 s dauern
> kann — läuft die Zeit ab, bricht der Agent ohne Änderung ab.
Für den Linux-Dienst siehe [§8 D3](#8-befunde-und-offene-punkte) — dafür gibt es
im Deploymentcenter derzeit keinen fertigen Weg.
### 7.3 Was ausgeliefert wird — Ausschließen vs. Schützen
Das ist die Stelle, an der Updates in anderen Projekten schon Konfigurationen
zerstört haben. Für IBKRTrader gilt:
| Datei | Liste | Begründung |
|---|---|---|
| `settings.example.json` | *(im Paket)* | Vorlage mit Platzhaltern — Erstinstallation braucht sie |
| `settings.json` | `preservePatterns` | enthält DB-Zugang, DC-Token, Flex-Token. Muss ein Update überleben |
| `appsettings.Local.json` | `preservePatterns` | Verbindungszeichenfolge des Zielsystems |
| `master.key`, `openrouter.key` | `excludePatterns` | **darf nie ins Paket** |
| `Logs/**`, `Backups/**` | `excludePatterns` | Laufzeitdaten |
| `*.pdb`, `*.xml` | `excludePatterns` | Build-Artefakte |
| `update-agent*` | bewusst entscheiden | mitliefern = aktualisiert sich selbst mit |
`pack-and-deploy` warnt von sich aus bei Dateien, die nach Zugangsdaten aussehen.
Die Warnung ist ernst zu nehmen: Release-Pakete liegen zwar hinter einem
Zugangsschutz, aber jeder lizenzierte Kunde kann sie auspacken.
### 7.4 `setup.json`
Beschreibt der Erstinstallation, was abzufragen ist; wird ins Paket kopiert.
Entwurf für IBKRTrader:
```json
{
"schema": 1,
"displayName": "IBKRTrader",
"targets": [
{ "id": "app", "file": "settings.json", "location": "install", "format": "json" }
],
"fields": [
{ "key": "Database:Host", "label": "MySQL-Host", "type": "string" },
{ "key": "Database:Database", "label": "Datenbank", "type": "string" },
{ "key": "Database:User", "label": "Benutzer", "type": "string" },
{ "key": "Database:Password", "label": "Passwort", "type": "secret" },
{ "key": "IBKR:Host", "label": "TWS/Gateway", "default": "127.0.0.1" },
{ "key": "IBKR:Port", "label": "Port (Paper 4002 / Live 4001)", "type": "int", "default": "4002" },
{ "key": "Trading:ApplicationTimeZoneId", "label": "Betriebszeitzone", "default": "Europe/Berlin" },
{ "key": "Deploymentcenter:BaseUrl", "source": "detect:baseurl", "type": "url" },
{ "key": "Deploymentcenter:ApiToken", "label": "Token dieser Installation",
"source": "provision", "scopes": ["watchdog:ping", "bugtracker:report"] },
{ "key": "Deploymentcenter:MonitorSource", "source": "detect:hostname" }
]
}
```
Zwei Punkte dazu:
- **`location: "install"` ist nur unter Windows richtig** — und auch dort nur,
solange nicht nach `C:\Program Files` installiert wird. Siehe
[§8 P2](#8-befunde-und-offene-punkte); solange `AppPaths` nicht korrigiert ist,
passen `setup.json` und die tatsächliche Leseposition der Anwendung unter Linux
nicht zusammen. **Dieser Punkt blockiert die Erstinstallation und gehört vor §7.4.**
- Der Installer schreibt **Klartext**. `SecretProtection` muss also Klartext
unverändert durchreichen und beim ersten Speichern verschlüsseln — die Weiche
ist zu prüfen und im Test ausdrücklich als *aktiv genutzt* zu beschreiben, damit
sie nicht eines Tages als tot entfernt wird.
### 7.5 Release-Pipeline
`scripts/release.ps1` und `scripts/release.sh` sind per `curl` von
`/docs/release-template/` geholt (wie `release.md` empfiehlt, statt aus dem
lokalen Schwester-Repo kopiert — damit ist sichergestellt, dass die kanonische
Fassung im Projekt liegt) und **unverändert** gelassen. Nur
`scripts/release.config.json` ist ausgefüllt:
```json
{
"project": "ibkrtrader",
"csproj": "src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj",
"runtimes": ["win-x64", "linux-x64"],
"selfContained": true,
"setupJson": "setup.json"
}
```
**Entschieden: nur der Daemon, keine Zwei-Produkte-Aufteilung.** IBKRTrader hat
zwei Einstiegspunkte (`App` = Avalonia-Shell, `Daemon` = kopflos). Die Vorlage
nimmt aber genau **eine** `csproj` für alle Runtimes in der Liste — sie ruft
`dotnet publish <csproj> -r <rid>` in einer Schleife auf, es gibt keine
Möglichkeit, App für `win-x64` und Daemon für `linux-x64` in **demselben** Lauf
zu veröffentlichen, ohne das (laut Anleitung unveränderte) Skript anzufassen.
Ursprünglich in diesem Dokument vorgeschlagen war genau das ("Windows = App,
Linux = Daemon") — das ist mit der Vorlage so nicht umsetzbar und wurde beim
Umsetzen korrigiert.
`IBKRTrader.Daemon` ist der bewusste Kandidat: laut README „plattformneutral"
(`net10.0` ohne Plattformbindung), läuft kopflos auf beiden Zielsystemen und
ist ohnehin die für den Serverbetrieb vorgesehene Form (`deploy/README.md`).
Eine automatisierte Auslieferung der Avalonia-Shell (`App`) als
Windows-Desktop-Distribution ist damit **nicht** Teil dieses Durchlaufs — dafür
bräuchte es entweder einen zweiten Produkt-Slug (`ibkrtrader-app`, doppelte
Lizenz-/Update-Pflege) oder ein angepasstes Release-Skript. Zurückgestellt, bis
tatsächlich Bedarf an einer automatisierten Desktop-Auslieferung besteht — bis
dahin bleibt `dotnet run --project src/IBKRTrader.App` bzw. eine manuelle
`dotnet publish` der übliche Weg für die Shell.
Zugangsdaten kommen aus `DC_FTP_HOST` / `DC_FTP_USER` / `DC_FTP_PASS` / `DC_TOKEN`,
`.dc-tools/` und `artifacts/` sind in der `.gitignore`.
**`packager.config.json` (`excludePatterns`/`preservePatterns`) ist nicht Teil
dieses Durchlaufs.** Sie liegt "neben dem Werkzeug", also unter `.dc-tools/`
das entsteht erst, wenn `pack-and-deploy` beim ersten echten Lauf von
`release.ps1`/`release.sh` selbst heruntergeladen wird (Prüfsummen-geprüft).
Ein erster Release-Lauf braucht `DC_FTP_*`/`DC_TOKEN` und ist damit eine
credentialierte, vom Betreiber auszulösende Aktion. **Sicherheitsnetz bis
dahin:** Der Packager bricht seit 2.5.0 ohnehin hart ab, wenn er im
Publish-Verzeichnis nach Zugangsdaten aussehende Dateien findet (Name *und*
Inhalt) — die in §7.3 beschriebenen Dateien (`settings.json`,
`appsettings.Local.json`) sind ohnehin gitignored und landen nur dann im
Publish-Output, wenn sie lokal im Projektverzeichnis liegen (`<None Update
Condition="Exists(...)">` in den `.csproj`-Dateien kopiert sie dann mit). Bricht
`release.ps1` mit einem Hinweis auf eine vermutete Zugangsdatei ab, ist das der
Packager, der genau das abfängt — kein Fehlschlag, sondern die Absicherung, die
2.5.0 dafür eingeführt hat.
---
## 8. Befunde und offene Punkte
> **Status 2026-08-23:** P1P4 sind umgesetzt (Einzelheiten in §9). **P5 ist eingetreten**,
> P6 entscheidet sich erst beim ersten Release. Der ursprüngliche Vermerk von 2026-08-14:
>
> **Status 2026-08-14:** Alle sieben ursprünglich an den Deploymentcenter-Entwickler
> gemeldeten Befunde (D1D7) sind mit den Versionen 2.5.0/2.5.1 behoben — bestätigt über
> `GET /api/updateservice/v1/changelog?since=2.4`. Der Abschnitt bleibt als Nachweis
> stehen, jeder Punkt trägt jetzt einen Fix-Vermerk. Die Projekt-Befunde (P1P6) sind
> davon unabhängig und weiterhin offen — sie werden in diesem Durchlauf abgearbeitet.
### Am Projekt (P) — muss vor der Integration bereinigt werden
**P1 · Echte Zugangsdaten als Vorgabewerte im Quelltext.**
`src/IBKRTrader.Core/Settings/AppSettings.cs:14-35` trägt Host, Datenbankname,
Benutzer und **Passwort** einer produktiven MariaDB als Vorgabe. Die Datei ist
versioniert, die Werte stehen damit auch in der Git-Historie — `settings.json`
im `.gitignore` hilft dagegen nicht. Das Passwort ist als kompromittiert zu
behandeln und zu wechseln; die Vorgaben gehören auf Platzhalter. Zusätzlich
brisant, weil die Vorlage künftig im Release-Paket ausgeliefert wird.
> **Verschärft mit 2.5.0:** der Packager **bricht jetzt hart ab**, wenn er
> Zugangsdaten im Publish-Verzeichnis erkennt (Dateiname *und* Inhalt, u. a.
> gefülltes `Password=`) — vorher nur eine Warnung, die auf der Preserve-Liste
> sogar unterdrückt wurde. Damit ist P1 nicht mehr „sollte bereinigt werden",
> sondern **release-blockierend**: Ohne Fix lässt sich `settings.example.json`
> gar nicht mehr veröffentlichen.
**P2 · `AppPaths` fällt unter Windows auf einen Linux-Pfad zurück.**
`Configuration/AppPaths.cs:57-71`: ist das Binärverzeichnis nicht beschreibbar,
gilt der FHS-Rückfall `/etc/ibkrtrader`. Unter Windows löst .NET das zu
`C:\etc\ibkrtrader` auf. Genau dieser Fall tritt ein, sobald der DC-Installer
seinen Windows-Vorschlag `Programme\<slug>` benutzt — dann liegen `settings.json`,
`master.key` und `Logs/` in `C:\etc\`. Nötig: plattformabhängiger Rückfall
(`%ProgramData%\IBKRTrader` bzw. `%APPDATA%`).
**P3 · Keine globalen Ausnahmebehandler.** Ohne
`AppDomain.UnhandledException` und `TaskScheduler.UnobservedTaskException` gibt es
keine `fatal`-Meldungen — und heute auch keinen Logeintrag, wenn der Prozess
wegbricht. Unabhängig vom Deploymentcenter überfällig.
**P4 · Version an zwei Stellen hartkodiert.** `App/Program.cs:34` und
`Daemon/Program.cs:43`. Ohne `Directory.Build.props` bricht `pack-and-deploy` beim
ersten Release ab — zu Recht.
**P5 · CI kennt das Schwester-Repo nicht.** `.gitea/workflows/build.yml` checkt
nur IBKRTrader aus. Eine `ProjectReference` nach `..\..\..\..\Deploymentcenter\…`
macht den Build auf beiden Matrix-Zielen rot. Siehe die Entscheidung in §2.2.
> **Seit 2026-08-23 eingetreten, nicht mehr nur vorhergesagt.** Die
> `ProjectReference` ist mit der Integration auf `main` gelandet, die CI ist
> damit rot. Das war die bewusst in Kauf genommene Folge der Interimslösung aus
> §2.2 — lokal baut die Projektmappe, sobald das Schwester-Repo daneben liegt.
> Auflösung ist Schritt 10: SDK als NuGet-Paket in die Gitea-Registry, dann
> `PackageReference` statt Cross-Repo-Pfad.
**P6 · Konfiguration liegt im Installationsverzeichnis.** Unter Windows schreibt
`AppPaths` neben die Binärdatei. Das ist mit `preservePatterns` beherrschbar
(§7.3), muss aber beim ersten Release stimmen — ein Update, das `settings.json`
überschreibt, nimmt einer laufenden Installation Datenbank, Token und Flex-Zugang
gleichzeitig.
### Am Deploymentcenter (D) — gemeldet, Stand vor 2.5.0
**D1 · Der Lizenzschlüssel als Update-Zugang war nirgends vollständig erklärt.**
**Behoben (2.5.1, Doku).** Laut Changelog explizit als „Lückenschluss" genannt:
„Woher die Anwendung den Lizenzschlüssel für den Update-Zugang nimmt
(`TryGetCachedKey`)". Für uns bleibt zusätzlich `EnsureLicensedAsync()` (neu in
2.5.0) relevant — siehe §5.1.
**D2 · Predictalytics wurde vom Zugangsschutz ausgesperrt.**
**Bestätigt und behoben (2.5.1).** Der Changelog bestätigt den Verdacht wörtlich:
„bei Predictalytics ist genau das passiert". Fix: Zugangsschutz ist jetzt je
Produkt abschaltbar (WebUI → UpdateService → Zugangsschutz) — für Installationsbasen
ohne SDK 2.4 lässt sich das Produkt ausnehmen, mit Schlüssel neu ausliefern, danach
wieder einschalten. **Für IBKRTrader heißt das:** genau diese Falle von Anfang an
vermeiden, indem der erste ausgelieferte Build bereits `licenseKey` mitgibt (§7.5,
§9 Reihenfolge).
**D3 · Für einen systemd-Dienst gab es keinen dokumentierten Update-Weg.**
**Behoben (2.5.1, Doku).** Laut Changelog ergänzt: „wie ein laufender
systemd-Dienst aktualisiert wird". Bleibt in §7.2/§8-P laufend zu beobachten, ob
die neue Beschreibung mit unserem tatsächlichen Vorgehen (`--target-dir`,
Dienstbenutzer-Rechte) übereinstimmt — bei der Umsetzung von Aufgabe „Update-Prüfung
mit ReleaseCredentials" gegenprüfen.
**D4 · `LicenseConfig.GetStorageDirectory`/`SetStorageDirectory` war für Dienste
unglücklich.** ✅ **Behoben, mit Breaking Change (2.5.1).** Zwei Korrekturen:
(a) Rückfall ohne `$HOME` zeigt jetzt auf `/var/lib/<slug>` statt ins
Installationsverzeichnis. (b) `SetStorageDirectory` hängt jetzt selbst
`<slug>/license` an, statt den Pfad roh zu verwenden — **das ändert unseren
Aufruf**, siehe §5 (`AppPaths.Config` statt `AppPaths.ConfigFile("license")`).
**D5 · `BuildInfo.targets` verhinderte inkrementelle Builds.**
**Behoben (2.5.1).** `BuildDateUtc` ist jetzt tagesgenau statt mit voller Uhrzeit,
`WriteOnlyWhenDifferent` greift wieder. Siehe §2.3.
**D6 · Die Empfehlung „bei einem Urteil beenden" passte für handelnde Systeme nicht.**
**Behoben (2.5.1, Doku)** — als Lückenschluss genannt: „was statt
`Environment.Exit(1)` zu tun ist, wenn Beenden die gefährlichere Option wäre".
Unser Sperrbetrieb-Ansatz (§5.2) bleibt die konkrete Umsetzung für IBKRTrader.
**Zusätzlich, über D6 hinausgehend:** 2.5.0 dokumentiert `exitCurrentApp: true`
jetzt als harten Kill ohne `finally`/Lifecycle-Hooks — praktisch relevanter für uns
als die Lizenz-Frage allein, siehe §7.2.
**D7 · Kleinigkeiten** (README-Verweis auf `AGENT_RELEASE_GUIDE.md`, Rate-Limits
nicht durchgängig beziffert). ✅ **Behoben (2.5.1, Doku)** — Changelog nennt
„die Drosselungsgrenzen aller Endpunkte" als ergänzt; `docs/README.md` führt jetzt
`/docs/release.md` und den Changelog-Endpunkt im Index.
---
## 9. Reihenfolge
| Schritt | Inhalt | Abhängig von | Stand |
|---|---|---|---|
| 0 | **P1** Zugangsdaten wechseln, Vorgaben auf Platzhalter | — | ✅ Vorgaben sind Platzhalter. **Die Rotation des geleakten Passworts steht weiterhin aus** (Nutzer-Aktion) |
| 1 | **P2** `AppPaths`-Rückfall plattformabhängig, **P4** `Directory.Build.props` | — | ✅ 2026-08-23 |
| 2 | SDK-Bezug Stufe 1: Cross-Repo-`ProjectReference` (§2.2) | — | ✅ 2026-08-23, inkl. Prüf-Target mit lesbarer Fehlermeldung |
| 3 | `DcConfig`, `DeploymentcenterSettings`, `DcApiClient`, `BuildInfo` | 1, 2 | ✅ 2026-08-23 |
| 4 | **P3** globale Handler + Fehler-Stream | 3 | ✅ 2026-08-23 (`DcCrashHandlers`, `DcErrorSink`, `DcErrorReporter`) |
| 5 | Watchdog-Heartbeat inkl. `stopped` | 3 | ✅ 2026-08-23 (`DcHeartbeatWorker`) |
| 6 | Lizenz mit Sperrbetrieb statt Abbruch (§5.2), `EnsureLicensedAsync` | 3 | ✅ 2026-08-23 (`LicenseGuard`) |
| 7 | Update-Prüfung **mit** `ReleaseCredentials`, `exitCurrentApp:false` bei offenem Zustand | 6 (braucht den Schlüssel) | 🔶 Die **Prüfung** läuft beim Start. Das **Anwenden** ist es nicht: `LaunchAgent` ist fertig, aber kein Aufrufer fährt danach geordnet herunter |
| 8 | `setup.json`, `preserve`/`exclude`, Release-Pipeline (Vorlage per `curl` von `/docs/release-template/`) | 17 | ✅ 2026-08-23 (`setup.json`, `scripts/release.*`). **Noch nie ausgeführt** das erste Release steht aus |
| 9 | Bugtracker-Baustein nach `AGENTS.md` / `.agents/rules` | Projekt-Slug angelegt | ⬜ offen — Projekt `ibkrtrader` muss im WebUI erst angelegt werden |
| 10 | **Folgeschritt, nicht Teil dieses Durchlaufs:** `Deploymentcenter.Client` als NuGet-Paket in die Gitea-Registry pushen, `<Import>` durch `PackageReference` ersetzen, Gitea-CI (**P5**) auf den Paketbezug umstellen | 2 | ⬜ offen — siehe §2.2 |
Schritt 7 und 8 hängen zusammen: **der erste ausgelieferte Build muss die
Zugangsdaten schon mitbringen.** Für ein Produkt, das noch nie veröffentlicht hat,
entsteht `/releases/ibkrtrader/` mit dem ersten Upload und wird vom nächsten
`tick.php`-Lauf geschützt — es gibt kein Zeitfenster, das SDK danach nachzurüsten.
Wird Schritt 7 übersprungen, fällt die Tür hinter dem ersten Release zu (genau der
Fall, der laut Changelog bei Predictalytics bereits eingetreten ist, siehe D2).
Schritt 9 (Bugtracker) setzt voraus, dass das Projekt `ibkrtrader` im
Deploymentcenter-WebUI angelegt ist und ein Token mit `bugtracker:report`
vorliegt — beides serverseitige Handarbeit, die dieser Durchlauf nicht auslösen
kann. Wird zurückgestellt.
@@ -1,3 +1,13 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es die Analyse vor dem Umbau ist: Fundstellenverzeichnis, Aufwandsschaetzung und
> was anders kam als geschaetzt. Die Portierung selbst ist abgeschlossen (L0L6). Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# Analyse: Linux-Fähigkeit des IBKRTrader # Analyse: Linux-Fähigkeit des IBKRTrader
> **UMGESETZT am 2026-08-07 (L0L5).** Dieses Dokument ist die Analyse, die der Portierung > **UMGESETZT am 2026-08-07 (L0L5).** Dieses Dokument ist die Analyse, die der Portierung
@@ -1,5 +1,25 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) (Bahn „Accounting") dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es die Leitprinzipien traegt unabhaengige Quelle, Idempotenz, append-only
> und die Datenbeschaffung ueber die Flex Query beschreibt. Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# Konzept: Modul „Accounting" (Buchhaltung/Reporting aller Konten) # Konzept: Modul „Accounting" (Buchhaltung/Reporting aller Konten)
> **UMGESETZT (Modulgerüst).** Das Modul steht: `acc_`-Schema mit Migration `InitialAccounting`,
> `AccountingIngestService` (append-only, idempotent über `IdempotencyKey`), `AccountingClassifier`,
> `AccountingEngine`, `FxConverter`, `AccountingReportService` sowie CSV- und PDF-Export. Die
> Ingest-Quellen liegen hinter Interfaces mit **Offline-Null-Stubs** — das Modul läuft vollständig
> und bucht dabei korrekt nichts.
>
> **Weiterhin offen ist genau die Zielland-Arbeit aus §6** — vor allem der Live-Flex-Abruf, ohne den
> keine echten Buchungen entstehen, und die Steuerschicht, deren Jurisdiktion nicht festgelegt ist.
> Das Modul ist damit lauffähig, aber noch nicht in Betrieb.
> Stand: 2026-07-30 > Stand: 2026-07-30
> Ziel: Vollständige, **von unserer Trading-DB unabhängige**, buchhalterisch korrekte Erfassung ALLER > Ziel: Vollständige, **von unserer Trading-DB unabhängige**, buchhalterisch korrekte Erfassung ALLER
> Kontobewegungen der IBKR-Konten. Periodische (meist monatliche), vor einer Steuerbehörde > Kontobewegungen der IBKR-Konten. Periodische (meist monatliche), vor einer Steuerbehörde
@@ -1,3 +1,13 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) (Stufe 5, dazu die Core-Voraussetzungen in Stufe 3) dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es den Zustandsautomaten, das Regelwerk mit allen Vorgabewerten und die Begruendung enthaelt,
> warum das Modul nicht additiv auf den heutigen aktienbasierten Core passt. Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# Konzept: Modul „OptionsWheel" (Covered Call / Cash-Secured Put) # Konzept: Modul „OptionsWheel" (Covered Call / Cash-Secured Put)
> Stand: 2026-08-03 > Stand: 2026-08-03
@@ -1,5 +1,23 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) (Bahn „Supervisor") dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es S-0 bis S-4 im Einzelnen beschreibt, samt Tool-Registry und den
> Sicherheitsgrenzen des Agenten. Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# Konzept: Modul „Supervisor" (KI-gestützte Handels-Analyse & Forensik) # Konzept: Modul „Supervisor" (KI-gestützte Handels-Analyse & Forensik)
> **UMGESETZT (S-0 bis S-4).** Datenfundament im Core (`core_decision_journal`, `core_order_events`,
> durchgereichte `SignalId`, JSONL-Log-Sink), `DossierService`/`DossierBuilder`, der
> OpenRouter-Agent mit read-only Tool-Registry und Profilen, `sup_reports`, `CounterfactualJob`,
> `DailyReportService` und der MCP-Server (`McpLightServer`, `McpJsonRpc`).
>
> **Weiterhin offen** sind die beiden Punkte am Ende dieses Dokuments: die Counterfactual-Kursauflösung
> (Interface + Stub vorhanden) und der externe Versand des Tagesberichts.
> Stand: 2026-07-30 > Stand: 2026-07-30
> Ziel: ALLES, was IBKRTrader getan (und bewusst NICHT getan) hat, detailliert analysierbar machen — > Ziel: ALLES, was IBKRTrader getan (und bewusst NICHT getan) hat, detailliert analysierbar machen —
> Entscheidungen, Orders, Trades und Logs — und die Analyse durch ein KI-Modell (OpenRouter) durchführen > Entscheidungen, Orders, Trades und Logs — und die Analyse durch ein KI-Modell (OpenRouter) durchführen
@@ -1,3 +1,13 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) (Stufe 4) dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es die vollstaendige Spezifikation ist, gegen die die Umsetzung geprueft wird
> die drei Wahrheiten, Eigentumsregeln, Reservierungs-Lebenszyklus, Invarianten und Testkatalog. Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# Kapital- und Buchmodell # Kapital- und Buchmodell
> **Status: Konzept (2026-08-03) noch nicht implementiert.** > **Status: Konzept (2026-08-03) noch nicht implementiert.**
+22
View File
@@ -0,0 +1,22 @@
{
"_comment": "Kopie als scripts/release.config.json angelegt und ausgefuellt. Diese Datei enthaelt KEINE Zugangsdaten - die kommen aus Umgebungsvariablen (DC_FTP_HOST, DC_FTP_USER, DC_FTP_PASS, DC_TOKEN) oder aus einer packager.config.json neben dem Werkzeug. release.config.json darf deshalb versioniert werden.",
"_project_comment": "IBKRTrader.Daemon ist der Release-Kandidat: plattformneutral (net10.0 ohne Plattformbindung, siehe README), laeuft kopflos auf beiden Zielsystemen. IBKRTrader.App (Avalonia-Shell) ist bewusst NICHT Teil dieser automatisierten Pipeline - das Vorlagenskript nimmt genau eine csproj fuer alle Runtimes, und das Skript bleibt laut Anleitung unveraendert. Eine Desktop-Distribution der Shell ist ein separater, spaeterer Schritt.",
"project": "ibkrtrader",
"csproj": "src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj",
"_runtimes_comment": "Je Eintrag entsteht ein eigenes Release - ohne Plattformangabe wuerden sie sich gegenseitig ueberschreiben.",
"runtimes": ["win-x64", "linux-x64"],
"_selfContained_comment": "true nimmt die .NET-Laufzeit ins Paket, damit ein frisch aufgesetztes System kein vorinstalliertes .NET braucht (siehe deploy/README.md zur ICU-Warnung).",
"selfContained": true,
"publishSingleFile": false,
"setupJson": "setup.json",
"mainAssembly": "",
"baseUrl": "https://dc.mhdf.de"
}
+233
View File
@@ -0,0 +1,233 @@
<#
.SYNOPSIS
Veroeffentlicht dieses Projekt im Deploymentcenter.
.DESCRIPTION
Vorlage zum Kopieren nach scripts/release.ps1 des eigenen Projekts.
Anzupassen ist nur der Kopf von release.config.json - dieses Skript
selbst bleibt unveraendert.
Der Ablauf je Zielplattform:
dotnet publish -r <rid> -> pack-and-deploy --platform <rid>
pack-and-deploy uebernimmt dabei Pruefsummen, Dateimanifest, das
Fortschreiben der latest.json und die Anmeldung beim Deploymentcenter.
Das hier nachzubauen waere ein zweiter Ort fuer dieselben Fehler; das
Skript orchestriert nur.
Fehlt das Werkzeug, wird es geholt und die Pruefsumme geprueft.
.EXAMPLE
.\scripts\release.ps1 -Version 1.4.3 -Changelog "Behebt den Login-Fehler."
.EXAMPLE
.\scripts\release.ps1 -Version 1.5.0 -Channel beta -WhatIf
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
# Ohne Angabe wird die Version aus Directory.Build.props bzw. der csproj gelesen.
[string] $Version,
[ValidateSet('prod', 'beta', 'dev')]
[string] $Channel = 'prod',
[string] $Changelog,
# Als kritisches Update kennzeichnen (Rollout priorisieren).
[switch] $Critical,
[string] $ConfigFile = (Join-Path $PSScriptRoot 'release.config.json')
)
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------- Konfiguration
if (-not (Test-Path $ConfigFile)) {
throw "Konfiguration fehlt: $ConfigFile`nVorlage kopieren: release.config.example.json -> release.config.json"
}
$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
foreach ($required in @('project', 'csproj', 'runtimes')) {
if (-not $config.$required) {
throw "In $ConfigFile fehlt der Eintrag '$required'."
}
}
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
$csprojRel = $config.csproj
$csproj = Join-Path $repoRoot $csprojRel
if (-not (Test-Path $csproj)) {
throw "Projektdatei nicht gefunden: $csproj"
}
$baseUrl = if ($config.baseUrl) { $config.baseUrl.TrimEnd('/') } else { 'https://dc.mhdf.de' }
$toolDir = Join-Path $repoRoot '.dc-tools'
# ---------------------------------------------------------------------- Version
function Get-ProjectVersion {
# Directory.Build.props zuerst: Steht <Version> nur in einem von mehreren
# Projekten, laufen die Angaben frueher oder spaeter auseinander - und
# pack-and-deploy bricht dann zu Recht mit einem Versionskonflikt ab.
foreach ($candidate in @(
(Join-Path $repoRoot 'Directory.Build.props'),
$csproj
)) {
if (-not (Test-Path $candidate)) { continue }
$match = [regex]::Match((Get-Content $candidate -Raw), '<Version>\s*([^<]+?)\s*</Version>')
if ($match.Success) {
return $match.Groups[1].Value.Trim()
}
}
return $null
}
if (-not $Version) {
$Version = Get-ProjectVersion
if (-not $Version) {
throw "Keine <Version> in Directory.Build.props oder $csprojRel gefunden. Bitte -Version angeben."
}
Write-Host "Version aus dem Projekt gelesen: $Version" -ForegroundColor DarkGray
}
if (-not $Changelog) {
$Changelog = "Release v$Version"
}
# ------------------------------------------------------------------- Werkzeug
function Get-PackAndDeploy {
$exe = Join-Path $toolDir 'pack-and-deploy.exe'
if (Test-Path $exe) { return $exe }
Write-Host "pack-and-deploy wird geholt ..." -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $toolDir | Out-Null
$name = 'pack-and-deploy-win-x64.exe'
$temp = Join-Path $toolDir 'download.tmp'
Invoke-WebRequest -Uri "$baseUrl/installer/$name" -OutFile $temp -UseBasicParsing
$expectedRaw = (Invoke-WebRequest -Uri "$baseUrl/installer/$name.sha256" -UseBasicParsing).Content
$expected = if ($expectedRaw -is [byte[]]) {
[System.Text.Encoding]::ASCII.GetString($expectedRaw)
} else { [string]$expectedRaw }
$expected = $expected.Trim().ToLower()
$actual = (Get-FileHash $temp -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $expected) {
Remove-Item $temp -Force
throw "Pruefsumme von $name stimmt nicht.`n erwartet: $expected`n erhalten: $actual"
}
Move-Item $temp $exe -Force
try { Unblock-File $exe -ErrorAction SilentlyContinue } catch { }
Write-Host " Pruefsumme in Ordnung." -ForegroundColor DarkGray
return $exe
}
$packAndDeploy = Get-PackAndDeploy
# --------------------------------------------------------------------- Ablauf
Write-Host ''
Write-Host "Projekt : $($config.project)" -ForegroundColor White
Write-Host "Version : $Version"
Write-Host "Kanal : $Channel"
Write-Host "Plattform : $($config.runtimes -join ', ')"
Write-Host ''
$results = @()
foreach ($rid in $config.runtimes) {
Write-Host "=== $rid ===" -ForegroundColor Cyan
$publishDir = Join-Path $repoRoot "artifacts/publish/$rid"
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'dotnet publish')) {
# Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden
# sonst mit ins Paket wandern.
if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force }
$publishArgs = @(
'publish', $csproj,
'-c', 'Release',
'-r', $rid,
'-o', $publishDir,
'--nologo'
)
if ($config.selfContained) { $publishArgs += '--self-contained', 'true' }
else { $publishArgs += '--self-contained', 'false' }
if ($config.publishSingleFile) { $publishArgs += '-p:PublishSingleFile=true' }
& dotnet @publishArgs
if ($LASTEXITCODE -ne 0) { throw "dotnet publish fuer $rid ist fehlgeschlagen." }
}
# setup.json mitliefern, damit der Installer weiss, was einzurichten ist.
# Bewusst kein Ternary-Operator: den gibt es erst ab PowerShell 7, und
# diese Vorlage soll auch mit dem mitgelieferten 5.1 laufen.
$setupRel = if ($config.setupJson) { $config.setupJson } else { 'setup.json' }
$setupJson = Join-Path $repoRoot $setupRel
if (Test-Path $setupJson) {
Copy-Item $setupJson (Join-Path $publishDir 'setup.json') -Force
Write-Host " setup.json mitgenommen" -ForegroundColor DarkGray
}
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'pack-and-deploy')) {
$packArgs = @(
'--project', $config.project,
'--version', $Version,
'--channel', $Channel,
'--platform', $rid,
'--publish-dir', $publishDir,
'--changelog', $Changelog
)
if ($Critical) { $packArgs += '--critical' }
if ($config.mainAssembly) { $packArgs += '--main-assembly', $config.mainAssembly }
& $packAndDeploy @packArgs
$code = $LASTEXITCODE
$results += [pscustomobject]@{ Runtime = $rid; ExitCode = $code }
# 1 = Konfigurationsfehler oder Versionskonflikt: dann stimmt etwas
# Grundsaetzliches, und die weiteren Plattformen wuerden genauso
# scheitern.
if ($code -eq 1) { throw "pack-and-deploy meldet einen Konfigurations- oder Versionsfehler." }
}
Write-Host ''
}
# ------------------------------------------------------------------ Ergebnis
Write-Host '=== Ergebnis ===' -ForegroundColor White
foreach ($r in $results) {
$text = switch ($r.ExitCode) {
0 { 'vollstaendig veroeffentlicht' }
2 { 'TEILWEISE - Upload oder Registrierung fehlgeschlagen' }
default { "unerwarteter Rueckgabewert $($r.ExitCode)" }
}
$color = if ($r.ExitCode -eq 0) { 'Green' } else { 'Yellow' }
Write-Host (" {0,-16} {1}" -f $r.Runtime, $text) -ForegroundColor $color
}
$failed = @($results | Where-Object { $_.ExitCode -ne 0 })
if ($failed.Count -gt 0) {
Write-Host ''
Write-Host 'Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen,' -ForegroundColor Yellow
Write-Host 'ob die bereits hochgeladenen Dateien konsistent sind.' -ForegroundColor Yellow
exit 2
}
Write-Host ''
Write-Host "Fertig. $($config.project) $Version ist im Kanal $Channel verfuegbar." -ForegroundColor Green
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env bash
#
# Veroeffentlicht dieses Projekt im Deploymentcenter.
#
# Vorlage zum Kopieren nach scripts/release.sh des eigenen Projekts.
# Anzupassen ist nur scripts/release.config.json - dieses Skript selbst
# bleibt unveraendert.
#
# Der Ablauf je Zielplattform:
# dotnet publish -r <rid> -> pack-and-deploy --platform <rid>
#
# pack-and-deploy uebernimmt Pruefsummen, Dateimanifest, das Fortschreiben der
# latest.json und die Anmeldung beim Deploymentcenter. Das hier nachzubauen
# waere ein zweiter Ort fuer dieselben Fehler; das Skript orchestriert nur.
#
# ./scripts/release.sh --version 1.4.3 --changelog "Behebt den Login-Fehler."
# ./scripts/release.sh --version 1.5.0 --channel beta --dry-run
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="${DC_RELEASE_CONFIG:-$SCRIPT_DIR/release.config.json}"
VERSION=""
CHANNEL="prod"
CHANGELOG=""
CRITICAL=0
DRY_RUN=0
usage() {
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
exit 0
}
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift 2 ;;
--channel) CHANNEL="$2"; shift 2 ;;
--changelog) CHANGELOG="$2"; shift 2 ;;
--critical) CRITICAL=1; shift ;;
--dry-run|-n) DRY_RUN=1; shift ;;
--help|-h) usage ;;
*) echo "Unbekannte Option: $1" >&2; exit 1 ;;
esac
done
# ------------------------------------------------------------- Voraussetzungen
command -v dotnet >/dev/null 2>&1 || {
echo "FEHLER: dotnet wird gebraucht, ist aber nicht installiert." >&2
exit 1
}
[ -f "$CONFIG_FILE" ] || {
echo "FEHLER: Konfiguration fehlt: $CONFIG_FILE" >&2
echo " Vorlage kopieren: release.config.example.json -> release.config.json" >&2
exit 1
}
# JSON lesen - mit jq, sonst mit Python.
#
# Bewusst nicht nur jq: Es ist auf den wenigsten Systemen vorinstalliert, und
# an einer fehlenden Abhaengigkeit soll die Vorlage nicht scheitern. Python
# liegt auf den meisten Entwickler- und CI-Systemen ohnehin bereit.
JSON_READER=""
if command -v jq >/dev/null 2>&1; then
JSON_READER="jq"
else
# Nicht nur pruefen, ob der Befehl existiert, sondern ob er laeuft:
# Windows legt unter WindowsApps einen python3-Platzhalter ab, der
# gefunden wird, beim Aufruf aber nur auf den Store verweist.
for candidate in python3 python; do
if command -v "$candidate" >/dev/null 2>&1 \
&& "$candidate" -c "import json" >/dev/null 2>&1; then
JSON_READER="$candidate"
break
fi
done
fi
if [ -z "$JSON_READER" ]; then
echo "FEHLER: Zum Lesen von $CONFIG_FILE wird jq oder ein lauffaehiges Python gebraucht." >&2
exit 1
fi
# Liest einen Skalar. Pfad in jq-Schreibweise, z. B. .project
cfg() {
if [ "$JSON_READER" = "jq" ]; then
jq -r "$1 // empty" "$CONFIG_FILE"
else
"$JSON_READER" -c "
import json,sys
d=json.load(open(sys.argv[1], encoding='utf-8'))
for part in sys.argv[2].lstrip('.').split('.'):
if not isinstance(d, dict): d=None; break
d=d.get(part)
if d is None: print('')
elif isinstance(d, bool): print('true' if d else 'false')
else: print(d)
" "$CONFIG_FILE" "$1"
fi
}
# Liest ein Feld mit Zeichenketten, eine je Zeile.
cfg_list() {
if [ "$JSON_READER" = "jq" ]; then
jq -r "$1[]?" "$CONFIG_FILE"
else
"$JSON_READER" -c "
import json,sys
d=json.load(open(sys.argv[1], encoding='utf-8'))
for part in sys.argv[2].lstrip('.').split('.'):
d = d.get(part) if isinstance(d, dict) else None
for item in (d or []): print(item)
" "$CONFIG_FILE" "$1"
fi
}
PROJECT="$(cfg .project)"
CSPROJ_REL="$(cfg .csproj)"
BASE_URL="$(cfg .baseUrl)"
BASE_URL="${BASE_URL:-https://dc.mhdf.de}"
BASE_URL="${BASE_URL%/}"
SETUP_REL="$(cfg .setupJson)"
SETUP_REL="${SETUP_REL:-setup.json}"
MAIN_ASSEMBLY="$(cfg .mainAssembly)"
[ -n "$PROJECT" ] || { echo "FEHLER: 'project' fehlt in $CONFIG_FILE" >&2; exit 1; }
[ -n "$CSPROJ_REL" ] || { echo "FEHLER: 'csproj' fehlt in $CONFIG_FILE" >&2; exit 1; }
CSPROJ="$REPO_ROOT/$CSPROJ_REL"
[ -f "$CSPROJ" ] || { echo "FEHLER: Projektdatei nicht gefunden: $CSPROJ" >&2; exit 1; }
mapfile -t RUNTIMES < <(cfg_list .runtimes)
[ "${#RUNTIMES[@]}" -gt 0 ] || { echo "FEHLER: 'runtimes' ist leer." >&2; exit 1; }
SELF_CONTAINED="$(cfg .selfContained)"
SINGLE_FILE="$(cfg .publishSingleFile)"
# -------------------------------------------------------------------- Version
if [ -z "$VERSION" ]; then
# Directory.Build.props zuerst: Steht <Version> nur in einem von mehreren
# Projekten, laufen die Angaben auseinander - und pack-and-deploy bricht
# dann zu Recht mit einem Versionskonflikt ab.
for candidate in "$REPO_ROOT/Directory.Build.props" "$CSPROJ"; do
[ -f "$candidate" ] || continue
VERSION="$(sed -n 's:.*<Version>\s*\([^<]*\)\s*</Version>.*:\1:p' "$candidate" | head -1 | tr -d '[:space:]')"
[ -n "$VERSION" ] && break
done
[ -n "$VERSION" ] || {
echo "FEHLER: Keine <Version> gefunden. Bitte --version angeben." >&2
exit 1
}
echo "Version aus dem Projekt gelesen: $VERSION"
fi
CHANGELOG="${CHANGELOG:-Release v$VERSION}"
# ------------------------------------------------------------------- Werkzeug
TOOL_DIR="$REPO_ROOT/.dc-tools"
PACK="$TOOL_DIR/pack-and-deploy"
fetch_tool() {
[ -x "$PACK" ] && return 0
case "$(uname -m)" in
x86_64|amd64) rid="linux-x64" ;;
aarch64|arm64) rid="linux-arm64" ;;
*) echo "FEHLER: Nicht unterstuetzte Architektur $(uname -m)" >&2; exit 1 ;;
esac
echo "pack-and-deploy wird geholt ($rid) ..."
mkdir -p "$TOOL_DIR"
tmp="$(mktemp)"
curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid" -o "$tmp"
expected="$(curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid.sha256" | tr -d ' \t\r\n')"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$tmp" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$tmp" | cut -d' ' -f1)"
fi
if [ "$actual" != "$expected" ]; then
rm -f "$tmp"
echo "FEHLER: Pruefsumme stimmt nicht." >&2
echo " erwartet: $expected" >&2
echo " erhalten: $actual" >&2
exit 1
fi
chmod +x "$tmp"
mv "$tmp" "$PACK"
echo " Pruefsumme in Ordnung."
}
fetch_tool
# --------------------------------------------------------------------- Ablauf
echo
echo "Projekt : $PROJECT"
echo "Version : $VERSION"
echo "Kanal : $CHANNEL"
echo "Plattform : ${RUNTIMES[*]}"
[ "$DRY_RUN" -eq 1 ] && echo "(Probelauf - es wird nichts hochgeladen)"
echo
FAILED=0
for rid in "${RUNTIMES[@]}"; do
echo "=== $rid ==="
PUBLISH_DIR="$REPO_ROOT/artifacts/publish/$rid"
if [ "$DRY_RUN" -eq 0 ]; then
# Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden
# sonst mit ins Paket wandern.
rm -rf "$PUBLISH_DIR"
publish_args=(publish "$CSPROJ" -c Release -r "$rid" -o "$PUBLISH_DIR" --nologo)
if [ "$SELF_CONTAINED" = "true" ]; then
publish_args+=(--self-contained true)
else
publish_args+=(--self-contained false)
fi
[ "$SINGLE_FILE" = "true" ] && publish_args+=(-p:PublishSingleFile=true)
dotnet "${publish_args[@]}"
# setup.json mitliefern, damit der Installer weiss, was einzurichten ist.
if [ -f "$REPO_ROOT/$SETUP_REL" ]; then
cp "$REPO_ROOT/$SETUP_REL" "$PUBLISH_DIR/setup.json"
echo " setup.json mitgenommen"
fi
pack_args=(
--project "$PROJECT"
--version "$VERSION"
--channel "$CHANNEL"
--platform "$rid"
--publish-dir "$PUBLISH_DIR"
--changelog "$CHANGELOG"
)
[ "$CRITICAL" -eq 1 ] && pack_args+=(--critical)
[ -n "$MAIN_ASSEMBLY" ] && pack_args+=(--main-assembly "$MAIN_ASSEMBLY")
set +e
"$PACK" "${pack_args[@]}"
code=$?
set -e
case "$code" in
0) echo " -> vollstaendig veroeffentlicht" ;;
1)
# Konfigurationsfehler oder Versionskonflikt: die weiteren
# Plattformen wuerden genauso scheitern.
echo "FEHLER: Konfigurations- oder Versionsfehler - Abbruch." >&2
exit 1
;;
2) echo " -> TEILWEISE: Upload oder Registrierung fehlgeschlagen"; FAILED=1 ;;
*) echo " -> unerwarteter Rueckgabewert $code"; FAILED=1 ;;
esac
fi
echo
done
if [ "$FAILED" -ne 0 ]; then
echo "Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen," >&2
echo "ob die bereits hochgeladenen Dateien konsistent sind." >&2
exit 2
fi
echo "Fertig. $PROJECT $VERSION ist im Kanal $CHANNEL verfuegbar."
+11
View File
@@ -30,5 +30,16 @@
"Enabled": true, "Enabled": true,
"IntervalMinutes": 30 "IntervalMinutes": 30
} }
},
"Deploymentcenter": {
"ApiToken": "",
"HeartbeatEnabled": true,
"MonitorSource": "",
"MonitorInstance": "default",
"HeartbeatIntervalSeconds": 60,
"ReportErrors": true,
"CheckUpdatesOnStart": true,
"UpdateChannel": "prod",
"LicenseKey": ""
} }
} }
+27
View File
@@ -0,0 +1,27 @@
{
"schema": 1,
"displayName": "IBKRTrader",
"description": "Modulares Trading-Framework fuer Interactive Brokers. Fuer den Betrieb werden eine MySQL/MariaDB-Datenbank und ein IBKR-Gateway (TWS/IB Gateway) benoetigt.",
"targets": [
{ "id": "app", "file": "settings.json", "location": "install", "format": "json" }
],
"fields": [
{ "key": "Database:Host", "label": "MySQL-Host", "type": "string", "required": true },
{ "key": "Database:Port", "label": "MySQL-Port", "type": "int", "default": "3306", "required": false },
{ "key": "Database:Database", "label": "Datenbankname", "type": "string", "required": true },
{ "key": "Database:User", "label": "MySQL-Benutzer", "type": "string", "required": true },
{ "key": "Database:Password", "label": "MySQL-Passwort", "type": "secret", "required": true },
{ "key": "IBKR:Host", "label": "TWS/Gateway-Adresse", "type": "string", "default": "127.0.0.1", "required": false },
{ "key": "IBKR:Port", "label": "TWS/Gateway-Port (Paper 4002 / Live 4001)", "type": "int", "default": "4002", "required": false },
{ "key": "Trading:ApplicationTimeZoneId", "label": "Betriebszeitzone (IANA, z. B. Europe/Berlin)", "type": "string", "default": "Europe/Berlin", "required": false },
{ "key": "Deploymentcenter:BaseUrl", "label": "Deploymentcenter", "type": "url", "source": "detect:baseurl" },
{ "key": "Deploymentcenter:MonitorSource", "label": "Name im Watchdog-Dashboard", "source": "detect:hostname" },
{ "key": "Deploymentcenter:ApiToken", "label": "Token dieser Installation",
"source": "provision", "scopes": ["watchdog:ping", "bugtracker:report"] }
]
}
+4 -1
View File
@@ -44,7 +44,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Die Symbole der bisherigen Oberfläche unverändert weiterverwenden (siehe Shell/ViewIcons.cs). --> <!-- Resources/ enthält genau die Symbole, die Shell/ViewIcons.cs auf einen Schlüssel abbildet.
Der Ordner trug bis 2026-08-23 zusätzlich 17 Symbole der WinForms-Oberfläche mit, die das
Wildcard-Muster mit in die Binärdatei nahm, ohne dass sie je aufgelöst wurden. Kommt ein
neuer Schlüssel dazu, gehört das PNG hierher eine Datei ohne Eintrag in ViewIcons nicht. -->
<AvaloniaResource Include="..\..\Resources\*.png" Link="Assets\%(Filename)%(Extension)" /> <AvaloniaResource Include="..\..\Resources\*.png" Link="Assets\%(Filename)%(Extension)" />
</ItemGroup> </ItemGroup>
+1 -1
View File
@@ -31,7 +31,7 @@ internal static class Program
var logger = host.Services.GetRequiredService<LoggingService>(); var logger = host.Services.GetRequiredService<LoggingService>();
logger.Info("Core", "=== IBKRTrader startet ==="); logger.Info("Core", "=== IBKRTrader startet ===");
logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}"); logger.Info("Core", $"Version: {IBKRTrader.Hosting.BuildInfo.Summary} | .NET {Environment.Version}");
host.Start(); host.Start();
StartModules(modules, logger); StartModules(modules, logger);
-24
View File
@@ -1,24 +0,0 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.AI;
/// <summary>
/// Platzhalter für KI-Modell-Integration.
/// Wird in einer späteren Phase mit echten ML-Modellen ersetzt.
/// </summary>
public class AIModelService
{
private readonly LoggingService _logger;
public AIModelService(LoggingService logger)
{
_logger = logger;
}
/// <summary>Platzhalter: Gibt immer 0.5 zurück (keine echte Vorhersage).</summary>
public Task<double> PredictAsync(string symbol, object features)
{
_logger.Warn("AI", $"AIModelService ist ein Platzhalter keine echte Vorhersage für {symbol}.");
return Task.FromResult(0.5);
}
}
@@ -19,13 +19,6 @@ public class BudgetService
_logger = logger; _logger = logger;
} }
public async Task<decimal> GetAvailableBudgetAsync(string module)
{
await using var db = await _dbf.CreateDbContextAsync();
var b = await db.Budgets.FindAsync(module);
return b is null ? 0m : b.TotalBudget - b.UsedBudget;
}
public async Task ReserveBudgetAsync(string module, decimal amount) public async Task ReserveBudgetAsync(string module, decimal amount)
{ {
await using var db = await _dbf.CreateDbContextAsync(); await using var db = await _dbf.CreateDbContextAsync();
+26 -6
View File
@@ -15,8 +15,13 @@ namespace IBKRTrader.Core.Configuration;
/// hat immer Vorrang, damit ein Betreiber die Ablage frei bestimmen kann.</item> /// hat immer Vorrang, damit ein Betreiber die Ablage frei bestimmen kann.</item>
/// <item>Das Verzeichnis der Binärdatei, <b>wenn dort geschrieben werden darf</b>. Das hält das /// <item>Das Verzeichnis der Binärdatei, <b>wenn dort geschrieben werden darf</b>. Das hält das
/// bisherige Verhalten unter Windows und beim Entwickeln unter Linux unverändert.</item> /// bisherige Verhalten unter Windows und beim Entwickeln unter Linux unverändert.</item>
/// <item>Sonst die FHS-Konvention: <c>/etc/ibkrtrader</c>, <c>/var/lib/ibkrtrader</c>, /// <item>Sonst plattformabhängig: unter Linux/macOS die FHS-Konvention
/// <c>/var/log/ibkrtrader</c>.</item> /// (<c>/etc/ibkrtrader</c>, <c>/var/lib/ibkrtrader</c>, <c>/var/log/ibkrtrader</c>), unter
/// Windows <c>%ProgramData%\IBKRTrader</c>. <b>Der FHS-Pfad darf hier nicht als
/// plattformübergreifender Rückfall dienen</b> .NET löst <c>/etc/ibkrtrader</c> unter
/// Windows zu <c>C:\etc\ibkrtrader</c> auf. Genau dieser Fall tritt ein, sobald eine
/// Installation nach <c>Program Files</c> zeigt, wo der Dienst- bzw. Nutzerkonto-Kontext
/// keinen Schreibzugriff neben der Binärdatei hat.</item>
/// </list> /// </list>
/// ///
/// <para>Die Prüfung läuft einmal beim ersten Zugriff; die Verzeichnisse werden dabei angelegt.</para> /// <para>Die Prüfung läuft einmal beim ersten Zugriff; die Verzeichnisse werden dabei angelegt.</para>
@@ -26,8 +31,8 @@ public static class AppPaths
private const string AppFolder = "ibkrtrader"; private const string AppFolder = "ibkrtrader";
private static readonly Lazy<string> _config = new(() => Resolve("IBKRTRADER_CONFIG_DIR", $"/etc/{AppFolder}")); private static readonly Lazy<string> _config = new(() => Resolve("IBKRTRADER_CONFIG_DIR", $"/etc/{AppFolder}"));
private static readonly Lazy<string> _data = new(() => Resolve("IBKRTRADER_DATA_DIR", $"/var/lib/{AppFolder}")); private static readonly Lazy<string> _data = new(() => Resolve("IBKRTRADER_DATA_DIR", $"/var/lib/{AppFolder}", windowsSubDir: "Data"));
private static readonly Lazy<string> _logs = new(() => Resolve("IBKRTRADER_LOG_DIR", $"/var/log/{AppFolder}", "Logs")); private static readonly Lazy<string> _logs = new(() => Resolve("IBKRTRADER_LOG_DIR", $"/var/log/{AppFolder}", "Logs", windowsSubDir: "Logs"));
/// <summary>Konfiguration und Schlüsselmaterial: <c>settings.json</c>, <c>master.key</c>, <c>openrouter.key</c>.</summary> /// <summary>Konfiguration und Schlüsselmaterial: <c>settings.json</c>, <c>master.key</c>, <c>openrouter.key</c>.</summary>
public static string Config => _config.Value; public static string Config => _config.Value;
@@ -48,13 +53,18 @@ public static class AppPaths
public static string Describe() => $"config={Config}, data={Data}, logs={Logs}"; public static string Describe() => $"config={Config}, data={Data}, logs={Logs}";
/// <param name="envVar">Umgebungsvariable, die alles überstimmt.</param> /// <param name="envVar">Umgebungsvariable, die alles überstimmt.</param>
/// <param name="fhsFallback">FHS-Pfad, wenn neben der Binärdatei nicht geschrieben werden darf.</param> /// <param name="fhsFallback">FHS-Pfad unter Linux/macOS, wenn neben der Binärdatei nicht geschrieben werden darf.</param>
/// <param name="localSubDir"> /// <param name="localSubDir">
/// Unterverzeichnis im Binärverzeichnis. Für Logs ist das <c>Logs/</c>; Konfiguration und Daten /// Unterverzeichnis im Binärverzeichnis. Für Logs ist das <c>Logs/</c>; Konfiguration und Daten
/// lagen bisher direkt daneben und bleiben dort, damit bestehende Installationen unverändert /// lagen bisher direkt daneben und bleiben dort, damit bestehende Installationen unverändert
/// weiterlaufen. /// weiterlaufen.
/// </param> /// </param>
private static string Resolve(string envVar, string fhsFallback, string? localSubDir = null) /// <param name="windowsSubDir">
/// Unterverzeichnis unter <c>%ProgramData%\IBKRTrader</c>, wenn unter Windows neben der
/// Binärdatei nicht geschrieben werden darf (z. B. Installation nach <c>Program Files</c>).
/// <c>null</c> = direkt in <c>%ProgramData%\IBKRTrader</c>, wie bei der Konfiguration.
/// </param>
private static string Resolve(string envVar, string fhsFallback, string? localSubDir = null, string? windowsSubDir = null)
{ {
var fromEnv = Environment.GetEnvironmentVariable(envVar); var fromEnv = Environment.GetEnvironmentVariable(envVar);
if (!string.IsNullOrWhiteSpace(fromEnv)) if (!string.IsNullOrWhiteSpace(fromEnv))
@@ -67,6 +77,16 @@ public static class AppPaths
if (IsWritable(AppContext.BaseDirectory)) if (IsWritable(AppContext.BaseDirectory))
return Ensure(local); return Ensure(local);
// Binärverzeichnis nicht beschreibbar - der FHS-Pfad taugt nur unter Linux/macOS als
// Rückfall. Unter Windows löst .NET "/etc/ibkrtrader" zu "C:\etc\ibkrtrader" auf; der
// richtige Rückfall dort ist das maschinenweite Anwendungsdatenverzeichnis.
if (OperatingSystem.IsWindows())
{
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
var winBase = Path.Combine(programData, "IBKRTrader");
return Ensure(windowsSubDir is null ? winBase : Path.Combine(winBase, windowsSubDir));
}
return Ensure(fhsFallback); return Ensure(fhsFallback);
} }
@@ -119,26 +119,6 @@ public class IBKRGatewayService
} }
} }
/// <summary>
/// Initialisiert die Brokerage-Session über den Gateway.
/// </summary>
public async Task<bool> InitBrokerageSessionAsync(CancellationToken ct = default)
{
try
{
await RateLimitAsync(ct);
var body = new StringContent("{\"publish\":true,\"compete\":true}",
System.Text.Encoding.UTF8, "application/json");
var response = await _http.PostAsync("iserver/auth/ssodh/init", body, ct);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.Warn("IBKR", $"Brokerage-Session-Init fehlgeschlagen: {ex.Message}");
return false;
}
}
// ─── Contract Search ───────────────────────────────────────────────────── // ─── Contract Search ─────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -198,33 +178,6 @@ public class IBKRGatewayService
} }
} }
/// <summary>
/// Sucht Aktien-Contracts nach Symbolen.
/// GET /trsrv/stocks?symbols=AAPL,MSFT
/// </summary>
public async Task<Dictionary<string, List<IBKRStockContract>>?> SearchStocksBySymbolAsync(
string symbols, CancellationToken ct = default)
{
try
{
await RateLimitAsync(ct);
var response = await _http.GetAsync($"trsrv/stocks?symbols={Uri.EscapeDataString(symbols)}", ct);
if (!response.IsSuccessStatusCode)
{
_logger.Warn("IBKR", $"Stock-Suche fehlgeschlagen: HTTP {(int)response.StatusCode}");
return null;
}
return await response.Content.ReadFromJsonAsync<Dictionary<string, List<IBKRStockContract>>>(JsonOpts, ct);
}
catch (Exception ex)
{
_logger.Error("IBKR", $"Stock-Suche fehlgeschlagen: {ex.Message}", ex);
return null;
}
}
// ─── Historical Market Data ────────────────────────────────────────────── // ─── Historical Market Data ──────────────────────────────────────────────
/// <summary> /// <summary>
@@ -263,12 +216,4 @@ public class IBKRGatewayService
return null; return null;
} }
} }
// ─── Disconnect ──────────────────────────────────────────────────────────
public Task DisconnectAsync()
{
IsConnected = false;
return Task.CompletedTask;
}
} }
@@ -47,7 +47,7 @@ public enum OrderEventType
/// <summary> /// <summary>
/// Eine Zeile im Entscheidungsjournal (core_decision_journal): JEDE Handelsentscheidung /// Eine Zeile im Entscheidungsjournal (core_decision_journal): JEDE Handelsentscheidung
/// ausgeführt, abgelehnt oder übersprungen strukturiert und abfragbar. Grundlage für /// ausgeführt, abgelehnt oder übersprungen strukturiert und abfragbar. Grundlage für
/// Supervisor-Analysen („warum (nicht) gehandelt?"). Siehe docs/konzepte/KONZEPT-Modul-Supervisor.md. /// Supervisor-Analysen („warum (nicht) gehandelt?"). Siehe docs/archiv/KONZEPT-Modul-Supervisor.md.
/// </summary> /// </summary>
public class CoreDecisionRecord public class CoreDecisionRecord
{ {
@@ -92,10 +92,6 @@ public static class SecretProtection
return Encoding.UTF8.GetString(pt); return Encoding.UTF8.GetString(pt);
} }
/// <summary>True, wenn der Wert bereits im verschlüsselten Format vorliegt.</summary>
public static bool IsEncrypted(string? value) =>
!string.IsNullOrEmpty(value) && value!.StartsWith(Prefix, StringComparison.Ordinal);
private static byte[] DeriveKey(string raw) private static byte[] DeriveKey(string raw)
{ {
// 32-Byte-Key als Base64? // 32-Byte-Key als Base64?
+64 -4
View File
@@ -11,7 +11,7 @@ public class DatabaseSettings
[Category("Database")] [Category("Database")]
[DisplayName("Host")] [DisplayName("Host")]
[Description("MySQL-Serveradresse (Hostname oder IP)")] [Description("MySQL-Serveradresse (Hostname oder IP)")]
public string Host { get; set; } = "larx.your-database.de"; public string Host { get; set; } = "your-db-host";
[Category("Database")] [Category("Database")]
[DisplayName("Port")] [DisplayName("Port")]
@@ -21,18 +21,18 @@ public class DatabaseSettings
[Category("Database")] [Category("Database")]
[DisplayName("Database")] [DisplayName("Database")]
[Description("Name der MySQL-Datenbank")] [Description("Name der MySQL-Datenbank")]
public string Database { get; set; } = "bergisnu_db0"; public string Database { get; set; } = "your-db-name";
[Category("Database")] [Category("Database")]
[DisplayName("User")] [DisplayName("User")]
[Description("MySQL-Benutzername")] [Description("MySQL-Benutzername")]
public string User { get; set; } = "bergisnu_0"; public string User { get; set; } = "your-db-user";
[Category("Database")] [Category("Database")]
[DisplayName("Password")] [DisplayName("Password")]
[Description("MySQL-Passwort")] [Description("MySQL-Passwort")]
[PasswordPropertyText(true)] [PasswordPropertyText(true)]
public string Password { get; set; } = "o5/A49+f}:Da"; public string Password { get; set; } = "CHANGE_ME";
public string BuildConnectionString() => public string BuildConnectionString() =>
$"Server={Host};Port={Port};Database={Database};" + $"Server={Host};Port={Port};Database={Database};" +
@@ -299,6 +299,61 @@ public class SupervisorSettings
public override string ToString() => Model; public override string ToString() => Model;
} }
// ─── Deploymentcenter ────────────────────────────────────────────────────────
[TypeConverter(typeof(ExpandableObjectConverter))]
public class DeploymentcenterSettings
{
[Category("Deploymentcenter")]
[DisplayName("API Token")]
[Description("Authorization: Bearer <Token>. Ohne Token: keine Heartbeats, keine Fehlermeldungen.")]
[PasswordPropertyText(true)]
public string ApiToken { get; set; } = "";
[Category("Deploymentcenter")]
[DisplayName("Heartbeat aktiv")]
[Description("Dead-Man's-Switch an/aus")]
public bool HeartbeatEnabled { get; set; } = true;
[Category("Deploymentcenter")]
[DisplayName("Monitor Source")]
[Description("Monitor-Name im Dashboard. Leer = Rechnername (Auto-Registrierung beim ersten Ping)")]
public string MonitorSource { get; set; } = "";
[Category("Deploymentcenter")]
[DisplayName("Monitor Instance")]
[Description("Für mehrere Instanzen desselben Dienstes (z. B. EU/US)")]
public string MonitorInstance { get; set; } = "default";
[Category("Deploymentcenter")]
[DisplayName("Heartbeat-Intervall (Sekunden)")]
[Description("warning nach 2×, down nach 4× dieses Intervalls")]
public int HeartbeatIntervalSeconds { get; set; } = 60;
[Category("Deploymentcenter")]
[DisplayName("Fehler melden")]
[Description("Error/Fatal an den Fehler-Stream (/api/errors/v1/report)")]
public bool ReportErrors { get; set; } = true;
[Category("Deploymentcenter")]
[DisplayName("Update-Prüfung beim Start")]
[Description("Prüft nur, installiert nichts von selbst")]
public bool CheckUpdatesOnStart { get; set; } = true;
[Category("Deploymentcenter")]
[DisplayName("Update-Kanal")]
[Description("prod, beta oder dev")]
public string UpdateChannel { get; set; } = "prod";
[Category("Deploymentcenter")]
[DisplayName("Lizenzschlüssel")]
[Description("Nur Rückfall, falls noch kein Schlüssel im verschlüsselten Cache liegt")]
[PasswordPropertyText(true)]
public string LicenseKey { get; set; } = "";
public override string ToString() => HeartbeatEnabled ? "Aktiv" : "Deaktiviert";
}
// ─── Root ──────────────────────────────────────────────────────────────────── // ─── Root ────────────────────────────────────────────────────────────────────
public class AppSettings public class AppSettings
@@ -347,4 +402,9 @@ public class AppSettings
[DisplayName("Supervisor")] [DisplayName("Supervisor")]
[Description("KI-Analyse/Forensik (OpenRouter, read-only)")] [Description("KI-Analyse/Forensik (OpenRouter, read-only)")]
public SupervisorSettings Supervisor { get; set; } = new(); public SupervisorSettings Supervisor { get; set; } = new();
[Category("Deploymentcenter")]
[DisplayName("Deploymentcenter")]
[Description("Lizenz, Watchdog, UpdateService, Fehler-Stream (https://dc.mhdf.de)")]
public DeploymentcenterSettings Deploymentcenter { get; set; } = new();
} }
@@ -41,6 +41,13 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
private PortfolioSlot? _portfolio; private PortfolioSlot? _portfolio;
private TaskCompletionSource<bool> _handshake = NewTcs(); private TaskCompletionSource<bool> _handshake = NewTcs();
private TaskCompletionSource<long>? _serverTime;
/// <summary>Ab diesem Versatz zur TWS-Serverzeit gilt die Uhr des Hosts als verstellt.</summary>
private const double MaxClockSkewSeconds = 5;
// Der Hinweis auf angenommene Zeitzonen soll einmal je Verbindung kommen, nicht je Abruf.
private bool _zoneWarningIssued;
private volatile bool _ready; private volatile bool _ready;
private volatile bool _disposed; private volatile bool _disposed;
private int _nextRequestId = 1000; private int _nextRequestId = 1000;
@@ -125,12 +132,104 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
} }
_ready = true; _ready = true;
_zoneWarningIssued = false;
_socket.reqMarketDataType(_marketDataType); _socket.reqMarketDataType(_marketDataType);
_logger.Info(LogModule, _logger.Info(LogModule,
$"Verbunden mit {_host}:{_port} (Client {_clientId}), Konto {_account ?? "unbekannt"}."); $"Verbunden mit {_host}:{_port} (Client {_clientId}), Konto {_account ?? "unbekannt"}.");
await LogTimeContextAsync(ct).ConfigureAwait(false);
return true; return true;
} }
/// <summary>
/// Schreibt den vollständigen Zeitkontext einer Verbindung ins Log: Betriebszeitzone,
/// Systemzeitzone und den Versatz zur Uhr des TWS-Servers.
///
/// <para><b>Wozu:</b> Wir betreiben Instanzen in EU und US, künftig auf Linux-VMs. Weicht die
/// Betriebszeitzone von der des Hosts ab oder geht die VM-Uhr nach, verschieben sich
/// Buchungszeiten ohne dass irgendwo ein Fehler auftaucht. Steht der Kontext am Anfang jeder
/// Verbindung im Log, lässt sich das im Nachhinein an einer Zeile ablesen statt zu raten.</para>
/// </summary>
private async Task LogTimeContextAsync(CancellationToken ct)
{
var context = $"Zeitkontext: Betriebszeitzone {AppTimeZone.CurrentId}, " +
$"Systemzeitzone {TimeZoneInfo.Local.Id}";
var pending = NewTcs<long>();
_serverTime = pending;
try
{
_socket.reqCurrentTime();
if (!await WaitAsync(pending.Task, TimeSpan.FromSeconds(5), ct).ConfigureAwait(false))
{
_logger.Info(LogModule, context + ", TWS-Serverzeit nicht ermittelbar.");
return;
}
var serverUtc = DateTimeOffset.FromUnixTimeSeconds(pending.Task.Result).UtcDateTime;
var skew = (serverUtc - DateTime.UtcNow).TotalSeconds;
context += $", TWS-Serverzeit {serverUtc:yyyy-MM-dd HH:mm:ss}Z, " +
$"Uhrenversatz {skew.ToString("+0.0;-0.0;0", CultureInfo.InvariantCulture)} s";
if (Math.Abs(skew) > MaxClockSkewSeconds)
_logger.Warn(LogModule, context +
" die Uhren laufen auseinander. In virtuellen Maschinen ist das ein häufiger " +
"Fehler; die Zeitsynchronisation des Hosts prüfen, sonst wandern Buchungszeiten.");
else
_logger.Info(LogModule, context + ".");
}
finally
{
_serverTime = null;
}
}
/// <summary>
/// Fasst nach jedem Abruf zusammen, wie viele Zeitstempel TWS mit Zonenangabe gemeldet hat und
/// wie viele über die Betriebszeitzone <b>angenommen</b> wurden. Die angenommenen sind die
/// Stelle, an der ein Wechsel zwischen EU- und US-Host lautlos danebenliegt.
/// </summary>
private void LogTimeProvenance(IReadOnlyList<IbkrMapping.ExecutionTimestamp> stamps)
{
if (stamps.Count == 0) return;
var reported = stamps.Count(t => t.Source == IbkrMapping.ExecutionTimeSource.ReportedZone);
var assumed = stamps.Count(t => t.IsAssumed);
var unreadable = stamps.Count(t => t.Source == IbkrMapping.ExecutionTimeSource.Unparsable);
var zones = string.Join(", ", stamps.Where(t => t.ReportedZone is not null)
.Select(t => t.ReportedZone!)
.Distinct());
var text = $"Zeitstempel von {stamps.Count} Ausführung(en): {reported} mit gemeldeter Zone" +
(zones.Length > 0 ? $" ({zones})" : "") +
$", {assumed} über die Betriebszeitzone {AppTimeZone.CurrentId}" +
(unreadable > 0 ? $", {unreadable} unlesbar" : "") + ".";
_logger.Info(LogModule, text);
// Unlesbare Zeitstempel sind immer ein Defekt die Ausführung landet sonst auf DateTime.MinValue.
if (unreadable > 0)
_logger.Warn(LogModule,
$"{unreadable} Ausführung(en) mit unlesbarem Zeitstempel das Format von TWS hat sich " +
"vermutlich geändert. IbkrMapping.ResolveExecutionTime prüfen.");
// Angenommene Zonen sind nur dann heikel, wenn Betriebs- und Systemzeitzone auseinandergehen:
// dann ist nicht mehr offensichtlich, gegen welche Uhr TWS die Zeit gemeldet hat.
if (assumed > 0 && !_zoneWarningIssued &&
!string.Equals(AppTimeZone.CurrentId, TimeZoneInfo.Local.Id, StringComparison.OrdinalIgnoreCase))
{
_zoneWarningIssued = true;
_logger.Warn(LogModule,
$"{assumed} Zeitstempel ohne Zonenangabe wurden gegen die Betriebszeitzone " +
$"{AppTimeZone.CurrentId} gerechnet, das System läuft aber auf {TimeZoneInfo.Local.Id}. " +
"Stimmt Trading.ApplicationTimeZoneId nicht mit der Zeitzone des TWS-Hosts überein, " +
"liegen die Buchungszeiten daneben. Einmal gegen TWS gegenprüfen.");
}
}
private void StartReader() private void StartReader()
{ {
var reader = new EReader(_socket, _signal); var reader = new EReader(_socket, _signal);
@@ -327,6 +426,8 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
LogTimeProvenance(slot.Timestamps);
return slot.Items return slot.Items
.Select(e => slot.Commissions.TryGetValue(e.ExecId, out var c) .Select(e => slot.Commissions.TryGetValue(e.ExecId, out var c)
? e with { Commission = c.Amount, CommissionCurrency = c.Currency } ? e with { Commission = c.Amount, CommissionCurrency = c.Currency }
@@ -452,14 +553,20 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
public override void accountDownloadEnd(string account) => _portfolio?.Complete(); public override void accountDownloadEnd(string account) => _portfolio?.Complete();
/// <summary>Antwort auf <c>reqCurrentTime</c> Sekunden seit Epoch, Basis des Uhrenvergleichs.</summary>
public override void currentTime(long time) => _serverTime?.TrySetResult(time);
public override void execDetails(int reqId, Contract contract, Execution execution) public override void execDetails(int reqId, Contract contract, Execution execution)
{ {
if (!_executions.TryGetValue(reqId, out var slot)) return; if (!_executions.TryGetValue(reqId, out var slot)) return;
var stamp = IbkrMapping.ResolveExecutionTime(execution.Time, AppTimeZone.Current);
slot.Timestamps.Add(stamp);
slot.Items.Add(new BrokerExecution slot.Items.Add(new BrokerExecution
{ {
ExecId = execution.ExecId, ExecId = execution.ExecId,
Time = IbkrMapping.ParseExecutionTime(execution.Time, AppTimeZone.Current) ?? DateTime.MinValue, Time = stamp.Utc ?? DateTime.MinValue,
Symbol = contract.Symbol, Symbol = contract.Symbol,
SecType = contract.SecType, SecType = contract.SecType,
Side = IbkrMapping.ParseSide(execution.Side), Side = IbkrMapping.ParseSide(execution.Side),
@@ -569,7 +676,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
? parsed ? parsed
: 0m; : 0m;
private static TaskCompletionSource<bool> NewTcs() => private static TaskCompletionSource<bool> NewTcs() => NewTcs<bool>();
private static TaskCompletionSource<T> NewTcs<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously); new(TaskCreationOptions.RunContinuationsAsynchronously);
private static async Task<bool> WaitAsync(Task task, TimeSpan timeout, CancellationToken ct) private static async Task<bool> WaitAsync(Task task, TimeSpan timeout, CancellationToken ct)
@@ -637,5 +746,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
{ {
public readonly List<BrokerExecution> Items = new(); public readonly List<BrokerExecution> Items = new();
public readonly ConcurrentDictionary<string, (decimal Amount, string Currency)> Commissions = new(); public readonly ConcurrentDictionary<string, (decimal Amount, string Currency)> Commissions = new();
// Herkunft der Zeitangaben, damit nach dem Abruf zusammengefasst werden kann, wie viele
// Zeitpunkte TWS gemeldet und wie viele wir angenommen haben.
public readonly List<IbkrMapping.ExecutionTimestamp> Timestamps = new();
} }
} }
+62 -13
View File
@@ -33,10 +33,6 @@ internal static class IbkrMapping
return null; 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> /// <summary>US-Aktie über die SMART-Route der einzige Instrumententyp, den die Module handeln.</summary>
public static Contract Stock(string symbol) => new() public static Contract Stock(string symbol) => new()
{ {
@@ -89,6 +85,43 @@ internal static class IbkrMapping
(decimal)(ask > 0 ? ask : price)); (decimal)(ask > 0 ? ask : price));
} }
/// <summary>
/// Woher die Zeitzone einer Ausführung stammt. Ohne diese Angabe lässt sich im Nachhinein
/// nicht mehr feststellen, ob ein Buchungszeitpunkt belastbar ist oder auf einer Annahme beruht
/// und genau das ist der Fehler, der bei einem Umzug zwischen EU- und US-Host lautlos entsteht.
/// </summary>
public enum ExecutionTimeSource
{
/// <summary>TWS hat eine Zone gemeldet und sie war auflösbar der verlässliche Fall.</summary>
ReportedZone,
/// <summary>TWS meldete keine Zone; es galt die Betriebszeitzone der Instanz.</summary>
FallbackZone,
/// <summary>TWS meldete eine Zone, die dieses System nicht kennt; es galt die Betriebszeitzone.</summary>
UnknownZoneFallback,
/// <summary>Der Zeitstempel war nicht lesbar.</summary>
Unparsable
}
/// <summary>
/// Ergebnis der Zeitauflösung samt Herkunft die Grundlage für die Beobachtung im Log.
/// </summary>
/// <param name="Utc">Zeitpunkt in UTC, oder <c>null</c> wenn nicht lesbar.</param>
/// <param name="Source">Woher die verwendete Zone stammt.</param>
/// <param name="ZoneUsed">Die tatsächlich zur Umrechnung benutzte Zone.</param>
/// <param name="ReportedZone">Was TWS gemeldet hat, oder <c>null</c> bei fehlender Angabe.</param>
public readonly record struct ExecutionTimestamp(
DateTime? Utc,
ExecutionTimeSource Source,
string ZoneUsed,
string? ReportedZone)
{
/// <summary>true, wenn der Zeitpunkt auf einer Annahme statt auf einer Meldung von TWS beruht.</summary>
public bool IsAssumed => Source is ExecutionTimeSource.FallbackZone or ExecutionTimeSource.UnknownZoneFallback;
}
/// <summary>Ausführungsseite laut TWS: "BOT" = gekauft, "SLD" = verkauft.</summary> /// <summary>Ausführungsseite laut TWS: "BOT" = gekauft, "SLD" = verkauft.</summary>
public static TradeSide ParseSide(string side) => public static TradeSide ParseSide(string side) =>
side.Trim().ToUpperInvariant() is "SLD" or "SELL" ? TradeSide.Sell : TradeSide.Buy; side.Trim().ToUpperInvariant() is "SLD" or "SELL" ? TradeSide.Sell : TradeSide.Buy;
@@ -109,32 +142,48 @@ internal static class IbkrMapping
/// <param name="fallbackZone"> /// <param name="fallbackZone">
/// Zeitzone für Meldungen ohne Zonenangabe die Betriebszeitzone (<c>AppTimeZone.Current</c>). /// Zeitzone für Meldungen ohne Zonenangabe die Betriebszeitzone (<c>AppTimeZone.Current</c>).
/// </param> /// </param>
public static DateTime? ParseExecutionTime(string? raw, TimeZoneInfo fallbackZone) public static DateTime? ParseExecutionTime(string? raw, TimeZoneInfo fallbackZone) =>
ResolveExecutionTime(raw, fallbackZone).Utc;
/// <summary>
/// Wie <see cref="ParseExecutionTime"/>, liefert aber zusätzlich die <b>Herkunft</b> der
/// verwendeten Zeitzone. Nur damit lässt sich im Nachhinein unterscheiden, ob ein
/// Buchungszeitpunkt von TWS gemeldet oder von uns angenommen wurde.
/// </summary>
public static ExecutionTimestamp ResolveExecutionTime(string? raw, TimeZoneInfo fallbackZone)
{ {
if (string.IsNullOrWhiteSpace(raw)) return null; if (string.IsNullOrWhiteSpace(raw))
return new ExecutionTimestamp(null, ExecutionTimeSource.Unparsable, fallbackZone.Id, null);
// Datum und Uhrzeit trennen TWS je nach Aufruf per Leerzeichen oder Bindestrich // Datum und Uhrzeit trennen TWS je nach Aufruf per Leerzeichen oder Bindestrich
// ("20260804-17:52:56" ist das Format, das auch der Anfragefilter nutzt). // ("20260804-17:52:56" ist das Format, das auch der Anfragefilter nutzt).
var parts = raw.Replace('-', ' ') var parts = raw.Replace('-', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2) return null;
if (!DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss", if (parts.Length < 2 ||
!DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var local)) CultureInfo.InvariantCulture, DateTimeStyles.None, out var local))
return null; return new ExecutionTimestamp(null, ExecutionTimeSource.Unparsable, fallbackZone.Id, null);
// Dritter Teil, falls vorhanden, ist die Zeitzone der Börse. // Dritter Teil, falls vorhanden, ist die Zeitzone der Börse.
var zone = parts.Length >= 3 ? ResolveZone(parts[2]) ?? fallbackZone : fallbackZone; var reported = parts.Length >= 3 ? parts[2] : null;
var resolved = reported is null ? null : ResolveZone(reported);
var zone = resolved ?? fallbackZone;
var source = reported is null ? ExecutionTimeSource.FallbackZone
: resolved is null ? ExecutionTimeSource.UnknownZoneFallback
: ExecutionTimeSource.ReportedZone;
local = DateTime.SpecifyKind(local, DateTimeKind.Unspecified); local = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
// Bei der Zeitumstellung kann die Ortszeit ungültig (Vorstellen) oder doppelt (Zurückstellen) // Bei der Zeitumstellung kann die Ortszeit ungültig (Vorstellen) oder doppelt (Zurückstellen)
// sein. ConvertTimeToUtc würde bei ungültigen Werten werfen eine Ausführung darf daran // sein. ConvertTimeToUtc würde bei ungültigen Werten werfen eine Ausführung darf daran
// nicht verlorengehen, deshalb der ausdrückliche Versatz. // nicht verlorengehen, deshalb der ausdrückliche Versatz.
if (zone.IsInvalidTime(local)) var utc = zone.IsInvalidTime(local)
return DateTime.SpecifyKind(local - zone.BaseUtcOffset, DateTimeKind.Utc); ? DateTime.SpecifyKind(local - zone.BaseUtcOffset, DateTimeKind.Utc)
: TimeZoneInfo.ConvertTimeToUtc(local, zone);
return TimeZoneInfo.ConvertTimeToUtc(local, zone); return new ExecutionTimestamp(utc, source, zone.Id, reported);
} }
/// <summary> /// <summary>
@@ -42,13 +42,4 @@ public class TradeHistoryService
_logger.Info(module, $"Trade gespeichert: {action} {quantity}x {symbol} @ {price:F2}"); _logger.Info(module, $"Trade gespeichert: {action} {quantity}x {symbol} @ {price:F2}");
} }
public async Task<IReadOnlyList<CoreTrade>> GetRecentTradesAsync(int limit = 100)
{
await using var db = await _dbf.CreateDbContextAsync();
return await db.TradeHistory
.OrderByDescending(t => t.TradedAt)
.Take(limit)
.ToListAsync();
}
} }
+49 -1
View File
@@ -1,6 +1,9 @@
using Deploymentcenter.Client;
using IBKRTrader.Core.Configuration;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Modularity;
using IBKRTrader.Hosting; using IBKRTrader.Hosting;
using IBKRTrader.Hosting.Deploymentcenter;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@@ -21,6 +24,16 @@ internal static class Program
if (HasFlag(args, "--db-version")) if (HasFlag(args, "--db-version"))
return DbVersion.Print(); return DbVersion.Print();
// Lizenz-Konvention aus dem Deploymentcenter-Leitfaden: das SDK bringt keine eigene CLI mit,
// diese beiden Schalter sind hier die vom Leitfaden empfohlene Umsetzung. Kein Host nötig -
// beide arbeiten nur mit dem lokalen Lizenz-Cache bzw. einer einzelnen Validierung.
if (HasFlag(args, "--license-status"))
return PrintLicenseStatus();
var setKeyIndex = Array.IndexOf(args, "--license-set-key");
if (setKeyIndex >= 0 && setKeyIndex + 1 < args.Length)
return await SetLicenseKeyAsync(args[setKeyIndex + 1]).ConfigureAwait(false);
// Trockenlauf: Host bauen, Startprüfungen fahren, Dienste NICHT starten. Für Deployment // Trockenlauf: Host bauen, Startprüfungen fahren, Dienste NICHT starten. Für Deployment
// und CI prüft Konfiguration, DB-Verbindungszeichenfolge, Master-Key und Ablageorte, // und CI prüft Konfiguration, DB-Verbindungszeichenfolge, Master-Key und Ablageorte,
// ohne eine einzige Verbindung zur Börse aufzubauen. // ohne eine einzige Verbindung zur Börse aufzubauen.
@@ -40,7 +53,7 @@ internal static class Program
} }
logger.Info("Core", "=== IBKRTrader (kopflos) startet ==="); logger.Info("Core", "=== IBKRTrader (kopflos) startet ===");
logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version} | {RuntimeDescription()}"); logger.Info("Core", $"Version: {IBKRTrader.Hosting.BuildInfo.Summary} | .NET {Environment.Version} | {RuntimeDescription()}");
await host.StartAsync().ConfigureAwait(false); await host.StartAsync().ConfigureAwait(false);
@@ -73,6 +86,41 @@ internal static class Program
private static bool HasFlag(string[] args, string flag) => private static bool HasFlag(string[] args, string flag) =>
args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase)); args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
// ─── Lizenz-CLI (kein Host nötig) ──────────────────────────────────────────
private static int PrintLicenseStatus()
{
LicenseConfig.SetStorageDirectory(AppPaths.Config);
var hw = HardwareId.GetHardwareId(DcConfig.ProductSlug);
var state = LicenseClient.TryGetCachedState(DcConfig.ProductSlug);
Console.WriteLine($"HWID v2: {hw.HardwareId} ({hw.HwidSource})");
Console.WriteLine($"Schlüssel: {LicenseClient.TryGetCachedKey(DcConfig.ProductSlug) ?? "keiner"}");
Console.WriteLine($"Cache: {(state == null ? "leer" : state.Status)}");
if (state is { CacheExpiresAt: > 0 })
{
var until = DateTimeOffset.FromUnixTimeSeconds(state.CacheExpiresAt);
Console.WriteLine($"Offline noch bis: {until:u}");
}
return 0;
}
private static async Task<int> SetLicenseKeyAsync(string key)
{
LicenseConfig.SetStorageDirectory(AppPaths.Config);
// ValidateAsync legt den Schluessel bei Erfolg selbst im verschluesselten Cache ab - eine
// eigene Ablage braucht es dafuer nicht.
var client = new LicenseClient();
var result = await client.ValidateAsync(DcConfig.ProductSlug, key, DcConfig.BaseUrl, DcConfig.AppVersion)
.ConfigureAwait(false);
Console.WriteLine(result.Message);
return result.IsValid ? 0 : 1;
}
private static string RuntimeDescription() => private static string RuntimeDescription() =>
$"{System.Runtime.InteropServices.RuntimeInformation.OSDescription.Trim()} " + $"{System.Runtime.InteropServices.RuntimeInformation.OSDescription.Trim()} " +
$"({System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture})"; $"({System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture})";
+53 -5
View File
@@ -1,4 +1,3 @@
using IBKRTrader.Core.AI;
using IBKRTrader.Core.Budget; using IBKRTrader.Core.Budget;
using IBKRTrader.Core.Configuration; using IBKRTrader.Core.Configuration;
using IBKRTrader.Core.DependencyInjection; using IBKRTrader.Core.DependencyInjection;
@@ -14,6 +13,7 @@ using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Trading.Ibkr; using IBKRTrader.Core.Trading.Ibkr;
using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers;
using IBKRTrader.Core.Workers.BuiltIn; using IBKRTrader.Core.Workers.BuiltIn;
using IBKRTrader.Hosting.Deploymentcenter;
using IBKRTrader.Modules.Accounting; using IBKRTrader.Modules.Accounting;
using IBKRTrader.Modules.CongressTrading; using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.Supervisor; using IBKRTrader.Modules.Supervisor;
@@ -92,9 +92,45 @@ public static class AppHostBuilder
// 4. Transportverschlüsselung der DB prüfen. // 4. Transportverschlüsselung der DB prüfen.
WarnIfDbTlsNotEnforced(services, logger); WarnIfDbTlsNotEnforced(services, logger);
// 5. Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine (vor dem Start). // 5. Zirkuläre Abhängigkeit auflösen: WebApiService und der Watchdog-Heartbeat brauchen die
services.GetRequiredService<WebApiService>() // Engine (vor dem Start), sind aber selbst Teil von deren IEnumerable<IWorker>.
.SetEngine(services.GetRequiredService<WorkerEngine>()); var workerEngine = services.GetRequiredService<WorkerEngine>();
services.GetRequiredService<WebApiService>().SetEngine(workerEngine);
services.GetRequiredService<DcHeartbeatWorker>().SetWorkerEngine(workerEngine);
// 6. Deploymentcenter-Fehlermeldung verdrahten. Ohne globale Handler gab es bisher keine
// "fatal"-Meldungen und keinen Logeintrag, wenn der Prozess unbehandelt wegbrach.
var errorReporter = services.GetRequiredService<DcErrorReporter>();
new DcErrorSink(errorReporter).Attach(logger);
DcCrashHandlers.Register(logger, errorReporter);
// 7. Lizenz pruefen - NACH der Fehlermeldung (ein Sperrbetrieb-Urteil soll sich melden
// koennen) und ohne Eingabeaufforderung: weder Shell noch Daemon duerfen hier auf eine
// Konsoleneingabe warten. Blockierend, weil RunStartupChecks synchron aufgerufen wird und
// vor dem Start der Worker abgeschlossen sein muss.
services.GetRequiredService<LicenseGuard>().CheckAtStartupAsync().GetAwaiter().GetResult();
// 8. Update-Pruefung - NACH der Lizenz, weil der Zugang zur Release-Ablage seit 2.4 am
// Lizenzschluessel haengt. Findet nur, installiert nichts von selbst.
var settingsService = services.GetRequiredService<SettingsService>();
if (settingsService.Settings.Deploymentcenter.CheckUpdatesOnStart)
{
try
{
var check = DcUpdateService.CheckAsync(settingsService).GetAwaiter().GetResult();
if (check.Unauthorized)
logger.Warn("Core", $"Update-Prüfung: Zugangsdaten abgelehnt (Lizenz abgelaufen/widerrufen?) - {check.Message}");
else if (check.UpdateAvailable)
logger.Warn("Core", $"Update verfügbar: v{check.LatestRelease?.Version}{(check.IsCritical ? " (KRITISCH)" : "")} - {check.Message}");
else
logger.Info("Core", "Update-Prüfung: kein Update verfügbar.");
}
catch (Exception ex)
{
// Ein nicht erreichbares Deploymentcenter darf den Start niemals verhindern.
logger.Warn("Core", $"Update-Prüfung fehlgeschlagen: {ex.Message}");
}
}
} }
/// <summary>Registriert alle Core-Services im DI-Container.</summary> /// <summary>Registriert alle Core-Services im DI-Container.</summary>
@@ -114,12 +150,21 @@ public static class AppHostBuilder
services.AddSingleton<LoggingService>(); services.AddSingleton<LoggingService>();
services.AddSingleton<CoreSettingsService>(); // core_settings via EF services.AddSingleton<CoreSettingsService>(); // core_settings via EF
// Deploymentcenter: Fehler-Stream. Als DI-Factory registriert (nicht eager erzeugt), damit
// sie sich denselben LoggingService- und SettingsService-Singleton aus demselben Container
// holt, statt einen zweiten Container dafür aufzubauen.
services.AddSingleton(sp =>
{
var token = sp.GetRequiredService<SettingsService>().Settings.Deploymentcenter.ApiToken;
return new DcErrorReporter(token, msg => sp.GetRequiredService<LoggingService>().Warn("Deploymentcenter", msg));
});
services.AddSingleton<LicenseGuard>();
services.AddSingleton<IBKRGatewayService>(); services.AddSingleton<IBKRGatewayService>();
services.AddSingleton<IBKRMarketDataRepository>(); services.AddSingleton<IBKRMarketDataRepository>();
services.AddSingleton<BudgetService>(); services.AddSingleton<BudgetService>();
services.AddSingleton<TradeHistoryService>(); services.AddSingleton<TradeHistoryService>();
services.AddSingleton<AIModelService>();
// Datenfundament für Analyse/Forensik (Supervisor): Entscheidungsjournal + Order-Events. // Datenfundament für Analyse/Forensik (Supervisor): Entscheidungsjournal + Order-Events.
services.AddSingleton<IDecisionJournal, EfDecisionJournal>(); services.AddSingleton<IDecisionJournal, EfDecisionJournal>();
@@ -152,6 +197,7 @@ public static class AppHostBuilder
services.AddSingleton<WebApiService>(); services.AddSingleton<WebApiService>();
services.AddSingleton<IBKRInstrumentSyncWorker>(); services.AddSingleton<IBKRInstrumentSyncWorker>();
services.AddSingleton<IBKRPriceHistoryWorker>(); services.AddSingleton<IBKRPriceHistoryWorker>();
services.AddSingleton<DcHeartbeatWorker>();
// Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable<IWorker>. // Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable<IWorker>.
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>());
@@ -159,6 +205,7 @@ public static class AppHostBuilder
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<DcHeartbeatWorker>());
// Lebenszyklus über den Generic Host (jeder Worker ist ein IHostedService). // Lebenszyklus über den Generic Host (jeder Worker ist ein IHostedService).
services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>()); services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>());
@@ -166,6 +213,7 @@ public static class AppHostBuilder
services.AddHostedService(sp => sp.GetRequiredService<WebApiService>()); services.AddHostedService(sp => sp.GetRequiredService<WebApiService>());
services.AddHostedService(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>()); services.AddHostedService(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
services.AddHostedService(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>()); services.AddHostedService(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
services.AddHostedService(sp => sp.GetRequiredService<DcHeartbeatWorker>());
services.AddSingleton<WorkerEngine>(); services.AddSingleton<WorkerEngine>();
} }
@@ -0,0 +1,100 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace IBKRTrader.Hosting.Deploymentcenter;
/// <summary>
/// Fehlgeschlagener Deploymentcenter-Aufruf. <see cref="Code"/> ist der stabile, maschinenlesbare
/// Fehlercode aus dem API-Umschlag ("unauthorized", "rate_limited", ...) - reagiert wird auf den
/// Code, nicht auf den Text.
/// </summary>
public sealed class DcApiException : Exception
{
public DcApiException(HttpStatusCode statusCode, string? code, string body)
: base($"Deployment Center HTTP {(int)statusCode}{(code is null ? "" : $" ({code})")}: {Shorten(body)}")
{
StatusCode = statusCode;
Code = code;
}
public HttpStatusCode StatusCode { get; }
public string? Code { get; }
/// <summary>Wahr für Fehler, die eine Wiederholung mit denselben Daten nicht beheben kann (Token falsch/fehlt).</summary>
public bool IsPermanent =>
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ||
Code is "unauthorized" or "project_forbidden";
private static string Shorten(string body) =>
body.Length <= 300 ? body : body[..300] + "…";
}
/// <summary>
/// Schlanker JSON-Client für Watchdog-Heartbeat und Fehler-Stream. Lizenz und UpdateService bringen
/// ihren eigenen Client mit (Deploymentcenter.Client).
///
/// <para><b>Ein nicht erreichbares Deploymentcenter darf den Handel niemals beeinflussen</b> - jeder
/// Aufruf ist best effort mit kurzer Zeitgrenze; Fehler werden dem Aufrufer nur als Exception
/// gemeldet, nie geworfen in einem Kontext, der den Trading-Kern mitreißen könnte.</para>
/// </summary>
public sealed class DcApiClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _http;
private readonly string _token;
public DcApiClient(string token, TimeSpan? timeout = null)
{
_token = token ?? "";
_http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(10) };
}
public async Task<string> PostJsonAsync(string path, object payload, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, DcConfig.BaseUrl + path);
if (!string.IsNullOrWhiteSpace(_token))
{
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_token}");
}
request.Content = new StringContent(
JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new DcApiException(response.StatusCode, ExtractErrorCode(body), body);
}
return body;
}
/// <summary>Liest error.code aus {"status":"error","error":{"code":"…"}}.</summary>
private static string? ExtractErrorCode(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
doc.RootElement.TryGetProperty("error", out var error) &&
error.ValueKind == JsonValueKind.Object &&
error.TryGetProperty("code", out var code))
{
return code.GetString();
}
}
catch (JsonException)
{
// Nicht jeder Fehlerpfad antwortet mit dem Umschlag (z. B. eine Proxy-Fehlerseite).
}
return null;
}
public void Dispose() => _http.Dispose();
}
@@ -0,0 +1,30 @@
namespace IBKRTrader.Hosting.Deploymentcenter;
/// <summary>
/// Fest verdrahtete Eckdaten der Deploymentcenter-Anbindung (https://dc.mhdf.de), das Lizenz,
/// Watchdog, UpdateService, Fehler-Stream und Bugtracker in einem Server bündelt.
///
/// <para><b>Die Basis-URL ist bewusst KEINE Einstellung.</b> Sie entscheidet, wohin die
/// Lizenzprüfung geht und woher ausführbarer Code (Update-Pakete) kommt. Ein einstellbarer
/// Endpunkt wäre eine Einladung, die Anwendung auf einen gefälschten Server zu zeigen.</para>
/// </summary>
public static class DcConfig
{
public const string BaseUrl = "https://dc.mhdf.de";
/// <summary>Slug in dc_projects - Lizenz, UpdateService, Fehler-Stream und Bugtracker teilen ihn.</summary>
public const string ProductSlug = "ibkrtrader";
/// <summary>Gruppierung des Watchdog-Monitors im Dashboard.</summary>
public const string MonitorGroup = "Trading";
/// <summary>
/// Gemeldete Version - Lizenz-Aktivierungsliste, Watchdog-Monitorspalte und Fehler-Stream.
/// Erzeugt von Deploymentcenter.BuildInfo.targets aus &lt;Version&gt; in Directory.Build.props;
/// beim Release dort hochziehen, das ist auch, wogegen der UpdateService vergleicht.
/// </summary>
public static string AppVersion => BuildInfo.Version;
/// <summary>Commit dieses Builds - reist im Fehler-Stream mit.</summary>
public static string GitCommitShort => BuildInfo.GitCommitShort;
}
@@ -0,0 +1,35 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Hosting.Deploymentcenter;
/// <summary>
/// Globale Ausnahmebehandler - ohne sie gab es keine "fatal"-Meldungen und, vor dieser
/// Integration, nicht einmal einen Logeintrag, wenn der Prozess unbehandelt wegbrach.
/// </summary>
public static class DcCrashHandlers
{
public static void Register(LoggingService logger, DcErrorReporter reporter)
{
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
var ex = e.ExceptionObject as Exception;
logger.Error("Core", $"Unbehandelte Ausnahme - Prozess beendet sich (IsTerminating={e.IsTerminating}).", ex);
// Blockierend: der Prozess ist gleich weg, ein fire-and-forget-Task käme nie mehr zur
// Ausführung.
reporter.Report(
level: "fatal",
module: "Core",
message: "Unbehandelte Ausnahme - Prozess beendet sich.",
exception: ex,
blocking: true);
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
logger.Error("Core", "Unbeobachtete Task-Ausnahme.", e.Exception);
reporter.Report(level: "error", module: "Core", message: "Unbeobachtete Task-Ausnahme.", exception: e.Exception);
e.SetObserved();
};
}
}
@@ -0,0 +1,114 @@
namespace IBKRTrader.Hosting.Deploymentcenter;
/// <summary>
/// Meldet Laufzeitfehler an POST /api/errors/v1/report.
///
/// <para><b>Bremse ist Pflicht, nicht Kür.</b> Ein Fehler in einer Handelsschleife könnte binnen
/// Sekunden das serverseitige Rate-Limit verbrennen. Deshalb: höchstens 20 Meldungen pro Minute
/// insgesamt, derselbe Fehler höchstens alle 5 Minuten, und bei 401/403 wird dauerhaft
/// abgeschaltet - ein abgelehntes Token wird durch Wiederholen nicht gültig.</para>
///
/// <para>Serverseitig werden gleiche Fehler ohnehin gruppiert und hochgezählt; betriebsbedingtes
/// Rauschen gehört in die Ignore-Regeln des WebUI, nicht in eine noch härtere lokale Bremse.</para>
/// </summary>
public sealed class DcErrorReporter
{
#if DEBUG
private const string Environment_ = "development";
#else
private const string Environment_ = "production";
#endif
private const int MaxPerMinute = 20;
private static readonly TimeSpan SameErrorCooldown = TimeSpan.FromMinutes(5);
private readonly string _token;
private readonly Action<string> _debugLog;
private readonly object _lock = new();
private readonly Queue<DateTime> _recentSends = new();
private readonly Dictionary<string, DateTime> _lastSentByFingerprint = new();
private volatile bool _disabled;
public DcErrorReporter(string token, Action<string>? debugLog = null)
{
_token = (token ?? "").Trim();
_debugLog = debugLog ?? (_ => { });
}
public bool Enabled => !_disabled && !string.IsNullOrWhiteSpace(_token);
/// <summary>
/// Meldet einen Fehler. <paramref name="blocking"/> gilt für den Fall "fatal": der Prozess
/// beendet sich gleich, ein fire-and-forget-Task käme nie mehr zur Ausführung.
/// </summary>
public void Report(string level, string module, string message, Exception? exception,
string? filePath = null, int? lineNo = null, string? correlationId = null,
bool blocking = false)
{
if (!Enabled) return;
var fingerprint = module + "|" + (exception?.GetType().FullName ?? message);
lock (_lock)
{
if (_disabled) return;
var now = DateTime.UtcNow;
while (_recentSends.Count > 0 && now - _recentSends.Peek() > TimeSpan.FromMinutes(1))
_recentSends.Dequeue();
if (_recentSends.Count >= MaxPerMinute) return;
if (_lastSentByFingerprint.TryGetValue(fingerprint, out var last) && now - last < SameErrorCooldown)
return;
_recentSends.Enqueue(now);
_lastSentByFingerprint[fingerprint] = now;
}
var payload = new
{
project_slug = DcConfig.ProductSlug,
exception = exception?.GetType().FullName ?? "LoggedError",
message = exception != null ? $"{message} | {exception.Message}" : message,
stack_trace = exception?.ToString(),
level,
build = DcConfig.AppVersion,
environment = Environment_,
file = filePath,
line = lineNo,
context = new { git_commit = DcConfig.GitCommitShort, correlation_id = correlationId, module }
};
var client = new DcApiClient(_token, TimeSpan.FromSeconds(blocking ? 5 : 10));
var task = SendAsync(client, payload);
if (blocking)
{
// AppDomain.UnhandledException: der Prozess ist gleich weg, ein fire-and-forget-Task
// käme nie mehr zur Ausführung. Kurze Zeitgrenze, damit das Beenden nicht unnötig hängt.
try { task.Wait(TimeSpan.FromSeconds(6)); } catch { /* best effort */ }
client.Dispose();
}
else
{
_ = task.ContinueWith(_ => client.Dispose(), TaskScheduler.Default);
}
}
private async Task SendAsync(DcApiClient client, object payload)
{
try
{
await client.PostJsonAsync("/api/errors/v1/report", payload).ConfigureAwait(false);
}
catch (DcApiException ex) when (ex.IsPermanent)
{
_disabled = true;
_debugLog($"[Deploymentcenter] Fehler-Stream dauerhaft deaktiviert: {ex.Message}");
}
catch (Exception ex)
{
// Ein nicht erreichbares Deploymentcenter darf den Handel niemals beeinflussen.
_debugLog($"[Deploymentcenter] Fehlermeldung fehlgeschlagen: {ex.Message}");
}
}
}
@@ -0,0 +1,32 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Hosting.Deploymentcenter;
/// <summary>
/// Hängt sich an <see cref="LoggingService.EntryWritten"/> und meldet Error-Einträge an den
/// Fehler-Stream. Ergänzt die globalen Ausnahmebehandler (<see cref="DcCrashHandlers"/>): die
/// decken den Absturz des Prozesses ab, dieser Sink jedes behandelte <c>logger.Error(...)</c> aus
/// Workern, Modulen und Kestrel.
///
/// <para>Bewusst nicht blockierend - ein hängender Sink darf den schreibenden Worker nicht
/// mitreißen. Die Bremse gegen Log-Fluten sitzt in <see cref="DcErrorReporter"/>.</para>
/// </summary>
public sealed class DcErrorSink
{
private readonly DcErrorReporter _reporter;
public DcErrorSink(DcErrorReporter reporter) => _reporter = reporter;
public void Attach(LoggingService logger) => logger.EntryWritten += OnEntryWritten;
private void OnEntryWritten(LogEntry entry)
{
if (entry.Level != AppLogLevel.Error) return;
try
{
_reporter.Report(level: "error", module: entry.Module, message: entry.Message, exception: entry.Exception);
}
catch { /* Fehlermeldung darf das Logging niemals stören */ }
}
}
@@ -0,0 +1,299 @@
using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Persistence;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
namespace IBKRTrader.Hosting.Deploymentcenter;
/// <summary>
/// Dead-Man's-Switch: meldet in festem Takt POST /api/watchdog/v1/ping.
///
/// <para>Bewusst <b>nicht</b> über <see cref="WorkerBase"/>: die Basisklasse schreibt bei jedem Run
/// zwei Zeilen nach <c>core_worker_log</c> - bei einem 60-Sekunden-Takt wären das über 2800
/// Schreibvorgänge am Tag für reine Lebenszeichen. Ein eigener, schlanker Loop passt hier besser;
/// <see cref="Info"/> wird trotzdem gepflegt, damit der Worker in der Workers-Ansicht auftaucht.</para>
///
/// <para>Ein Heartbeat beweist nur, dass ein Thread läuft - deshalb schickt er den selbst
/// ermittelten Zustand mit (<c>checks</c>). Ein nicht erreichbares Deploymentcenter darf den Handel
/// niemals beeinflussen: jeder Aufruf ist best effort mit kurzer Zeitgrenze.</para>
/// </summary>
public sealed class DcHeartbeatWorker : IWorker, IHostedService
{
public string Name => "DcHeartbeatWorker";
public string Module => "Core";
public WorkerType Type => WorkerType.Service;
public WorkerInfo Info { get; } = new();
private readonly LoggingService _logger;
private readonly SettingsService _settings;
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly IBKRGatewayService _gateway;
private readonly IPortfolioService _portfolio;
private readonly IOrderEventLog _orderEvents;
private readonly IEnumerable<IModule> _modules;
private readonly LicenseGuard _licenseGuard;
private readonly DateTime _startedAtUtc = DateTime.UtcNow;
private readonly SemaphoreSlim _wake = new(0, 1);
private static readonly TimeSpan LicenseRevalidationInterval = TimeSpan.FromHours(12);
private DateTime _lastLicenseRevalidationUtc = DateTime.UtcNow;
private WorkerEngine? _workerEngine;
private CancellationTokenSource? _cts;
private Task? _loop;
public DcHeartbeatWorker(
LoggingService logger, SettingsService settings, IDbContextFactory<CoreDbContext> dbf,
IBKRGatewayService gateway, IPortfolioService portfolio, IOrderEventLog orderEvents,
IEnumerable<IModule> modules, LicenseGuard licenseGuard)
{
_logger = logger;
_settings = settings;
_dbf = dbf;
_gateway = gateway;
_portfolio = portfolio;
_orderEvents = orderEvents;
_modules = modules;
_licenseGuard = licenseGuard;
Info.WorkerName = Name;
Info.Module = Module;
Info.Type = Type.ToString();
Info.Active = settings.Settings.Deploymentcenter.HeartbeatEnabled;
Info.Status = WorkerStatus.Idle;
Info.Info = "Bereit";
}
/// <summary>
/// Zirkuläre Abhängigkeit auflösen (WorkerEngine registriert alle IWorker, auch diesen) - gleiches
/// Muster wie <see cref="Workers.BuiltIn.WebApiService"/>: erst nach dem Bau des Hosts setzbar.
/// </summary>
public void SetWorkerEngine(WorkerEngine engine) => _workerEngine = engine;
// ─── Lifecycle ────────────────────────────────────────────────────────────
public Task StartAsync(CancellationToken cancellationToken)
{
if (!Info.Active)
{
Info.Status = WorkerStatus.Stopped;
Info.Info = "Deaktiviert";
return Task.CompletedTask;
}
Info.RunEvery = $"{Math.Max(5, _settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds)}s";
_cts = new CancellationTokenSource();
_loop = Task.Run(() => RunLoopAsync(_cts.Token), _cts.Token);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_cts == null) return;
await _cts.CancelAsync();
if (_loop != null)
{
try { await _loop.ConfigureAwait(false); }
catch (OperationCanceledException) { /* erwartet */ }
}
// Geordnetes Beenden ankündigen - ohne das folgt wenige Minuten später ein Fehlalarm.
// Kurze eigene Zeitgrenze: ein hängender Monitoring-Server darf das Herunterfahren
// nicht blockieren.
await SendAsync("stopped", "Dienst planmäßig beendet", CancellationToken.None, timeout: TimeSpan.FromSeconds(3))
.ConfigureAwait(false);
Info.Status = WorkerStatus.Stopped;
Info.Info = "Gestoppt";
}
public async Task TriggerAsync()
{
if (_wake.CurrentCount == 0) _wake.Release();
await Task.CompletedTask;
}
// ─── Loop ─────────────────────────────────────────────────────────────────
private async Task RunLoopAsync(CancellationToken ct)
{
_logger.Info(Module, $"{Name}: Heartbeat gestartet (Intervall {_settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds}s).");
while (!ct.IsCancellationRequested)
{
await RunOnceAsync(ct).ConfigureAwait(false);
await RevalidateLicenseIfDueAsync(ct).ConfigureAwait(false);
var interval = TimeSpan.FromSeconds(Math.Max(5, _settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds));
Info.NextRuntime = DateTime.Now.Add(interval);
try { await _wake.WaitAsync(interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
private async Task RunOnceAsync(CancellationToken ct)
{
Info.Status = WorkerStatus.Running;
var (checks, metrics, allOk) = await GatherStateAsync(ct).ConfigureAwait(false);
var status = allOk ? "ok" : "warning";
var message = allOk ? "Betrieb normal" : "Eine oder mehrere Prüfungen auffällig";
var sent = await SendAsync(status, message, ct, checks: checks, metrics: metrics).ConfigureAwait(false);
Info.LastRuntime = DateTime.Now;
Info.Status = WorkerStatus.Idle;
Info.Info = sent ? $"{status} {Info.LastRuntime:HH:mm:ss}" : $"Melden fehlgeschlagen {Info.LastRuntime:HH:mm:ss}";
}
/// <summary>
/// Trittbrett für die Lizenz-Revalidierung (alle 12h) - reitet auf dem ohnehin laufenden
/// Heartbeat-Takt statt einen eigenen Timer zu betreiben.
/// </summary>
private async Task RevalidateLicenseIfDueAsync(CancellationToken ct)
{
if (DateTime.UtcNow - _lastLicenseRevalidationUtc < LicenseRevalidationInterval) return;
_lastLicenseRevalidationUtc = DateTime.UtcNow;
try
{
await _licenseGuard.RevalidateAsync(ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.Warn(Module, $"Lizenz-Revalidierung fehlgeschlagen: {ex.Message}");
}
}
// ─── Zustand ermitteln ────────────────────────────────────────────────────
/// <summary>
/// Ein Heartbeat beweist nur, dass ein Thread läuft - nicht, dass die Anwendung ihre Arbeit
/// tut. Jede Prüfung ist einzeln abgesichert: eine fehlschlagende Prüfung darf die übrigen
/// (und vor allem den Heartbeat selbst) nicht verhindern.
/// </summary>
private async Task<(Dictionary<string, object> checks, Dictionary<string, object> metrics, bool allOk)> GatherStateAsync(CancellationToken ct)
{
var checks = new Dictionary<string, object>();
var metrics = new Dictionary<string, object>();
var allOk = true;
// db
try
{
using var dbCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
dbCts.CancelAfter(TimeSpan.FromSeconds(5));
await using var db = await _dbf.CreateDbContextAsync(dbCts.Token).ConfigureAwait(false);
await db.Database.ExecuteSqlRawAsync("SELECT 1", dbCts.Token).ConfigureAwait(false);
checks["db"] = new { ok = true };
}
catch (Exception ex)
{
checks["db"] = new { ok = false, message = Shorten(ex.Message) };
allOk = false;
}
// ibkr_gateway - NullBroker (UseTwsApi = false) ist ein gewollter Zustand, kein Fehler.
try
{
var useTws = _settings.Settings.IBKR.UseTwsApi;
var gatewayOk = !useTws || _gateway.IsConnected;
checks["ibkr_gateway"] = useTws
? new { ok = gatewayOk, message = gatewayOk ? "verbunden" : "nicht verbunden" }
: new { ok = true, message = "TWS-Broker deaktiviert (NullBroker)" };
if (!gatewayOk) allOk = false;
}
catch (Exception ex)
{
checks["ibkr_gateway"] = new { ok = false, message = Shorten(ex.Message) };
allOk = false;
}
// worker_engine - kein Worker im Fehlerzustand
try
{
var infos = _workerEngine?.WorkerInfos;
var failing = infos?.Where(w => w.Status == WorkerStatus.Error).Select(w => w.WorkerName).ToList() ?? [];
checks["worker_engine"] = new { ok = failing.Count == 0, message = failing.Count == 0 ? "alle Worker ok" : string.Join(", ", failing) };
if (failing.Count > 0) allOk = false;
metrics["workers_running"] = infos?.Count(w => w.Status is WorkerStatus.Idle or WorkerStatus.Running) ?? 0;
}
catch (Exception ex)
{
checks["worker_engine"] = new { ok = false, message = Shorten(ex.Message) };
allOk = false;
}
// Metriken - bewusst kein Check auf "Trading aktiv": ein abgeschalteter Handel ist ein
// gewollter Betriebszustand und würde den Monitor sonst dauerhaft auf "warning" halten.
metrics["uptime_sec"] = (long)(DateTime.UtcNow - _startedAtUtc).TotalSeconds;
metrics["trading_enabled"] = _settings.Settings.Trading.TradingEnabled ? 1 : 0;
metrics["memory_mb"] = GC.GetTotalMemory(false) / 1024 / 1024;
try
{
var todayStartUtc = DateTime.UtcNow.Date;
metrics["orders_today"] = _orderEvents.Query(e => e.Timestamp >= todayStartUtc, limit: 5000).Count;
}
catch { /* best effort - Metrik darf den Heartbeat nicht verhindern */ }
try
{
var open = 0;
foreach (var module in _modules)
open += (await _portfolio.GetPositionsAsync(module.Name, ct).ConfigureAwait(false)).Count;
metrics["open_positions"] = open;
}
catch { /* best effort */ }
return (checks, metrics, allOk);
}
// ─── Senden ───────────────────────────────────────────────────────────────
private async Task<bool> SendAsync(string status, string message, CancellationToken ct,
Dictionary<string, object>? checks = null, Dictionary<string, object>? metrics = null,
TimeSpan? timeout = null)
{
var token = _settings.Settings.Deploymentcenter.ApiToken;
if (string.IsNullOrWhiteSpace(token)) return false;
var payload = new
{
source = string.IsNullOrWhiteSpace(_settings.Settings.Deploymentcenter.MonitorSource)
? Environment.MachineName
: _settings.Settings.Deploymentcenter.MonitorSource,
instance = _settings.Settings.Deploymentcenter.MonitorInstance,
status,
interval = _settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds,
message,
group = DcConfig.MonitorGroup,
os = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
version = DcConfig.AppVersion,
checks,
metrics
};
using var client = new DcApiClient(token, timeout ?? TimeSpan.FromSeconds(10));
try
{
await client.PostJsonAsync("/api/watchdog/v1/ping", payload, ct).ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
// Ein nicht erreichbarer Monitoring-Server darf die Anwendung niemals stoppen.
_logger.Warn(Module, $"{Name}: Heartbeat fehlgeschlagen: {ex.Message}");
return false;
}
}
private static string Shorten(string s) => s.Length <= 200 ? s : s[..200] + "…";
}
@@ -0,0 +1,80 @@
using Deploymentcenter.Client;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Hosting.Deploymentcenter;
/// <summary>
/// Fragt den UpdateService ab (GET /api/updateservice/v1/check) und übergibt bei Bedarf an den
/// eigenständigen <c>update-agent</c>. Die Prüfung selbst liest nur Metadaten - installiert wird
/// nichts von selbst. Ein Trading-Dienst, der sich unangekündigt neu startet, ist keine akzeptable
/// Vorgabe.
///
/// <para><b>Seit 2.4 mit Zugangsdaten</b> - die Release-Ablage liegt hinter HTTP-Basic-Auth, der
/// Zugang hängt am Lizenzschlüssel (siehe LicenseGuard). Ohne ihn liefert der Server 401 statt
/// Updates.</para>
/// </summary>
public static class DcUpdateService
{
public static Task<UpdateCheckResult> CheckAsync(SettingsService settings, CancellationToken ct = default)
{
var dc = settings.Settings.Deploymentcenter;
var key = ResolveLicenseKey(dc);
return new UpdateClient().CheckForUpdateAsync(
baseUrl: DcConfig.BaseUrl,
projectId: DcConfig.ProductSlug,
currentVersion: DcConfig.AppVersion,
channel: string.IsNullOrWhiteSpace(dc.UpdateChannel) ? "prod" : dc.UpdateChannel,
credentials: ReleaseCredentials.FromLicenseKey(key),
cancellationToken: ct);
}
/// <summary>Pfad des Agenten neben der laufenden Anwendung, oder null, wenn er dort nicht liegt.</summary>
public static string? FindUpdateAgent() => UpdateClient.ResolveAgentPath(AppContext.BaseDirectory);
/// <summary>
/// Übergibt die Kontrolle an den Update-Agenten.
///
/// <para><b><c>exitCurrentApp</c> ist bewusst immer <c>false</c>.</b> Seit SDK 2.5.0 ist
/// <c>exitCurrentApp: true</c> ausdrücklich als harter Kill dokumentiert - laufende
/// <c>finally</c>-Blöcke, <c>IHostApplicationLifetime</c> und Destruktoren kommen dann nicht
/// mehr zum Zug. Für ein System mit potenziell offenem Auftragsbuch oder einer laufenden
/// Buchungstransaktion ist das die falsche Vorgabe. Der Aufrufer beendet die Anwendung
/// stattdessen selbst geordnet (<c>host.StopAsync(...)</c>), <b>nachdem</b> dieser Aufruf
/// <c>true</c> geliefert hat - der Agent wartet über <c>--wait-for-pid</c> ohnehin auf das
/// Prozessende.</para>
/// </summary>
public static bool LaunchAgent(SettingsService settings, LoggingService logger)
{
var dc = settings.Settings.Deploymentcenter;
var agent = FindUpdateAgent();
if (agent is null)
{
logger.Warn("Core", $"Update-Agent ({UpdateClient.AgentFileName}) liegt nicht neben der Anwendung - Update bitte manuell einspielen.");
return false;
}
var key = ResolveLicenseKey(dc);
var channel = string.IsNullOrWhiteSpace(dc.UpdateChannel) ? "prod" : dc.UpdateChannel;
logger.Info("Core", $"Starte Update-Agent {agent} (Kanal {channel}).");
return UpdateClient.LaunchUpdateAgent(
agentPath: agent,
projectId: DcConfig.ProductSlug,
channel: channel,
action: "update",
version: "latest",
currentVersion: DcConfig.AppVersion,
exitCurrentApp: false,
licenseKey: key,
// Vorgabe 60s reicht knapp fuer EF-Flush, Kestrel, DB-Backup-Worker beim geordneten
// Beenden nicht immer - 90s Luft, statt dass der Agent grundlos abbricht.
waitTimeoutSeconds: 90);
}
private static string? ResolveLicenseKey(DeploymentcenterSettings dc) =>
string.IsNullOrWhiteSpace(dc.LicenseKey)
? LicenseClient.TryGetCachedKey(DcConfig.ProductSlug)
: dc.LicenseKey;
}
@@ -0,0 +1,143 @@
using Deploymentcenter.Client;
using IBKRTrader.Core.Configuration;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Hosting.Deploymentcenter;
/// <summary>
/// Meldet <see cref="ILicensePrompt"/>-Ereignisse an <see cref="LoggingService"/> statt an die
/// Konsole - <c>ConsoleLicensePrompt</c> (die Vorgabe des SDK) passt weder für eine GUI-Anwendung
/// noch für einen Dienst ohne angehängtes Terminal. <see cref="RequestLicenseKeyAsync"/> wird nie
/// aufgerufen, solange <see cref="LicenseGuard"/> mit <c>allowPrompt: false</c> arbeitet - trotzdem
/// definiert, damit die Schnittstelle vollständig implementiert ist.
/// </summary>
internal sealed class SilentLicensePrompt : ILicensePrompt
{
private readonly LoggingService _logger;
public SilentLicensePrompt(LoggingService logger) => _logger = logger;
public Task<string?> RequestLicenseKeyAsync(string productSlug) => Task.FromResult<string?>(null);
public void ShowLicenseError(string title, string message) => _logger.Warn("Core", $"[Lizenz] {title}: {message}");
public void ShowLicenseInfo(string title, string message) => _logger.Info("Core", $"[Lizenz] {title}: {message}");
}
/// <summary>
/// Prüft die Lizenz beim Start und bei Revalidierung.
///
/// <para><b>Weicht bewusst vom Leitfaden ab.</b> Der Deploymentcenter-Leitfaden empfiehlt bei einem
/// endgültigen Urteil (<c>revoked</c>, <c>expired</c>, ...) <c>Environment.Exit(1)</c>. Für ein
/// Handelssystem mit offenen Positionen ist ein sofortiges Beenden potenziell schädlicher als der
/// unlizenzierte Weiterbetrieb. Stattdessen: <b>Sperrbetrieb</b> - <see cref="TradingSettings.TradingEnabled"/>
/// wird hart auf <c>false</c> gesetzt (das ist bereits der zentrale, von
/// <c>ExecutionService.ExecuteAsync</c> geprüfte Hauptschalter, siehe §5.2 im Integrationskonzept).
/// Keine neuen Einstiege, aber Risiko-, Exit- und Buchhaltungslogik laufen unverändert weiter.</para>
/// </summary>
public sealed class LicenseGuard
{
private readonly LoggingService _logger;
private readonly SettingsService _settings;
private readonly DcErrorReporter _errorReporter;
private LicenseClient? _client;
public LicenseGuard(LoggingService logger, SettingsService settings, DcErrorReporter errorReporter)
{
_logger = logger;
_settings = settings;
_errorReporter = errorReporter;
}
/// <summary>
/// Erstprüfung beim Start. Läuft ohne Eingabeaufforderung (<c>allowPrompt: false</c>) - weder
/// die Desktop-Shell noch der Daemon dürfen hier auf eine Konsoleneingabe warten, die nie kommt.
/// Ein eigenes Lizenzfenster für die Shell ist als Folgeschritt vorgesehen (siehe Konzept §5.2).
/// </summary>
public async Task CheckAtStartupAsync(CancellationToken ct = default)
{
// Breaking Change 2.5.1: SetStorageDirectory haengt jetzt selbst "<slug>/license" an - der
// Aufruf bekommt hier bewusst NUR AppPaths.Config, kein zusaetzliches "license"-Unterverzeichnis
// mehr (sonst entstuende ".../license/ibkrtrader/license").
LicenseConfig.SetStorageDirectory(AppPaths.Config);
LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
_client = new LicenseClient(prompt: new SilentLicensePrompt(_logger));
// Liegt noch kein Schluessel im Cache, aber einer in den Settings (Erstinbetriebnahme oder
// manuell eingetragen): einmal validieren, damit ValidateAsync ihn selbst in den
// verschluesselten Cache legt. Danach reicht TryGetCachedKey.
var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
var configuredKey = _settings.Settings.Deploymentcenter.LicenseKey;
if (string.IsNullOrWhiteSpace(cachedKey) && !string.IsNullOrWhiteSpace(configuredKey))
{
await _client.ValidateAsync(DcConfig.ProductSlug, configuredKey, DcConfig.BaseUrl, DcConfig.AppVersion, ct)
.ConfigureAwait(false);
}
var result = await _client.EnsureLicensedAsync(
DcConfig.ProductSlug, DcConfig.BaseUrl, allowPrompt: false, appVersion: DcConfig.AppVersion,
cancellationToken: ct).ConfigureAwait(false);
Apply(result);
}
/// <summary>Erneute Prüfung zur Laufzeit (Empfehlung: alle 12h, aus einem Worker heraus aufzurufen).</summary>
public async Task RevalidateAsync(CancellationToken ct = default)
{
if (_client == null) return;
var result = await _client.RevalidateAsync(DcConfig.ProductSlug, DcConfig.BaseUrl, DcConfig.AppVersion, ct)
.ConfigureAwait(false);
Apply(result);
}
/// <summary>Für die kopflosen Schalter (--license-status / --license-set-key) im Daemon.</summary>
public LicenseClient Client => _client ??= new LicenseClient(prompt: new SilentLicensePrompt(_logger));
private void Apply(LicenseValidationResult result)
{
if (result.IsValid)
{
_logger.Info("Core", $"Lizenz: {result.Status} ({(result.IsCached ? "aus Cache" : "vom Server")}).");
WarnIfGracePeriodExpiringSoon(result);
return;
}
if (result.IsTransient)
{
// Kein Urteil, nur keine Auskunft (server_unavailable, cache_expired) - weiterlaufen,
// der nächste Durchlauf prüft erneut.
_logger.Warn("Core", $"Lizenzprüfung derzeit nicht möglich: {result.Message}");
return;
}
// Ein Urteil (revoked, expired, not_found, activation_limit, suspended, clock_rollback,
// no_license): Sperrbetrieb statt Prozessende.
var trading = _settings.Settings.Trading;
if (trading.TradingEnabled)
{
trading.TradingEnabled = false;
_settings.Save();
_logger.Error("Core",
$"Lizenz ungültig ({result.Status}): {result.Message}. Sperrbetrieb aktiv - " +
"TradingEnabled auf false gesetzt, keine neuen Einstiege mehr. Bestehende Positionen " +
"und Risiko-/Exit-Logik laufen unverändert weiter.");
}
else
{
_logger.Warn("Core", $"Lizenz weiterhin ungültig ({result.Status}): {result.Message}.");
}
_errorReporter.Report(
level: "warning", module: "Core",
message: $"Lizenz ungültig ({result.Status}) - Sperrbetrieb aktiv, TradingEnabled=false.",
exception: null);
}
private void WarnIfGracePeriodExpiringSoon(LicenseValidationResult result)
{
if (result.CacheExpiresAt is not { } expiresUnix) return;
var expires = DateTimeOffset.FromUnixTimeSeconds(expiresUnix);
if (expires - DateTimeOffset.UtcNow < TimeSpan.FromHours(48))
_logger.Warn("Core", $"Offline-Gnadenfrist der Lizenz läuft in weniger als 48h ab ({expires:u}).");
}
}
@@ -22,6 +22,11 @@
<ProjectReference Include="..\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" /> <ProjectReference Include="..\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
<ProjectReference Include="..\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" /> <ProjectReference Include="..\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
<ProjectReference Include="..\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" /> <ProjectReference Include="..\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
<!-- Externes Schwester-Repo: J:\Softwareprojekte\Deploymentcenter muss neben dem IBKRTrader-Checkout
liegen (..\..\..\..\ von hier aus). Liefert Lizenz, Watchdog, UpdateService, Fehler-Stream und
Bugtracker. Interimslösung, solange kein NuGet-Paket in der Gitea-Registry liegt - siehe
docs/archiv/KONZEPT-Deploymentcenter-Integration.md §2.2. -->
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -30,4 +35,15 @@
</AssemblyAttribute> </AssemblyAttribute>
</ItemGroup> </ItemGroup>
<!-- Ohne das Schwester-Repo scheitert der Build sonst mit einer Meldung ueber eine fehlende
Projektdatei, aus der niemand die eigentliche Ursache ablesen kann. -->
<Target Name="CheckDeploymentcenterCheckout" BeforeTargets="PrepareForBuild">
<Error Condition="!Exists('$(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj')"
Text="Das Schwester-Repo 'Deploymentcenter' wurde nicht gefunden. Es muss neben dem IBKRTrader-Checkout liegen (erwartet: $(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter). Klonen von http://192.168.178.10:8418/Richard/Deploymentcenter.git" />
</Target>
<!-- Erzeugt IBKRTrader.Hosting.BuildInfo (Version, Git-Commit, Build-Datum, Kanal) zur
Uebersetzungszeit aus <Version> (Directory.Build.props) und dem Git-Stand. -->
<Import Project="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
</Project> </Project>
@@ -14,7 +14,7 @@ namespace IBKRTrader.Modules.Accounting;
/// Modul „Accounting": von unserer Trading-DB UNABHÄNGIGE, buchhalterisch korrekte Erfassung aller /// Modul „Accounting": von unserer Trading-DB UNABHÄNGIGE, buchhalterisch korrekte Erfassung aller
/// Kontobewegungen (IBKR-Kontoauszug → append-only Ledger) mit Periodenabrechnung/BWA, FX (USD/EUR) und /// Kontobewegungen (IBKR-Kontoauszug → append-only Ledger) mit Periodenabrechnung/BWA, FX (USD/EUR) und
/// CSV/PDF-Export. Reines Ingest-/Reporting-Modul, KEIN Handel. Konzept: /// CSV/PDF-Export. Reines Ingest-/Reporting-Modul, KEIN Handel. Konzept:
/// docs/konzepte/KONZEPT-Modul-Accounting.md. /// docs/archiv/KONZEPT-Modul-Accounting.md.
/// ///
/// Live-Abruf (IBKR Flex Query) liegt hinter Interfaces mit Null-Stubs → das Modul läuft offline und /// Live-Abruf (IBKR Flex Query) liegt hinter Interfaces mit Null-Stubs → das Modul läuft offline und
/// bucht dann korrekt nichts. Die konkrete Steuerschicht ist bewusst offen (neutraler Ledger gilt /// bucht dann korrekt nichts. Die konkrete Steuerschicht ist bewusst offen (neutraler Ledger gilt
@@ -1,4 +1,3 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
using IBKRTrader.Modules.CongressTrading.Models; using IBKRTrader.Modules.CongressTrading.Models;
using IBKRTrader.Modules.CongressTrading.Persistence.Ef; using IBKRTrader.Modules.CongressTrading.Persistence.Ef;
@@ -14,16 +13,13 @@ public class CongressRepository
{ {
private readonly IDbContextFactory<CongressTradingDbContext> _dbf; private readonly IDbContextFactory<CongressTradingDbContext> _dbf;
private readonly CoreSettingsService _settings; private readonly CoreSettingsService _settings;
private readonly LoggingService _logger;
public CongressRepository( public CongressRepository(
IDbContextFactory<CongressTradingDbContext> dbf, IDbContextFactory<CongressTradingDbContext> dbf,
CoreSettingsService settings, CoreSettingsService settings)
LoggingService logger)
{ {
_dbf = dbf; _dbf = dbf;
_settings = settings; _settings = settings;
_logger = logger;
} }
// ─── CongressMember ─────────────────────────────────────────────────────── // ─── CongressMember ───────────────────────────────────────────────────────
@@ -71,13 +67,6 @@ public class CongressRepository
return await db.Trades.AnyAsync(t => t.TradeId == tradeId); return await db.Trades.AnyAsync(t => t.TradeId == tradeId);
} }
public async Task<HashSet<string>> GetAllTradeIdsAsync()
{
await using var db = await _dbf.CreateDbContextAsync();
var ids = await db.Trades.Select(t => t.TradeId).ToListAsync();
return ids.ToHashSet();
}
/// <summary>Fügt einen Trade ein, sofern die TradeId noch nicht existiert.</summary> /// <summary>Fügt einen Trade ein, sofern die TradeId noch nicht existiert.</summary>
public async Task InsertTradeAsync(CongressTrade trade, bool detailsFetched = true) public async Task InsertTradeAsync(CongressTrade trade, bool detailsFetched = true)
{ {
@@ -133,19 +122,4 @@ public class CongressRepository
public Task<string?> GetSettingAsync(string key) => _settings.GetAsync(key); public Task<string?> GetSettingAsync(string key) => _settings.GetAsync(key);
public Task SetSettingAsync(string key, string v) => _settings.SetAsync(key, v); public Task SetSettingAsync(string key, string v) => _settings.SetAsync(key, v);
public Task DeleteSettingAsync(string key) => _settings.DeleteAsync(key); public Task DeleteSettingAsync(string key) => _settings.DeleteAsync(key);
// ─── Reset ────────────────────────────────────────────────────────────────
/// <summary>Löscht alle CT-Fortschritts-Flags und alle CT-Daten aus der DB.</summary>
public async Task ResetHistoryImportAsync()
{
await DeleteSettingAsync("ct.history_import_done");
await DeleteSettingAsync("ct.history_import_page");
await DeleteSettingAsync("ct.history_import_total");
await using var db = await _dbf.CreateDbContextAsync();
await db.Trades.ExecuteDeleteAsync();
await db.Members.ExecuteDeleteAsync();
_logger.Warn("CT", "History-Import-Reset durchgeführt alle CT-Daten gelöscht.");
}
} }
@@ -9,20 +9,6 @@ namespace IBKRTrader.Modules.CongressTrading.Scraper;
// ─── JSON-Modelle (exakte Feldnamen aus dem RSC-Stream) ────────────────────── // ─── JSON-Modelle (exakte Feldnamen aus dem RSC-Stream) ──────────────────────
internal class CtApiWrapper
{
[JsonPropertyName("data")] public List<CtTrade>? Data { get; set; }
[JsonPropertyName("meta")] public CtMeta? Meta { get; set; }
}
internal class CtMeta
{
[JsonPropertyName("totalPages")] public int TotalPages { get; set; }
[JsonPropertyName("pageSize")] public int PageSize { get; set; }
[JsonPropertyName("totalCount")] public int TotalCount { get; set; }
[JsonPropertyName("page")] public int Page { get; set; }
}
internal class CtTrade internal class CtTrade
{ {
[JsonPropertyName("_txId")] public long TxId { get; set; } [JsonPropertyName("_txId")] public long TxId { get; set; }
@@ -18,7 +18,7 @@ namespace IBKRTrader.Modules.Supervisor;
/// Supervisor-Modul: KI-gestützte Analyse/Forensik über ALLE Module — strikt read-only (kein Handel). /// Supervisor-Modul: KI-gestützte Analyse/Forensik über ALLE Module — strikt read-only (kein Handel).
/// Dossier-Browser über Entscheidungsjournal/Order-Events/Trade-Log/JSONL-Logs, OpenRouter-Agent mit /// Dossier-Browser über Entscheidungsjournal/Order-Events/Trade-Log/JSONL-Logs, OpenRouter-Agent mit
/// read-only Tool-Registry, optional Counterfactual-Auswertung, Tagesbericht und MCP-Light. Konzept: /// read-only Tool-Registry, optional Counterfactual-Auswertung, Tagesbericht und MCP-Light. Konzept:
/// docs/konzepte/KONZEPT-Modul-Supervisor.md. /// docs/archiv/KONZEPT-Modul-Supervisor.md.
/// </summary> /// </summary>
public sealed class SupervisorModule : IModule public sealed class SupervisorModule : IModule
{ {
@@ -26,7 +26,7 @@ public class CongressRepositoryTests
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
var settings = new CoreSettingsService(new Factory<CoreDbContext>(coreOpts)); var settings = new CoreSettingsService(new Factory<CoreDbContext>(coreOpts));
return new CongressRepository(new Factory<CongressTradingDbContext>(ctOpts), settings, new LoggingService()); return new CongressRepository(new Factory<CongressTradingDbContext>(ctOpts), settings);
} }
[Fact] [Fact]
@@ -165,6 +165,71 @@ public class IbkrMappingTests
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
// ─── Herkunft der Zeitangabe (Beobachtbarkeit) ────────────────────────────
//
// Ohne die Herkunft lässt sich später nicht mehr unterscheiden, ob ein Buchungszeitpunkt von
// TWS gemeldet oder von uns angenommen wurde. Genau daran hängt die Diagnose, wenn eine
// Instanz von einem EU- auf einen US-Host umzieht.
[Fact]
public void ResolveExecutionTime_MitZone_MeldetGemeldeteHerkunft()
{
var stamp = IbkrMapping.ResolveExecutionTime("20260804 09:30:00 America/New_York", Berlin);
stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.ReportedZone);
stamp.IsAssumed.Should().BeFalse();
stamp.ReportedZone.Should().Be("America/New_York");
stamp.ZoneUsed.Should().Contain("New_York");
stamp.Utc.Should().Be(new DateTime(2026, 8, 4, 13, 30, 0, DateTimeKind.Utc));
}
[Fact]
public void ResolveExecutionTime_OhneZone_MeldetAnnahme()
{
// Der Normalfall bei execDetails - und damit die Stelle, die beobachtet werden muss.
var stamp = IbkrMapping.ResolveExecutionTime("20260804 17:39:18", Berlin);
stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.FallbackZone);
stamp.IsAssumed.Should().BeTrue();
stamp.ReportedZone.Should().BeNull();
stamp.ZoneUsed.Should().Be(Berlin.Id);
}
[Fact]
public void ResolveExecutionTime_UnbekannteZone_IstAlsAnnahmeErkennbar()
{
// Weicht auf die Betriebszeitzone aus - aber unterscheidbar von "TWS meldete nichts",
// denn hier hat TWS etwas gemeldet, das dieses System nicht kennt.
var stamp = IbkrMapping.ResolveExecutionTime("20260804 17:39:18 Gibt/EsNicht", Berlin);
stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.UnknownZoneFallback);
stamp.IsAssumed.Should().BeTrue();
stamp.ReportedZone.Should().Be("Gibt/EsNicht");
stamp.ZoneUsed.Should().Be(Berlin.Id);
}
[Fact]
public void ResolveExecutionTime_Unlesbar_IstAlsDefektErkennbar()
{
var stamp = IbkrMapping.ResolveExecutionTime("Unsinn", Berlin);
stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.Unparsable);
stamp.Utc.Should().BeNull();
// Unlesbar ist keine Annahme, sondern ein Defekt - die Warnung im Log ist eine andere.
stamp.IsAssumed.Should().BeFalse();
}
[Fact]
public void ResolveExecutionTime_UsHost_LiefertDenselbenZeitpunktWieEuHost()
{
// Der eigentliche Regressionsschutz fuer den geplanten Umzug: Meldet TWS die Zone, darf die
// Betriebszeitzone der Instanz das Ergebnis NICHT mehr veraendern.
const string raw = "20260804 09:30:00 America/New_York";
IbkrMapping.ResolveExecutionTime(raw, Berlin).Utc
.Should().Be(IbkrMapping.ResolveExecutionTime(raw, NewYork).Utc);
}
[Fact] [Fact]
public void ParseExecutionTime_OhneZone_RechnetGegenDieBetriebszeitzone() public void ParseExecutionTime_OhneZone_RechnetGegenDieBetriebszeitzone()
{ {