Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f66183f1c | ||
|
|
e1546bd1b1 | ||
|
|
0a72ea7e28 | ||
|
|
87194bfc48 |
@@ -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/
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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.*" />
|
||||||
|
|||||||
@@ -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 (R1–R7) 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: R1–R7 (Kurskorrektur auf das PolytraderSharp-Konzept), Accounting und Supervisor inkl.
|
||||||
|
Core-Datenfundament S-0, Linux-Portierung L0–L6, 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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
### L0–L5 – Linux-Portierung: Avalonia statt WinForms ✅ (2026-08-07)
|
### L0–L5 – 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 0–8 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 80–83 (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,15–0,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 |
|
||||||
|
|---|---|---|
|
||||||
|
| **R1–R7** | Kurskorrektur auf das PolytraderSharp-Konzept: Multi-Projekt, Generic Host, EF Core, Trading-Kern, CongressTrading-Strategie, Security, Dashboard, TWS-Broker-Adapter | 2026-07 |
|
||||||
|
| **S-0–S-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 |
|
||||||
|
| **L0–L6** | Linux-Portierung: Avalonia statt WinForms, Betriebszeitzone, kopfloser Daemon, geteiltes Hosting, WinForms vollständig entfernt, Namensgebung bereinigt | 2026-08-07 |
|
||||||
|
| **DC 0–8** | 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 P1–P6 und D1–D7. |
|
||||||
|
| [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).
|
||||||
@@ -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 3–5 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 P1–P6 und D1–D7 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:** P1–P4 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 (D1–D7) 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 (P1–P6) 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/`) | 1–7 | ✅ 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 (L0–L6). 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 (L0–L5).** Dieses Dokument ist die Analyse, die der Portierung
|
> **UMGESETZT am 2026-08-07 (L0–L5).** 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.**
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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."
|
||||||
@@ -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": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})";
|
||||||
|
|||||||
@@ -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 <Version> 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()
|
||||||
{
|
{
|
||||||
|
|||||||