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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:44:41 +02:00
RichardandClaude Opus 5 c176b05ea1 L6: IBKRTrader.App.Avalonia -> IBKRTrader.App; letzte WinForms-Spuren raus
Build & Test / build (ubuntu-latest) (push) Waiting to run
Build & Test / build (windows-latest) (push) Waiting to run
Das Suffix ".Avalonia" gab es nur, weil daneben ein WinForms-IBKRTrader.App
stand. Das ist seit L5 weg, also faellt auch das Suffix. Git erkennt alle
Dateien als Umbenennung; Assembly, Wurzel-Namensraum und die
avares://-Ressourcen-URI sind mitgezogen.

Nebeneffekt der Umbenennung: die global::Avalonia-Qualifizierungen entfallen.
Sie waren noetig, weil der Namensraum IBKRTrader.App.Avalonia das
Avalonia-Paket verdeckt hat - ein Ueberbleibsel genau der Namensgebung, die
jetzt weg ist.

Inhaltlich falsch gewordene Aussagen berichtigt - das waren die eigentlichen
Ueberbleibsel, nicht die Kommentare:
- .agents/rules/grundregeln.md schrieb weiterhin "C# .NET 10 WinForms",
  RichTextBox-Logging, LauncherForm und PropertyGrid vor. Das ist die Regel,
  nach der kuenftig gearbeitet wird - sie haette die Portierung Stueck fuer
  Stueck rueckgaengig gemacht. Jetzt: Avalonia, keine Plattform-Suffixe, die
  11er-Pinnung mit Begruendung, dazu die beiden Regeln, die uns in L1b am
  meisten gekostet haben (UTC persistieren + AppTimeZone statt DateTime.Now;
  jede Formatierung mit ausdruecklichem IFormatProvider).
- Core: LogEntry ("wird in RichTextBox geschrieben"), IWorker/WorkerEngine/
  WorkerInfo ("DataGridView-Zeile"/"-Binding"), ModuleView ("die
  WinForms-Shell castet auf Form").
- Doku: ARCHITECTURE (Modul-Ui-Ordner, "designbare Forms mit Initialize"),
  KONZEPT-Modul-Accounting ("UI (WinForms, ein Fenster mit Tabs)").

BEWUSST STEHEN GEBLIEBEN sind die Kommentare, die WinForms nur als
Begruendung nennen - warum LoggingService ein Ereignis hat statt einer
RichTextBox, warum ModuleView Func<object> liefert, warum es benannte
Record-Zeilentypen gibt, warum die Einstellungsmaske aus Attributen entsteht.
Das ist die Herleitung des heutigen Entwurfs; ohne sie sieht spaeter jede
dieser Stellen nach Umstaendlichkeit ohne Grund aus.

KONZEPT-Linux-Portierung.md bekommt einen Statusvermerk: umgesetzt, die
Pfadangaben im Fundstellenverzeichnis beziehen sich auf den alten Aufbau.
Zwei Abweichungen von der Schaetzung sind dort festgehalten - der geringere
Aufwand dank der PolytraderSharp-Vorlage, und dass die dort empfohlene
InvariantGlobalization ein Fehler gewesen waere.

Verifiziert: Build 0 Fehler/0 Warnungen, 193 Tests gruen, Smoke-UI
konstruiert alle 7 Ansichten + Launcher + Dialog, Daemon-Prueflauf OK,
publish -r linux-x64 fuer beide Einstiegspunkte fehlerfrei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 21:16:48 +02:00
RichardandClaude Opus 5 d86a083438 L5: WinForms vollstaendig entfernt - das Projekt ist plattformneutral
Die Avalonia-Shell hatte in L4 Funktionsgleichheit erreicht, damit ist die
WinForms-Fassung ueberfluessig. Entfernt: IBKRTrader.App.csproj, Program.cs,
LauncherForm.cs, UI/ (14 Dateien) und Properties/Resources.* - zusammen rund
1.900 Zeilen. Rueckfallpunkt bleibt der Tag winforms-final.

Resources/ mit den 24 PNG-Symbolen bleibt: die Avalonia-Shell bindet sie
ueber AvaloniaResource ein und nutzt dieselben Symbolschluessel.

Kein Projekt traegt mehr eine Plattformbindung. Verifiziert: alle acht
Projekte bauen einzeln fuer linux-x64, und die gesamte Projektmappe baut
ohne Fehler und ohne Warnung.

Die CI kann damit vereinfacht werden: der Behelf aus L4 - unter Linux die
Projekte einzeln statt der Mappe zu bauen, weil IBKRTrader.App dort nicht
baubar war - entfaellt. Beide Plattformen bauen und testen jetzt dieselbe
Mappe.

Doku nachgezogen: README (Architektur-Uebersicht, Start- und Pruefbefehle),
ARCHITECTURE (Ziel-Architektur auf den Ist-Stand, neuer Abschnitt L0-L5 in
der Phasen-Checkliste, AppTimeZone ergaenzt). Der Hinweis "UI-Contract liegt
im Core" war nach L1a falsch - WindowMenu ist dort raus, der Contract ist
jetzt toolkit-neutral.

Verifiziert: Build 0 Fehler/0 Warnungen, 193 Tests gruen, Smoke-UI
konstruiert alle 7 Ansichten + Launcher + Dialog, Daemon-Prueflauf OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:35:01 +02:00
RichardandClaude Opus 5 3513a0b2d9 L4: Modul-Fenster auf Avalonia - Funktionsgleichheit mit der WinForms-Shell
Die drei Modul-Fenster sind portiert; die Avalonia-Shell zeigt jetzt
dieselben sieben Ansichten wie die WinForms-Shell.

- CongressTradingWindow: Kennzahlen als Kacheln, manueller Scrape-Ausloeser,
  offene Positionen des Moduls.
- SupervisorWindow: Analyse mit Live-Anzeige der Tool-Aufrufe, Dossier-
  Browser mit Splitter, Berichte, Hinweise. Der Hinweistext nennt jetzt die
  Betriebszeitzone, weil die Tagesbericht-Stunde in ihr gilt und nicht in
  der Zeitzone des Rechners.
- AccountingWindow: gemeinsame Filterleiste, Uebersicht/BWA, Ledger, Steuer
  (Platzhalter), Export und Abruf/Status.

Ersetzte WinForms-Bausteine:
- SaveFileDialog -> IStorageProvider.SaveFilePickerAsync (asynchron, anderer
  Zuschnitt). Betrifft die drei Exporte des Accounting-Moduls.
- MessageBox bei Fehlern -> Statuszeile im Fenster. Ein modaler Dialog fuer
  einen fehlgeschlagenen Ladevorgang war ohnehin zu laut.
- Anonyme Typen an DataGridView.DataSource -> benannte Records. Kompilierte
  Bindings pruefen die Spalten dadurch beim Kompilieren; ein Tippfehler in
  einer Spaltendefinition (Binding="{Binding Profile}}") ist beim Build
  aufgefallen statt im Betrieb.

Accounting-Betraege werden beim Laden gegen InvariantCulture formatiert, das
Delta eines Ingest-Laufs ist nullable behandelt (ohne Saldo-Anker gibt es
keins) - vorher waere dort eine NullReferenceException moeglich gewesen.

CI-Matrix (Gitea Actions, Actions-kompatibel): ubuntu-latest und
windows-latest. Sie ist der Waechter gegen Rueckfaelle - ein neues
DateTime.Now, ein ToString ohne Formatanbieter oder eine WinForms-Referenz
im Core faellt auf dem Entwicklungsrechner nicht auf, hier schon. Der
Linux-Lauf baut bewusst NICHT die ganze Mappe, weil IBKRTrader.App
(net10.0-windows) dort nicht baubar ist; die Aufzaehlung entfaellt, sobald
die WinForms-Shell aus dem Build genommen wird.

Der Smoke-UI-Lauf ist Teil der CI - mit Avalonia braucht er kein
Anzeigegeraet mehr (SetupWithoutStarting). Mit WinForms war das auf einem
Build-Server nicht moeglich.

Verifiziert: Build 0 Fehler/0 Warnungen, 193 Tests gruen, Smoke-UI
konstruiert alle 7 Ansichten + Launcher + Dialog, publish -r linux-x64
liefert 32 MB mit ELF-Launcher. Die WinForms-Shell ist unveraendert
lauffaehig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:25:54 +02:00
RichardandClaude Opus 5 afae3c9c4a L3: Avalonia-Shell und Core-Ansichten - die Oberflaeche laeuft auf Linux
Neues Projekt IBKRTrader.App.Avalonia (net10.0, plattformneutral) mit
Launcher, Fenster-Menue, Beenden-Abfrage und den vier Core-Ansichten
Dashboard, Workers, Logs und Settings. Nutzt denselben AppHostBuilder wie
der Daemon; ShellServices ergaenzt nur, was ohne Oberflaeche nicht existiert.

Versionen bewusst auf der 11er-Linie: Avalonia 11.3.19, DataGrid 11.3.13.
LiveCharts2 2.0.5 ist gegen Avalonia 11 gebaut und bricht unter 12
(Gestures.PinchEvent gibt es dort nicht mehr). Diagramme kommen mit den
neuen Modulen - bis LiveCharts2 Avalonia 12 unterstuetzt, darf hier nicht
angehoben werden. Erfahrung aus PolytraderSharp, im csproj vermerkt.

Ersatz fuer WinForms-Bausteine ohne Gegenstueck:
- PropertyGrid -> SettingsModelBuilder erzeugt die Maske aus den bereits
  vorhandenen Category-/DisplayName-/Description-Attributen von AppSettings.
  Eine neue Einstellung erscheint damit automatisch, ohne dass jemand die
  Oberflaeche anfasst - genau der Vorteil des PropertyGrid. Kennwortfelder
  (DB-Passwort, Flex-Token) werden verdeckt. Gemessen: 11 Abschnitte,
  41 Felder.
- MessageBox.Show -> ShutdownConfirmWindow (Avalonia bringt keinen
  Meldungsdialog mit). Schliessen ueber das X zaehlt als Abbruch, damit ein
  versehentlicher Klick nie den Handelsbetrieb stoppt.
- RichTextBox mit SelectionColor -> eingefaerbte Elemente je Logzeile, mit
  Filter, Auto-Scroll und Zeilenbegrenzung (im Dauerbetrieb waere die Liste
  sonst unbegrenzt gewachsen).
- ToolStrip/StatusStrip -> zentrale Stilklassen in App.axaml (toolbar,
  statusbar, kpi, section). Die neuen Module setzen darauf auf.

Gemeinsame Bausteine bewusst jetzt schon zentral, weil die neuen Module
direkt in Avalonia entwickelt werden sollen.

Kompilierte Bindings sind aktiv (x:DataType je Datenkontext) - Tippfehler in
Bindings fallen damit beim Kompilieren auf statt erst zur Laufzeit. Dafuer
brauchte es benannte Record-Zeilentypen statt der anonymen Typen, die
DataGridView.DataSource frueher bekommen hat.

--smoke-ui laeuft jetzt ueber SetupWithoutStarting, also OHNE Anzeigegeraet
und ohne laufende Dienste. Das war mit WinForms nicht moeglich und macht die
Konstruktionspruefung erstmals CI-tauglich. Der Host wird dabei bewusst nicht
gestartet, sonst liefen Worker und Broker-Verbindungen gegen die echten
Endpunkte an.

AVLN3001 unterdrueckt: die Fenster haben absichtlich keinen parameterlosen
Konstruktor. Einer wuerde sie ohne ihre Dienste konstruierbar machen und
genau den Fehler verdecken, den die Konstruktionspruefung finden soll.

Verifiziert: Build 0 Fehler/0 Warnungen, 193 Tests gruen, Avalonia-Smoke-UI
konstruiert alle 6 Fenster, publish -r linux-x64 liefert 32 MB mit
ELF-Launcher samt libSkiaSharp.so/libHarfBuzzSharp.so und ohne
Windows-Abhaengigkeiten. Die WinForms-Shell ist unveraendert lauffaehig.

Offen fuer L4: die drei Modul-Fenster (ModuleViews ist noch leer).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:18:44 +02:00
RichardandClaude Opus 5 9c7e19149c L2: Kopfloser Linux-Dienst - der Trading-Kern laeuft ohne Oberflaeche
Meilenstein: Trading, Worker, Accounting, Supervisor, REST und MCP laufen
als systemd-Dienst auf Linux. Nur noch IBKRTrader.App ist Windows-gebunden.

Neu IBKRTrader.Hosting: die komplette Host-Zusammenstellung, aus der
WinForms-Program.cs herausgezogen. Bewusst ein eigenes Projekt - laege sie
weiter in der Shell, muesste sie fuer den Daemon dupliziert werden, und
beide wuerden auseinanderlaufen. Ein Modul, das nur in einer Variante
registriert ist, faellt sonst erst im Betrieb auf. RunStartupChecks buendelt
die Startschritte in fester Reihenfolge (Zeitzone vor dem ersten Logeintrag,
Master-Key vor jeder Entschluesselung).

Neu IBKRTrader.Daemon: kopfloser Einstiegspunkt mit SIGTERM-Behandlung,
--check (Trockenlauf: Host bauen, Startpruefungen, keine Boersenverbindung -
fuer Deployment und CI) und --db-version.

AppPaths: Logs, Backups, settings.json und master.key lagen neben der
Binaerdatei. Unter /opt hat der Dienstbenutzer dort keinen Schreibzugriff -
der Dienst waere beim ersten Logeintrag gescheitert. Aufloesung jetzt:
Umgebungsvariable, sonst Binaerverzeichnis WENN beschreibbar (haelt Windows
und die Entwicklung unveraendert), sonst FHS. Der Schreibtest ist ein echter
Schreibversuch - Attributpruefung traegt nicht, weil unter Linux
Besitzer/Gruppe/Modus und unter Windows die ACL entscheiden.

Rechtepruefung fuer master.key/openrouter.key auf Unix: Windows-ACLs
uebertragen sich beim Kopieren auf einen Linux-Host nicht, eine key-Datei mit
644 waere dort fuer jeden lokalen Nutzer lesbar. Nur eine Warnung.

InvariantGlobalization ausdruecklich auf false. Der naheliegende Griff zum
schlanken Image waere hier ein Eigentor gewesen und haette genau die zwei
Punkte aus L1b lautlos rueckgaengig gemacht: ohne ICU faellt die Aufloesung
von Windows-Zeitzonen-IDs aus (Rueckfall auf UTC im Container) und
CultureInfo.GetCultureInfo("de-DE") liefert die invariante Kultur, womit die
PDF-Betraege wieder das Format wechseln.

systemd: Unit mit Type=notify plus UseSystemd() im Host - ohne sd_notify
haette systemctl start bis zum Timeout gehangen. Unit mit Absicherung
(ProtectSystem=strict, ReadWritePaths), deploy/README.md mit Voraussetzungen,
Verzeichnissen, Zeitzonen-Festlegung und dem, was der Daemon NICHT loest
(IB Gateway headless bleibt eine eigene Baustelle).

Verifiziert: 193 Tests gruen (+5), Build 0 Fehler/0 Warnungen, --smoke-ui
konstruiert alle 7 Fenster, --check laeuft durch, und publish -r linux-x64
liefert 11 MB mit ELF-Launcher, ohne eine einzige Windows-Abhaengigkeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:09:33 +02:00
RichardandClaude Opus 5 8b9b993d1d L1b/2: Kultur und Dateisystem plattformunabhaengig machen
Kultur - Ausgaben und Parsen haengen nicht mehr am Host:

- PdfExporter formatierte Betraege mit ToString("N2") ohne Formatanbieter,
  also CurrentCulture. Auf dem deutschen Desktop "1.234,56", in einem
  Container mit LANG=C "1,234.56" - dieselbe Zahl, fuer einen Leser eine
  andere. Fuer ein ausdruecklich pruefbares Dokument jetzt fest de-DE.
- CapitolTradesScraper.ParseDate nutzte DateOnly.TryParse ohne
  Formatanbieter. Das ist nicht theoretisch: gemessen wurde aus
  "2026-08-04" unter th-TH das Jahr 1483 (buddhistischer Kalender), unter
  fa-IR das Jahr 2647 (persischer Kalender), unter ar-SA schlug das Parsen
  ganz fehl. de-DE und en-US kommen mit ISO klar - genau deshalb faellt so
  etwas auf dem Entwicklungsrechner nie auf. Jetzt TryParseExact mit
  InvariantCulture; ein Formatwechsel der Quelle faellt damit auf, statt
  still ein falsches Datum zu erzeugen. Regressionstest ueber vier Kulturen.
- IBKRGatewayService baute den Query-Parameter mit .ToString().ToLower()
  (Tuerkisch-I) - jetzt fest "true"/"false".

PDF-Schriften: PDFsharp 6 loest auf Nicht-Windows-Plattformen nichts von
selbst auf, "Segoe UI" gibt es dort nicht - der Export waere zur Laufzeit
gescheitert. Neuer DocumentFontResolver: unter Windows bleibt die Plattform
zustaendig (unveraenderte Optik), auf Linux wird eine freie Systemschrift
gesucht (DejaVu/Liberation/Noto/FreeSans). Bewusst keine Schrift im Repo -
das erspart eine Lizenzfrage; fehlt sie, nennt die Fehlermeldung das zu
installierende Paket.

BackupWorker:
- Suchte "mysqldump.exe" in C:\Program Files\... und splittete PATH mit ';'.
  Auf Linux ist das Trennzeichen ':' - der gesamte PATH waere als ein
  Eintrag gelesen worden. Jetzt Path.PathSeparator, plattformabhaengige
  Suchpfade und zusaetzlich "mariadb-dump" (MariaDB hat mysqldump ab 10.5
  umbenannt).
- Das DB-Passwort stand als Kommandozeilenargument im Prozessbaum. Unter
  Linux ist /proc/<pid>/cmdline fuer jeden lokalen Nutzer lesbar - das waere
  eine neue Offenlegung gewesen, die es unter Windows so nicht gab. Jetzt
  ueber MYSQL_PWD, nur an den Kindprozess vererbt. Argumente einzeln statt
  als Zeichenkette (kein Quoting-Problem bei Pfaden mit Leerzeichen).

Verifiziert: 188 Tests gruen (+5), Build 0 Fehler/0 Warnungen, Core + 3
Module + Tests bauen fuer linux-x64, --smoke-ui konstruiert alle 7 Fenster.

Offen aus L1b und nach L2 verschoben: IAppPaths (Logs/Backups/settings.json/
master.key liegen neben der Binaerdatei; unter /opt ist das nicht schreibbar).
Gehoert zum Daemon, wo die Pfade tatsaechlich gebraucht werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:58:10 +02:00
RichardandClaude Opus 5 d8273c3a1e L1b/1: Betriebszeitzone statt Rechnerzeit - EU- und US-Instanzen sauber trennen
Wir betreiben Instanzen in zwei Regionen. Bisher hing jede Ortszeit an der
Zeitzone des Rechners (DateTime.Now, DateTimeKind.Local): derselbe Code
haette auf einem Windows-Desktop mit Europe/Berlin und in einem
Linux-Container mit UTC lautlos unterschiedliche Werte geliefert - ohne
Fehler, nur um Stunden verschoben, mitten in Buchungszeitstempeln.

AppTimeZone (Core/Time): Betriebszeitzone der Instanz, einmalig aus
Trading.ApplicationTimeZoneId gesetzt, IANA- und Windows-Schreibweise tragen
beide, unbekannter Wert weicht auf die Systemzone aus und warnt. Wird laut
Festlegung vor den ersten Trades gesetzt und danach nie gewechselt - ein
Wechsel verschoebe rueckwirkend alle Tagesgrenzen. Persistenz bleibt UTC,
damit die Daten beider Instanzen vergleichbar sind.

IbkrMapping.ParseExecutionTime verwirft die von TWS gemeldete Zeitzone nicht
mehr, sondern rechnet gegen sie nach UTC; ohne Zonenangabe gilt die
Betriebszeitzone. Das ist der Kern: eine NYSE-Ausfuehrung darf nicht mit
demselben nackten Zeitwert in die Buecher wie eine an der Eurex.
Rueckgabe ist jetzt immer Kind=Utc.

DailyReportService.NextRun -> NextRunUtc(nowUtc, hour, zone): der Bericht
laeuft zu einer festen ORTSZEIT. Sommerzeitumstellung wird behandelt - bei
der uebersprungenen Stunde weicht er aus, statt den Tag ausfallen zu lassen.

LoggingService fuehrt Anzeigezeit und UTC getrennt: Dateinamen und Anzeige in
Ortszeit (Tagesgrenzen gehoeren zur Instanz, der Supervisor liest die
JSONL-Dateien ueber diese Namen), das ts-Feld im JSONL in UTC. Beides musste
getrennt werden, weil die umgerechnete Ortszeit Kind=Unspecified traegt und
ein ToUniversalTime() darauf sie als Zeit des HOSTS gedeutet haette.

Verbleibende DateTime.Now in Worker-Zeitplaenen und Statuszeilen ebenfalls
auf AppTimeZone.Now umgestellt.

Verifiziert: 183 Tests gruen (+20), darunter EU/US-Versatz, Winter-/Sommerzeit,
unbekannte Zone und die uebersprungene Stunde bei der Zeitumstellung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:52:23 +02:00
RichardandClaude Opus 5 123f38ab6f L1a: Core und Module von WinForms entkoppeln - net10.0 statt net10.0-windows
Core, alle drei Module und das Testprojekt tragen keinen UI-Code mehr und
bauen fuer linux-x64. Nur noch IBKRTrader.App ist Windows-gebunden.

UI-Contract toolkit-neutral (Vorbild: PolytraderSharp):
- ModuleView.CreateForm (Func<Form>) -> CreateView (Func<object>)
- ModuleView.Icon (System.Drawing.Image) -> IconKey (string).
  System.Drawing.Common ist seit .NET 7 Windows-only und wirft auf Linux.
- WindowMenu.cs war reine WinForms-Umsetzung -> in die Shell verschoben.

LoggingService haelt keine RichTextBox mehr, sondern meldet Eintraege ueber
event EntryWritten. Einfaerbung und UI-Thread-Wechsel liegen jetzt im
LogPanelController der Shell. Nebenbei: ToUpper() -> ToUpperInvariant()
(tr-TR haette aus "info" ein "İNFO" gemacht) und \r\n -> Environment.NewLine.

Die drei Modul-Fenster liegen jetzt unter UI/Views/Modules/; RegisterUi der
Module ist bewusst leer, die Shell registriert sie zentral ueber
UI/ModuleViews.cs (nur fuer tatsaechlich geladene Module). ViewIcons loest
IconKey gegen die PNG-Ressourcen auf - dieselben Schluessel bekommt spaeter
die Avalonia-Shell.

UiConstructionTests entfernt: die Konstruktionspruefung deckt --smoke-ui ab,
das Testprojekt braucht dafuer keine UI-Referenz mehr. Der Test
RegisterUi_RegistersMainView haelt jetzt das Gegenteil fest - das Modul darf
nichts registrieren, sonst waere es wieder toolkit-gebunden.

Verifiziert: Build 0 Fehler/0 Warnungen, 163 Tests gruen, --smoke-ui
konstruiert alle 7 Fenster, und Core + 3 Module + Tests bauen fuer linux-x64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:38:01 +02:00
RichardandClaude Opus 5 18b1059fa9 L0: NuGet.config reparieren - Restore gegen leeren Paketordner
Drei Pakete hatten kein passendes packageSourceMapping-Muster; mit
<clear/> und Allowlist bedeutet das NU1100. Auf dem Entwicklungsrechner
unsichtbar, weil alle drei laengst im globalen Cache liegen - ein frischer
Klon (und damit jeder Linux-Host) konnte nicht wiederherstellen.

- PDFsharp*                     : gar kein Muster vorhanden
- Microsoft.EntityFrameworkCore : der Glob "…EntityFrameworkCore.*" matcht
                                  das Basispaket ohne Suffix nicht
- Microsoft.CodeAnalysis.*      : transitiv ueber EntityFrameworkCore.Design

Verifiziert: dotnet restore der Projektmappe gegen einen leeren
--packages-Ordner stellt jetzt alle sechs Projekte wieder her.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:30:32 +02:00
RichardandClaude Opus 5 27577ef1b2 @ Doku: Analyse Linux-Portierung (Avalonia + net10, Aufwand, Fundstellen)
Bestandsaufnahme gegen b96a207, kein Code geaendert. Kernbefunde:

- Der nicht-UI-Teil ist portabel: kein DllImport, keine Registry, kein WMI,
  kein DPAPI, keine Windows-Zeitzonen-IDs. WinForms-Kopplung im Core sind
  genau 3 Dateien, je Modul genau eine Form-Datei.
- Daraus die Empfehlung: erst headless (~6,5 PT), dann Avalonia (~12 PT).
  LiveCharts2 ist Feature-Neubau, kein Migrationsposten (kein Diagramm im
  Bestand).
- Kritisch: ParseExecutionTime verwirft die Zeitzone (Kind=Unspecified) und
  DailyReportService nutzt DateTimeKind.Local - auf einem UTC-Host liefert
  derselbe Code lautlos andere Werte.
- NuGet.config ist bereits heute kaputt: PDFsharp-MigraDoc,
  Microsoft.EntityFrameworkCore (Glob matcht nicht ohne Suffix) und
  Microsoft.CodeAnalysis.CSharp.Workspaces fehlen im packageSourceMapping.
  Gegen leeren Paket-Ordner verifiziert (NU1100).
- Weiter: mysqldump.exe + PATH-Split(';') im BackupWorker, "Segoe UI" und
  kulturabhaengige Betragsformatierung im PdfExporter, Schreibzugriff neben
  der Binaerdatei (FHS), Fundstellenverzeichnis mit Datei+Zeile im Anhang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:26:49 +02:00
150 changed files with 8087 additions and 2559 deletions
+26 -7
View File
@@ -17,24 +17,43 @@ Keine gegenseitigen Blockierungen alles thread-sicher und performant
Technik (fest): Technik (fest):
C# .NET 10 WinForms C# .NET 10, **Avalonia** für die Oberfläche plattformneutral (Windows und Linux).
KEIN WinForms und kein System.Drawing: beides bindet an Windows. Alle Projekte sind `net10.0`
ohne Plattform-Suffix; ein `net10.0-windows` irgendwo ist ein Fehler.
Avalonia bleibt auf der 11er-Linie (11.3.19 / DataGrid 11.3.13), bis LiveCharts2 Avalonia 12
unterstützt sonst brechen die Diagramme der kommenden Module.
MySQL Zugangsdaten NUR in settings.json (gitignored), NIE im Repo/Code/Doku hinterlegen MySQL Zugangsdaten NUR in settings.json (gitignored), NIE im Repo/Code/Doku hinterlegen
IBKR TWS/Gateway API (Paper: Port 4002, Live: Port 4001 Umschaltung über TradingSettings.Mode) IBKR TWS/Gateway API (Paper: Port 4002, Live: Port 4001 Umschaltung über TradingSettings.Mode)
Interne REST-API + lokaler Webserver (für späteres Web-UI) Interne REST-API + lokaler Webserver (für späteres Web-UI)
Settings: settings.json (Vorlage: settings.example.json) Settings: settings.json (Vorlage: settings.example.json)
Logging: RichTextBox (rtb_logs) + Dateien unter Logs\[Modul]\[Level]-dd-MM-yy.txt (Info/Warn/Error) Logging: LoggingService meldet Einträge über das Ereignis `EntryWritten` (die Oberfläche hängt sich
ein und färbt selbst) + Dateien unter Logs/[Modul]/[Level]-dd-MM-yy.txt sowie Logs/[Datum].jsonl
Zeit: Zeitstempel IMMER in UTC persistieren. Für Anzeige, Tagesgrenzen und Zeitpläne
`AppTimeZone` verwenden, NIE `DateTime.Now` oder `DateTimeKind.Local` wir betreiben Instanzen
in EU und US, die Ortszeit darf nicht am Rechner hängen.
Kultur: Jede Zahl-/Datumsformatierung und jedes Parsen braucht einen ausdrücklichen
IFormatProvider (i. d. R. InvariantCulture). Ohne ihn hängt das Ergebnis an der Kultur des Hosts.
Tests: eigenes Projekt IBKRTrader.Tests (xUnit + NSubstitute + FluentAssertions), NUR Unit-Tests, Tests: eigenes Projekt IBKRTrader.Tests (xUnit + NSubstitute + FluentAssertions), NUR Unit-Tests,
alles Externe (DB/IBKR/Scraper) gemockt. DoD jeder Phase: `dotnet test` grün + Build sauber. alles Externe (DB/IBKR/Scraper) gemockt. DoD jeder Phase: `dotnet test` grün + Build sauber.
UI-Grundmodell (Launcher-Prinzip nach Polytrader): UI-Grundmodell (Launcher-Prinzip nach Polytrader):
LauncherForm = Basis-Fenster. Enthält: Core-Status/Steuerung ("Trading aktivieren", Paper/Live), LauncherWindow = Basis-Fenster mit einer Schaltfläche je registrierter Ansicht und dem
Workers/Services (dgv_workerlist), Logs (rtb_logs), Settings (PropertyGrid) und eine Modul-Liste. gemeinsamen Fenster-Menü. Die Inhalte (Dashboard, Workers, Logs, Settings, Modul-Fenster) sind
Jedes Modul wird als EIGENSTÄNDIGES Fenster aus dem Launcher geöffnet (nicht als Tab). eigenständige Fenster, keine Tabs; je Ansicht höchstens eines, erneutes Öffnen fokussiert.
Der Launcher trackt offene Fenster (Key → Form) und fokussiert bei erneutem Öffnen. Layout deklarativ in .axaml, nicht zur Laufzeit im Code. Kompilierte Bindings sind aktiv, jeder
dgv_workerlist Spalten: Active | Type | Module | Workername | Last Runtime | Next Runtime | Run Every | Info Datenkontext braucht ein x:DataType dadurch fallen Bindungsfehler beim Kompilieren auf.
Module tragen KEINEN UI-Code: sonst müssten sie Avalonia referenzieren und wären nicht mehr
kopflos lauffähig. Ihr RegisterUi bleibt leer, die Fenster registriert die Shell zentral in
src/IBKRTrader.App/Shell/ModuleViews.cs.
Workers-Ansicht, Spalten: Aktiv | Typ | Modul | Worker | Letzter Lauf | Nächster Lauf | Intervall | Info
Type = "Worker" oder "Service" (Service = permanent laufend) Type = "Worker" oder "Service" (Service = permanent laufend)
Betriebsformen (beide aus derselben Host-Zusammenstellung, src/IBKRTrader.Hosting):
src/IBKRTrader.App mit Oberfläche; `--smoke-ui` prüft die Fenster-Konstruktion ohne Anzeigegerät
src/IBKRTrader.Daemon kopflos für Linux/systemd; `--check` fährt die Startprüfungen ohne Dienste
Core-Worker (müssen zuerst): Core-Worker (müssen zuerst):
Backup (alle 30 min) Backup (alle 30 min)
+67
View File
@@ -0,0 +1,67 @@
# Build- und Testlauf auf beiden Zielplattformen.
#
# Zweck: Die Portierung ist nur so lange etwas wert, wie sie nicht wieder zurückschleicht. Ein
# neues DateTime.Now, ein ToString("N2") ohne Formatanbieter oder eine WinForms-Referenz im Core
# fallen auf dem Windows-Entwicklungsrechner nicht auf hier schon.
#
# Gitea Actions ist Actions-kompatibel; unter GitHub läuft dieselbe Datei als .github/workflows/.
name: Build & Test
on:
push:
branches: [main, 'feat/**']
pull_request:
branches: [main]
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
# Die gesamte Projektmappe baut auf beiden Plattformen seit die WinForms-Shell
# entfernt ist, gibt es kein Projekt mehr mit Windows-Bindung.
- name: Restore
run: dotnet restore IBKRTrader.slnx
- name: Build
run: dotnet build IBKRTrader.slnx --no-restore -c Release
- name: Test
run: dotnet test IBKRTrader.slnx --no-build -c Release --logger "trx;LogFileName=test-results.trx"
# Die Konstruktionsprüfung der Oberfläche braucht mit Avalonia KEIN Anzeigegerät mehr
# (SetupWithoutStarting). Mit WinForms war das auf einem Build-Server nicht möglich.
- name: Smoke-UI (Fenster-Konstruktion)
run: dotnet run --project src/IBKRTrader.App --no-build -c Release -- --smoke-ui
# Trockenlauf des kopflosen Dienstes: Host bauen, Startprüfungen fahren, nichts starten.
- name: Daemon-Prüflauf
run: dotnet run --project src/IBKRTrader.Daemon --no-build -c Release -- --check
# Der eigentliche Portierungs-Wächter: läuft nur unter Linux und schlägt fehl, sobald ein
# Projekt wieder eine Windows-Abhängigkeit hereinzieht.
- name: Linux-Publish (Daemon + Oberfläche)
if: matrix.os == 'ubuntu-latest'
run: |
dotnet publish src/IBKRTrader.Daemon -c Release -r linux-x64 --self-contained false -o out/daemon
dotnet publish src/IBKRTrader.App -c Release -r linux-x64 --self-contained false -o out/gui
- name: Testergebnisse sichern
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.os }}
path: '**/test-results.trx'
if-no-files-found: ignore
+4
View File
@@ -47,3 +47,7 @@ Logs/
## Local Claude / agent config ## Local Claude / agent config
.claude/settings.local.json .claude/settings.local.json
## Deploymentcenter Release-Pipeline (siehe scripts/release.ps1 / release.sh)
.dc-tools/
artifacts/
+10
View File
@@ -0,0 +1,10 @@
<Project>
<!--
Zentrale Versionsangabe fuer alle Projekte der Solution. pack-and-deploy (Deploymentcenter)
liest die Version aus der Hauptassembly und bricht bei einer Abweichung zum uebergebenen
Versionsparameter ab - deshalb steht sie hier genau einmal statt in einzelnen csproj-Dateien.
-->
<PropertyGroup>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
-62
View File
@@ -1,62 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
<AssemblyName>IBKRTrader.App</AssemblyName>
<RootNamespace>IBKRTrader</RootNamespace>
</PropertyGroup>
<!-- Core-, Modul- und Testprojekte liegen unter src/ bzw. tests/ und werden separat kompiliert. -->
<ItemGroup>
<Compile Remove="src\**" />
<None Remove="src\**" />
<EmbeddedResource Remove="src\**" />
<Compile Remove="tests\**" />
<None Remove="tests\**" />
<EmbeddedResource Remove="tests\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
</ItemGroup>
<!-- Icon-Ressourcen (Button-Icons, aus PolytraderSharp übernommen). -->
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="src\IBKRTrader.Core\IBKRTrader.Core.csproj" />
<ProjectReference Include="src\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
<ProjectReference Include="src\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
<ProjectReference Include="src\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+3 -1
View File
@@ -1,6 +1,8 @@
<Solution> <Solution>
<Project Path="IBKRTrader.App.csproj" />
<Project Path="src/IBKRTrader.Core/IBKRTrader.Core.csproj" /> <Project Path="src/IBKRTrader.Core/IBKRTrader.Core.csproj" />
<Project Path="src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj" />
<Project Path="src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj" />
<Project Path="src/IBKRTrader.App/IBKRTrader.App.csproj" />
<Project Path="src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj" /> <Project Path="src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj" />
<Project Path="src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj" /> <Project Path="src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj" />
<Project Path="src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj" /> <Project Path="src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj" />
-162
View File
@@ -1,162 +0,0 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Workers;
using IBKRTrader.UI;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader;
/// <summary>
/// Launcher das Basis-Fenster (Shell). Zeigt je registrierter View einen Button, führt beim Start
/// Migrationen aus, startet Module und die WorkerEngine, und trägt das gemeinsame Fenster-Menü.
/// Die inhaltlichen Ansichten (Logs, Settings, Workers, Module) sind eigenständige Fenster.
/// </summary>
public sealed class LauncherForm : Form
{
private readonly ShellUiHost _uiHost;
private readonly IServiceProvider _services;
private readonly LoggingService _logger;
private readonly WorkerEngine _workerEngine;
private readonly IReadOnlyList<IModule> _modules;
private readonly Dictionary<string, ToolStripButton> _viewButtons = new(StringComparer.OrdinalIgnoreCase);
private readonly ToolStripStatusLabel _status = new("Start...");
public LauncherForm(ShellUiHost uiHost, IServiceProvider services)
{
_uiHost = uiHost;
_services = services;
_logger = services.GetRequiredService<LoggingService>();
_workerEngine = services.GetRequiredService<WorkerEngine>();
_modules = services.GetServices<IModule>().ToList();
Text = "IBKRTrader — Launcher";
Width = 720;
Height = 540;
StartPosition = FormStartPosition.CenterScreen;
_uiHost.SetMainWindow(this);
BuildUi();
_uiHost.OpenStateChanged += UpdateButtonStates;
}
private void BuildUi()
{
var menu = new MenuStrip { Dock = DockStyle.Top, ImageScalingSize = new Size(24, 24) };
WindowMenu.Wire(menu, _uiHost, null);
// Fenster-Buttons in einem ToolStrip (Icon über Text) wie im PolytraderSharp-Launcher.
var toolstrip = new ToolStrip
{
Dock = DockStyle.Top,
GripStyle = ToolStripGripStyle.Hidden,
ImageScalingSize = new Size(32, 32),
AutoSize = true,
Padding = new Padding(4)
};
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
var id = view.Id;
var btn = new ToolStripButton(view.Title, view.Icon)
{
DisplayStyle = ToolStripItemDisplayStyle.ImageAndText,
ImageScaling = ToolStripItemImageScaling.None,
TextImageRelation = TextImageRelation.ImageAboveText,
AutoSize = true,
Padding = new Padding(6, 2, 6, 2)
};
btn.Click += (_, _) => _uiHost.OpenView(id);
_viewButtons[id] = btn;
toolstrip.Items.Add(btn);
}
var content = new Panel { Dock = DockStyle.Fill, BackColor = SystemColors.ControlLightLight };
content.Controls.Add(new Label
{
Text = "IBKRTrader — Launcher\nFenster über die Leiste oben öffnen.",
Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleCenter,
ForeColor = SystemColors.GrayText, Font = new Font(Font.FontFamily, 11f)
});
var statusStrip = new StatusStrip();
statusStrip.Items.Add(_status);
// Dock-Stacking: zuletzt hinzugefügtes Top-Control liegt oben → Menü über ToolStrip.
Controls.Add(content);
Controls.Add(statusStrip);
Controls.Add(toolstrip);
Controls.Add(menu);
MainMenuStrip = menu;
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
_ = StartupAsync();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
// Auch das Schließen-X läuft über die Sicherheitsabfrage.
if (!_uiHost.ShutdownConfirmed)
{
e.Cancel = true;
BeginInvoke((Action)(() => _uiHost.RequestShutdown()));
return;
}
_uiHost.CloseAllViews();
try
{
foreach (var module in _modules)
module.StopAsync(default).GetAwaiter().GetResult();
}
catch { /* Shutdown darf nicht am Modul scheitern */ }
// Worker/Services stoppt der Host in Program.Main via AppHost.StopAsync() nach Application.Run.
base.OnFormClosing(e);
}
private async Task StartupAsync()
{
_logger.Info("Core", "=== IBKRTrader startet ===");
_logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}");
// Log-Level aus Settings.
var levelStr = _services.GetRequiredService<SettingsService>().Settings.Logging.Level;
if (Enum.TryParse<AppLogLevel>(levelStr, true, out var level))
_logger.SetMinLevel(level);
// Das gesamte Schema (core_ + ct_) läuft über EF-Migrationen, extern via
// `dotnet ef database update` angewendet keine Laufzeit-Migration mehr.
// Worker/Services laufen bereits (Generic Host, AppHost.Start()). Hier nur noch Modul-Start.
foreach (var module in _modules)
{
try { await module.StartAsync(default); }
catch (Exception ex) { _logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
}
_logger.Info("Core", "IBKRTrader bereit.");
SetStatus("Bereit");
UpdateButtonStates();
}
private void UpdateButtonStates()
{
if (IsDisposed) return;
if (InvokeRequired) { BeginInvoke((Action)UpdateButtonStates); return; }
foreach (var (id, btn) in _viewButtons)
btn.Checked = _uiHost.IsOpen(id);
}
private void SetStatus(string text)
{
if (IsDisposed) return;
if (InvokeRequired) { BeginInvoke((Action)(() => SetStatus(text))); return; }
_status.Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}";
}
}
+23 -5
View File
@@ -13,15 +13,34 @@
</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" />
<!-- EF Core / Pomelo (MySQL/MariaDB) --> <!-- PDF-Export im Accounting-Modul (PDFsharp/MigraDoc, MIT) -->
<package pattern="PDFsharp*" />
<!-- Avalonia-Oberfläche (MIT) samt Renderer-Unterbau. SkiaSharp/HarfBuzz bringen die
nativen Bibliotheken mit, Tmds.DBus und MicroCom sind Linux- bzw. Windows-Unterbau. -->
<package pattern="Avalonia*" />
<package pattern="SkiaSharp*" />
<package pattern="HarfBuzzSharp*" />
<package pattern="Tmds.DBus*" />
<package pattern="MicroCom*" />
<!-- Diagramme (kommen mit den neuen Modulen, s. Kommentar im Avalonia-csproj) -->
<package pattern="LiveChartsCore*" />
<!-- EF Core / Pomelo (MySQL/MariaDB).
ACHTUNG: "Microsoft.EntityFrameworkCore.*" matcht das Basispaket OHNE Suffix NICHT
deshalb steht es zusätzlich einzeln. Ohne das schlägt ein Restore gegen einen leeren
Paket-Ordner mit NU1100 fehl (auf dem Entwicklungsrechner unsichtbar, weil gecacht). -->
<package pattern="Microsoft.EntityFrameworkCore" />
<package pattern="Microsoft.EntityFrameworkCore.*" /> <package pattern="Microsoft.EntityFrameworkCore.*" />
<!-- Transitiv über EntityFrameworkCore.Design (Migrations-Codegenerierung). -->
<package pattern="Microsoft.CodeAnalysis.*" />
<package pattern="Pomelo.*" /> <package pattern="Pomelo.*" />
<package pattern="Humanizer.*" /> <package pattern="Humanizer.*" />
<package pattern="Mono.TextTemplating" /> <package pattern="Mono.TextTemplating" />
@@ -38,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.*" />
-321
View File
@@ -1,321 +0,0 @@
using IBKRTrader.Core.AI;
using IBKRTrader.Core.Budget;
using IBKRTrader.Core.Configuration;
using IBKRTrader.Core.DependencyInjection;
using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Persistence;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Security;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Trading.Ibkr;
using IBKRTrader.Core.Workers;
using IBKRTrader.Core.Workers.BuiltIn;
using IBKRTrader.Modules.Accounting;
using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.Supervisor;
using IBKRTrader.UI;
using IBKRTrader.UI.Views;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace IBKRTrader;
internal static class Program
{
public static IHost? AppHost { get; private set; }
[STAThread]
static void Main(string[] args)
{
// Headless-Smoke-Test der UI (konstruiert jede View + Launcher, ohne Message-Loop).
if (args.Length > 0 && string.Equals(args[0], "--smoke-ui", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = RunSmokeUi();
return;
}
// Diagnose: gibt die MySQL/MariaDB-Serverversion aus (für das EF-ServerVersion-Pinning).
if (args.Length > 0 && string.Equals(args[0], "--db-version", StringComparison.OrdinalIgnoreCase))
{
RunDbVersion();
return;
}
ApplicationConfiguration.Initialize();
var modules = new List<IModule> { new CongressTradingModule(), new AccountingModule(), new SupervisorModule() };
AppHost = Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
.ConfigureAppConfiguration((_, config) =>
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
.ConfigureServices((context, services) =>
{
RegisterCoreServices(services, context.Configuration);
foreach (var module in modules)
{
services.AddSingleton(module);
module.RegisterServices(services, context.Configuration);
}
services.AddSingleton<ShellUiHost>();
services.AddSingleton<IModuleUiHost>(sp => sp.GetRequiredService<ShellUiHost>());
services.AddSingleton<LauncherForm>();
})
.Build();
// Sicherheit: Master-Key laden (VOR jeder Entschlüsselung) und DB-TLS prüfen.
var startupLog = AppHost.Services.GetRequiredService<LoggingService>();
ConfigureSecretProtection(startupLog);
WarnIfDbTlsNotEnforced(AppHost.Services, startupLog);
// Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine-Referenz (vor dem Start).
AppHost.Services.GetRequiredService<WebApiService>()
.SetEngine(AppHost.Services.GetRequiredService<WorkerEngine>());
// Host starten → alle Worker/Services (IHostedService) laufen an.
AppHost.Start();
// Views registrieren (Core + Module), dann Launcher starten.
var uiHost = AppHost.Services.GetRequiredService<ShellUiHost>();
RegisterCoreViews(uiHost, AppHost.Services);
foreach (var module in modules)
module.RegisterUi(uiHost, AppHost.Services);
AssignViewIcons(uiHost);
Application.Run(AppHost.Services.GetRequiredService<LauncherForm>());
AppHost.StopAsync().GetAwaiter().GetResult();
}
/// <summary>Registriert alle Core-Services im DI-Container.</summary>
private static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration)
{
// EF-Core-Persistenz (Connection aus appsettings.Local.json).
services.AddCorePersistence(new DatabaseOptions
{
MySqlConnectionString = configuration["Database:MySqlConnectionString"] ?? string.Empty
});
// Settings zuerst laden (eine Quelle, als Singleton weitergereicht).
var settingsService = new SettingsService();
settingsService.Load();
services.AddSingleton(settingsService);
services.AddSingleton<LoggingService>();
services.AddSingleton<CoreSettingsService>(); // core_settings via EF
services.AddSingleton<IBKRGatewayService>();
services.AddSingleton<IBKRMarketDataRepository>();
services.AddSingleton<BudgetService>();
services.AddSingleton<TradeHistoryService>();
services.AddSingleton<AIModelService>();
// Datenfundament für Analyse/Forensik (Supervisor): Entscheidungsjournal + Order-Events.
services.AddSingleton<IDecisionJournal, EfDecisionJournal>();
services.AddSingleton<IOrderEventLog, EfOrderEventLog>();
// Trading-Kern
services.AddSingleton<DashboardService>();
services.AddSingleton<IRiskService, RiskService>();
services.AddSingleton<IPortfolioService, PortfolioService>();
services.AddSingleton<IExecutionService, ExecutionService>();
// Echter TWS-Broker nur, wenn ausdrücklich aktiviert sonst der NullBroker, der nie handelt.
// Beide Rollen (Handel + lesender Bestandsabgleich) bedient dieselbe Instanz.
if (settingsService.Settings.IBKR.UseTwsApi)
{
services.AddSingleton<IbkrBrokerClient>();
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<IbkrBrokerClient>());
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<IbkrBrokerClient>());
}
else
{
services.AddSingleton<NullBrokerClient>();
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<NullBrokerClient>());
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<NullBrokerClient>());
}
// Core-Worker/Services
services.AddSingleton<BackupWorker>();
services.AddSingleton<WebserverService>();
services.AddSingleton<WebApiService>();
services.AddSingleton<IBKRInstrumentSyncWorker>();
services.AddSingleton<IBKRPriceHistoryWorker>();
// Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable<IWorker> (nur UI/Registry).
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebserverService>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
// Lebenszyklus über den Generic Host (jeder Worker ist ein IHostedService).
services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>());
services.AddHostedService(sp => sp.GetRequiredService<WebserverService>());
services.AddHostedService(sp => sp.GetRequiredService<WebApiService>());
services.AddHostedService(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
services.AddHostedService(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
services.AddSingleton<WorkerEngine>();
}
/// <summary>
/// Weist den registrierten Views ihr Button-/Menü-Icon aus den App-Ressourcen zu (über die stabile
/// View-ID). Icons stammen aus PolytraderSharp; nicht passende können später ausgetauscht werden.
/// Bereits gesetzte Icons bleiben erhalten.
/// </summary>
private static void AssignViewIcons(IModuleUiHost uiHost)
{
var map = new Dictionary<string, Image>
{
["core.dashboard"] = Properties.Resources.dashboard,
["core.workers"] = Properties.Resources.system_time,
["core.logs"] = Properties.Resources.error_log,
["core.settings"] = Properties.Resources.setting_tools,
["congresstrading.main"] = Properties.Resources.cross_reference,
["accounting.main"] = Properties.Resources.coins_in_hand,
["supervisor.main"] = Properties.Resources.token_quantifier,
};
foreach (var view in uiHost.Views)
if (view.Icon is null && map.TryGetValue(view.Id, out var img))
view.Icon = img;
}
/// <summary>Registriert die Core-Views (Logs, Settings, Workers) bei der Shell.</summary>
private static void RegisterCoreViews(IModuleUiHost uiHost, IServiceProvider sp)
{
uiHost.RegisterView(new ModuleView
{
Id = "core.dashboard", Title = "Dashboard", Group = "Core", Order = 5,
CreateForm = () => new DashboardView(
sp.GetRequiredService<DashboardService>(),
sp.GetRequiredService<SettingsService>(),
sp.GetServices<IModule>(),
sp.GetRequiredService<IConfiguration>(),
sp.GetServices<IWorker>())
});
uiHost.RegisterView(new ModuleView
{
Id = "core.workers", Title = "Workers / Services", Group = "Core", Order = 10,
CreateForm = () => new WorkersView(sp.GetRequiredService<WorkerEngine>())
});
uiHost.RegisterView(new ModuleView
{
Id = "core.logs", Title = "Logs", Group = "Core", Order = 20,
CreateForm = () => new LogsView(sp.GetRequiredService<LoggingService>())
});
uiHost.RegisterView(new ModuleView
{
Id = "core.settings", Title = "Settings", Group = "Core", Order = 30,
CreateForm = () => new SettingsView(sp.GetRequiredService<SettingsService>())
});
}
/// <summary>
/// Lädt den Master-Key (env IBKRTRADER_MASTER_KEY, sonst gitignorierte master.key) und aktiviert die
/// at-rest-Verschlüsselung. Ohne Key läuft die App mit Klartext mit deutlicher Warnung.
/// </summary>
private static void ConfigureSecretProtection(LoggingService logger)
{
var masterKey = Environment.GetEnvironmentVariable("IBKRTRADER_MASTER_KEY");
if (string.IsNullOrWhiteSpace(masterKey))
{
var keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim();
}
SecretProtection.Configure(masterKey);
if (SecretProtection.IsConfigured)
logger.Info("Core", "🔐 Secret-Verschlüsselung aktiv sensible Daten werden at-rest verschlüsselt (AES-256-GCM).");
else
logger.Warn("Core", "⚠️ SICHERHEIT: Kein IBKRTRADER_MASTER_KEY gesetzt sensible Daten würden UNVERSCHLÜSSELT gespeichert. " +
"Master-Key setzen (env IBKRTRADER_MASTER_KEY oder Datei master.key).");
}
/// <summary>Warnt, wenn der DB-Connection-String keine TLS-Option (SslMode) enthält. Der String wird NICHT geloggt.</summary>
private static void WarnIfDbTlsNotEnforced(IServiceProvider services, LoggingService logger)
{
try
{
var conn = services.GetService<IConfiguration>()?["Database:MySqlConnectionString"] ?? string.Empty;
if (string.IsNullOrEmpty(conn)) return;
if (conn.IndexOf("sslmode", StringComparison.OrdinalIgnoreCase) < 0)
logger.Warn("Core", "⚠️ SICHERHEIT: DB-Verbindung ohne SslMode Transportverschlüsselung nicht erzwungen. " +
"Im Connection-String 'SslMode=Required' setzen.");
}
catch { /* best-effort, darf den Start nie stören */ }
}
/// <summary>Diagnose: öffnet die DB (aus settings.json) und gibt die Serverversion aus. Kein UI.</summary>
private static void RunDbVersion()
{
var settings = new SettingsService();
settings.Load();
var conn = settings.Settings.Database.BuildConnectionString();
try
{
using var c = new MySqlConnector.MySqlConnection(conn);
c.Open();
Console.WriteLine($"ServerVersion: {c.ServerVersion}");
}
catch (Exception ex)
{
Console.WriteLine($"FEHLER: {ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>
/// Headless-Smoke-Test: baut den Host, registriert Views + Modul-UI und konstruiert jede
/// registrierte View sowie den Launcher ohne Message-Loop. Gibt die Fehleranzahl zurück.
/// </summary>
private static int RunSmokeUi()
{
ApplicationConfiguration.Initialize();
var modules = new List<IModule> { new CongressTradingModule(), new AccountingModule(), new SupervisorModule() };
using var host = Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
.ConfigureAppConfiguration((_, config) =>
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
.ConfigureServices((context, services) =>
{
RegisterCoreServices(services, context.Configuration);
foreach (var module in modules)
{
services.AddSingleton(module);
module.RegisterServices(services, context.Configuration);
}
services.AddSingleton<ShellUiHost>();
services.AddSingleton<IModuleUiHost>(s => s.GetRequiredService<ShellUiHost>());
services.AddSingleton<LauncherForm>();
})
.Build();
host.Services.GetRequiredService<WebApiService>()
.SetEngine(host.Services.GetRequiredService<WorkerEngine>());
var uiHost = host.Services.GetRequiredService<ShellUiHost>();
RegisterCoreViews(uiHost, host.Services);
foreach (var module in modules)
module.RegisterUi(uiHost, host.Services);
var failures = 0;
Console.WriteLine("=== Smoke-UI: View-Konstruktion ===");
foreach (var view in uiHost.Views)
{
try { using var form = view.CreateForm(); Console.WriteLine($"[OK] {view.Id} ({view.Title})"); }
catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] {view.Id}: {ex.GetType().Name}: {ex.Message}"); }
}
try { using var launcher = host.Services.GetRequiredService<LauncherForm>(); Console.WriteLine("[OK] LauncherForm konstruiert"); }
catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] LauncherForm: {ex.GetType().Name}: {ex.Message}"); }
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
return failures;
}
}
-303
View File
@@ -1,303 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// Dieser Code wurde von einem Tool generiert.
// Laufzeitversion:4.0.30319.42000
//
// Änderungen an dieser Datei können falsches Verhalten verursachen und gehen verloren, wenn
// der Code erneut generiert wird.
// </auto-generated>
//------------------------------------------------------------------------------
namespace IBKRTrader.Properties {
using System;
/// <summary>
/// Eine stark typisierte Ressourcenklasse zum Suchen von lokalisierten Zeichenfolgen usw.
/// </summary>
// Diese Klasse wurde von der StronglyTypedResourceBuilder automatisch generiert
// -Klasse über ein Tool wie ResGen oder Visual Studio automatisch generiert.
// Um einen Member hinzuzufügen oder zu entfernen, bearbeiten Sie die .ResX-Datei und führen dann ResGen
// mit der /str-Option erneut aus, oder Sie erstellen Ihr VS-Projekt neu.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Gibt die zwischengespeicherte ResourceManager-Instanz zurück, die von dieser Klasse verwendet wird.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("IBKRTrader.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Überschreibt die CurrentUICulture-Eigenschaft des aktuellen Threads für alle
/// Ressourcenzuordnungen, die diese stark typisierte Ressourcenklasse verwenden.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap accept_button {
get {
object obj = ResourceManager.GetObject("accept_button", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap add {
get {
object obj = ResourceManager.GetObject("add", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap cancel {
get {
object obj = ResourceManager.GetObject("cancel", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap coins_in_hand {
get {
object obj = ResourceManager.GetObject("coins_in_hand", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap cross_reference {
get {
object obj = ResourceManager.GetObject("cross_reference", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap dashboard {
get {
object obj = ResourceManager.GetObject("dashboard", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap delete {
get {
object obj = ResourceManager.GetObject("delete", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap diskette {
get {
object obj = ResourceManager.GetObject("diskette", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap emotion_batman {
get {
object obj = ResourceManager.GetObject("emotion_batman", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap error_log {
get {
object obj = ResourceManager.GetObject("error_log", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap file_start_workflow {
get {
object obj = ResourceManager.GetObject("file_start_workflow", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap money {
get {
object obj = ResourceManager.GetObject("money", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap money_add {
get {
object obj = ResourceManager.GetObject("money_add", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap money_delete {
get {
object obj = ResourceManager.GetObject("money_delete", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap money_dollar {
get {
object obj = ResourceManager.GetObject("money_dollar", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap refresh_all {
get {
object obj = ResourceManager.GetObject("refresh_all", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap setting_tools {
get {
object obj = ResourceManager.GetObject("setting_tools", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap stop {
get {
object obj = ResourceManager.GetObject("stop", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap system_time {
get {
object obj = ResourceManager.GetObject("system_time", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap token_quantifier {
get {
object obj = ResourceManager.GetObject("token_quantifier", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap traffic_lights_green {
get {
object obj = ResourceManager.GetObject("traffic_lights_green", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap traffic_lights_red {
get {
object obj = ResourceManager.GetObject("traffic_lights_red", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap traffic_lights_yellow {
get {
object obj = ResourceManager.GetObject("traffic_lights_yellow", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Sucht eine lokalisierte Ressource vom Typ System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap warning {
get {
object obj = ResourceManager.GetObject("warning", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
}
}
-133
View File
@@ -1,133 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="accept_button" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\accept_button.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="add" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\add.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="cancel" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\cancel.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="coins_in_hand" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\coins_in_hand.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="cross_reference" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\cross_reference.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="dashboard" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\dashboard.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="delete" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\delete.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="diskette" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\diskette.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="emotion_batman" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\emotion_batman.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="error_log" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\error_log.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="file_start_workflow" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\file_start_workflow.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="money" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\money.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="money_add" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\money_add.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="money_delete" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\money_delete.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="money_dollar" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\money_dollar.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="refresh_all" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\refresh_all.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="setting_tools" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\setting_tools.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="stop" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\stop.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="system_time" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\system_time.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="token_quantifier" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\token_quantifier.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="traffic_lights_green" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\traffic_lights_green.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="traffic_lights_red" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\traffic_lights_red.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="traffic_lights_yellow" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\traffic_lights_yellow.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="warning" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\warning.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
</root>
+36 -12
View File
@@ -4,13 +4,20 @@ Modulares C#-Trading-Framework für Interactive-Brokers-Aktien. **Harter Core +
Strategie-Module + Launcher**, der die Fenster der Module öffnet. Konzept nach dem Vorbild von Strategie-Module + Launcher**, der die Fenster der Module öffnet. Konzept nach dem Vorbild von
PolytraderSharp (nur IBKR statt Polymarket). PolytraderSharp (nur IBKR statt Polymarket).
**Läuft auf Windows und Linux** wahlweise mit Oberfläche (Avalonia) oder kopflos als Dienst.
## Architektur (Kurzform) ## Architektur (Kurzform)
``` ```
IBKRTrader.App WinExe Generic Host + Launcher/Shell (WinForms) src/IBKRTrader.App Oberfläche (Avalonia, plattformneutral)
src/IBKRTrader.Daemon kopfloser Dienst (systemd) dieselbe Anwendung ohne Fenster
src/IBKRTrader.Hosting Host-Zusammenstellung, von beiden Einstiegspunkten geteilt
src/IBKRTrader.Core Contracts, EF-Persistenz, Trading-Kern, Worker, Security src/IBKRTrader.Core Contracts, EF-Persistenz, Trading-Kern, Worker, Security
src/IBKRTrader.Modules.* je Modul ein eigenes Projekt (referenziert nur Core) src/IBKRTrader.Modules.* je Modul ein eigenes Projekt (referenziert nur Core)
tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory) tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory)
``` ```
Alle Projekte sind `net10.0` ohne Plattformbindung. Der UI-Contract im Core ist toolkit-neutral
(`Func<object> CreateView`, `IconKey` statt Bild), damit Core und Module auch kopflos laufen
die Fenster registriert die Shell zentral in `Shell/ModuleViews.cs`.
- **Generic Host** (`Host.CreateDefaultBuilder`), Worker/Services als `IHostedService`. - **Generic Host** (`Host.CreateDefaultBuilder`), Worker/Services als `IHostedService`.
- **Module** über `IModule` (RegisterServices/RegisterUi/Start/Stop); UI über `IModuleUiHost`/`ModuleView`. - **Module** über `IModule` (RegisterServices/RegisterUi/Start/Stop); UI über `IModuleUiHost`/`ModuleView`.
- **Persistenz**: EF Core (Pomelo/MariaDB), Migrationen **extern** angewendet (nicht zur Laufzeit). - **Persistenz**: EF Core (Pomelo/MariaDB), Migrationen **extern** angewendet (nicht zur Laufzeit).
@@ -19,16 +26,27 @@ tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory)
ü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
dotnet build IBKRTrader.slnx dotnet build IBKRTrader.slnx
dotnet test IBKRTrader.slnx dotnet test IBKRTrader.slnx
dotnet run --project IBKRTrader.App.csproj -- --smoke-ui # Headless-UI-Check
dotnet run --project IBKRTrader.App.csproj # App starten
``` ```
Anwendung starten:
```bash
dotnet run --project src/IBKRTrader.App
```
Prüfläufe beide ohne Anzeigegerät und ohne laufende Dienste, also CI-tauglich:
```bash
dotnet run --project src/IBKRTrader.App -- --smoke-ui
dotnet run --project src/IBKRTrader.Daemon -- --check
```
Kopflos auf Linux (systemd): siehe [deploy/README.md](deploy/README.md).
## Konfiguration ## Konfiguration
- `appsettings.Local.json` (gitignored) hält den DB-Connection-String (`Database:MySqlConnectionString`). - `appsettings.Local.json` (gitignored) hält den DB-Connection-String (`Database:MySqlConnectionString`).
- `settings.json` (gitignored) App-Settings (IBKR-Ports, Logging, Worker, Trading). - `settings.json` (gitignored) App-Settings (IBKR-Ports, Logging, Worker, Trading).
@@ -49,19 +67,25 @@ powershell -File scripts/provision-db.ps1
- **Accounting** von der Trading-DB unabhängige Buchführung aus dem IBKR-Kontoauszug (Activity Flex - **Accounting** von der Trading-DB unabhängige Buchführung aus dem IBKR-Kontoauszug (Activity Flex
Query) → append-only Ledger `acc_*`, Periodenabrechnung/BWA, FX (USD/EUR), CSV/PDF-Export. Kein Handel. Query) → append-only Ledger `acc_*`, Periodenabrechnung/BWA, FX (USD/EUR), CSV/PDF-Export. Kein Handel.
Live-Abruf hinter Interfaces (Offline-Null-Stubs); Steuerschicht bewusst offen. Konzept: Live-Abruf hinter Interfaces (Offline-Null-Stubs); Steuerschicht bewusst offen. Konzept:
[docs/konzepte/KONZEPT-Modul-Accounting.md](docs/konzepte/KONZEPT-Modul-Accounting.md). [docs/archiv/KONZEPT-Modul-Accounting.md](docs/archiv/KONZEPT-Modul-Accounting.md).
- **Supervisor** read-only KI-Analyse/Forensik über alle Module (OpenRouter-Agent + read-only - **Supervisor** read-only KI-Analyse/Forensik über alle Module (OpenRouter-Agent + read-only
Tool-Registry, Dossier-Browser, optional MCP-Light). Stützt sich auf das Core-Datenfundament Tool-Registry, Dossier-Browser, optional MCP-Light). Stützt sich auf das Core-Datenfundament
(`core_decision_journal`, `core_order_events`, `SignalId`, JSONL-Logs). Konzept: (`core_decision_journal`, `core_order_events`, `SignalId`, JSONL-Logs). Konzept:
[docs/konzepte/KONZEPT-Modul-Supervisor.md](docs/konzepte/KONZEPT-Modul-Supervisor.md). [docs/archiv/KONZEPT-Modul-Supervisor.md](docs/archiv/KONZEPT-Modul-Supervisor.md).
## Status / Nächstes ## Status / Nächstes
- Kurskorrektur auf das PolytraderSharp-Konzept (R1R7) abgeschlossen.
- **Accounting**- und **Supervisor**-Modul (inkl. Core-Datenfundament S-0) ergänzt; Live-Abruf (IBKR **➡️ Was noch zu tun ist, steht vollständig in der [Roadmap](docs/ROADMAP.md).** Sie ist seit dem
Flex / OpenRouter-Key) und Steuerschicht sind bewusst noch offen (Stubs/Platzhalter). 2026-08-23 das einzige Dokument, das den offenen Stand führt einschließlich der Ideen, die wir
- **IBKR-Broker über die TWS API / IB Gateway** ist implementiert (Paper-Konto steht, Verbindung bewusst zurückstellen (🧊) und derer, die wir geprüft und verworfen haben (❌). Die früheren
verifiziert) Design und offene Punkte: [docs/IBKR-Integration.md](docs/IBKR-Integration.md), Konzepte liegen unverändert unter [docs/archiv/](docs/archiv/) und tragen die Herleitung.
TWS-Einstellungen: [docs/TWS-Setup-Checkliste.md](docs/TWS-Setup-Checkliste.md).
Kurzfassung:
- Gebaut: R1R7 (Kurskorrektur auf das PolytraderSharp-Konzept), Accounting und Supervisor inkl.
Core-Datenfundament S-0, Linux-Portierung L0L6, Deploymentcenter-Anbindung.
- **Es hat noch nie eine echte Order gegeben.** Der TWS-Adapter ist gegen das Paper-Konto
verifiziert (Verbindung, Konto, Kurse, Optionskette, Greeks, What-If-Order), aber
`PlaceOrderAsync` mit echter Ausführung steht aus daran hängt alles Weitere.
- **Sicherheit:** DB-Passwort rotieren (liegt in der Git-Historie, Commit `ebeb035`). - **Sicherheit:** DB-Passwort rotieren (liegt in der Git-Historie, Commit `ebeb035`).
## Sicherheitshinweis ## Sicherheitshinweis
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

-33
View File
@@ -1,33 +0,0 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.UI;
/// <summary>
/// Steuert das Log-Panel (RichTextBox im Logs-Tab).
/// Bietet Clear- und Filter-Funktionalität.
/// </summary>
public class LogPanelController
{
private readonly RichTextBox _rtb;
private readonly LoggingService _logger;
public LogPanelController(RichTextBox rtb, LoggingService logger)
{
_rtb = rtb;
_logger = logger;
// Logging-Service mit RichTextBox verbinden
_logger.AttachRichTextBox(rtb);
// Hintergrund der RTB auf dunkles Theme setzen
_rtb.BackColor = Color.FromArgb(20, 20, 30);
_rtb.ForeColor = Color.FromArgb(200, 200, 200);
_rtb.Font = new Font("Consolas", 9f);
_rtb.ReadOnly = true;
_rtb.WordWrap = false;
}
public void Clear() => _rtb.Clear();
public void CopyAll() => Clipboard.SetText(_rtb.Text);
}
-107
View File
@@ -1,107 +0,0 @@
using IBKRTrader.Core.Modularity;
namespace IBKRTrader.UI;
/// <summary>
/// Verwaltet die registrierten Fenster-Views: öffnet sie als eigenständige Forms, hält je View
/// höchstens eine Instanz offen und holt ein bereits offenes Fenster wieder in den Vordergrund.
/// Meldet Änderungen am Offen-Status (für die Button-Markierung im Launcher).
/// Vorbild: PolytraderSharp <c>ShellUiHost</c>.
/// </summary>
public sealed class ShellUiHost : IModuleUiHost
{
private readonly List<ModuleView> _views = new();
private readonly Dictionary<string, Form> _open = new(StringComparer.OrdinalIgnoreCase);
private Form? _mainWindow;
/// <summary>True, sobald das Beenden bestätigt wurde (der Launcher wertet das in FormClosing aus).</summary>
public bool ShutdownConfirmed { get; private set; }
public event Action? OpenStateChanged;
public IReadOnlyList<ModuleView> Views => _views;
public void RegisterView(ModuleView view) => _views.Add(view);
/// <summary>Setzt das Hauptfenster (Launcher) Ziel für <see cref="ActivateMain"/>.</summary>
public void SetMainWindow(Form main) => _mainWindow = main;
public bool IsOpen(string viewId) =>
_open.TryGetValue(viewId, out var form) && !form.IsDisposed;
public void ActivateMain()
{
if (_mainWindow is null || _mainWindow.IsDisposed) return;
if (_mainWindow.WindowState == FormWindowState.Minimized)
_mainWindow.WindowState = FormWindowState.Normal;
_mainWindow.BringToFront();
_mainWindow.Activate();
}
/// <summary>Sicherheitsabfrage; bei Bestätigung wird die Message-Loop beendet.</summary>
public void RequestShutdown()
{
if (ShutdownConfirmed) return;
var owner = _mainWindow is { IsDisposed: false } ? _mainWindow : null;
var result = MessageBox.Show(owner,
"IBKRTrader wirklich beenden? Laufende Worker/Services werden gestoppt.",
"Beenden", MessageBoxButtons.OKCancel, MessageBoxIcon.Warning);
if (result != DialogResult.OK) return;
ShutdownConfirmed = true;
Application.Exit();
}
public void OpenView(string viewId)
{
var view = _views.FirstOrDefault(v => v.Id == viewId);
if (view is not null) OpenView(view);
}
private void OpenView(ModuleView view)
{
if (_open.TryGetValue(view.Id, out var existing) && !existing.IsDisposed)
{
if (existing.WindowState == FormWindowState.Minimized)
existing.WindowState = FormWindowState.Normal;
existing.BringToFront();
existing.Activate();
return;
}
var form = view.CreateForm();
if (string.IsNullOrEmpty(form.Text) || form.Text == form.Name)
form.Text = view.Title;
// Gemeinsames „Fenster"-Menü in jedes Fenster injizieren (identische Shell-Chrome).
if (form.MainMenuStrip is null)
AttachWindowMenu(form, view.Id);
_open[view.Id] = form;
form.FormClosed += (_, _) =>
{
_open.Remove(view.Id);
OpenStateChanged?.Invoke();
};
form.Show();
OpenStateChanged?.Invoke();
}
private void AttachWindowMenu(Form form, string currentViewId)
{
var menu = new MenuStrip { Dock = DockStyle.Top, ImageScalingSize = new Size(24, 24) };
WindowMenu.Wire(menu, this, currentViewId);
form.Controls.Add(menu);
form.MainMenuStrip = menu;
}
/// <summary>Schließt alle offenen View-Fenster (beim Herunterfahren).</summary>
public void CloseAllViews()
{
foreach (var form in _open.Values.ToList())
if (!form.IsDisposed)
form.Close();
_open.Clear();
}
}
-98
View File
@@ -1,98 +0,0 @@
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using Microsoft.Extensions.Configuration;
namespace IBKRTrader.UI.Views;
/// <summary>Core-View: Gesamtüberblick (Trading-Modus, aggregierte Kennzahlen, geladene Module).</summary>
public sealed class DashboardView : Form
{
private readonly DashboardService _dashboard;
private readonly SettingsService _settings;
private readonly IReadOnlyList<IModule> _modules;
private readonly IConfiguration _config;
private readonly int _workerCount;
private readonly Label _lblMode = new() { AutoSize = true, Location = new Point(20, 20), Font = new Font("Segoe UI", 13f, FontStyle.Bold) };
private readonly Label _lblStats = new() { AutoSize = true, Location = new Point(20, 60) };
private readonly Label _lblStatus = new() { AutoSize = true, Location = new Point(20, 90), ForeColor = SystemColors.GrayText };
private readonly DataGridView _modulesGrid = new()
{
Location = new Point(20, 130),
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
ReadOnly = true,
AllowUserToAddRows = false,
RowHeadersVisible = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill
};
public DashboardView(
DashboardService dashboard,
SettingsService settings,
IEnumerable<IModule> modules,
IConfiguration config,
IEnumerable<IWorker> workers)
{
_dashboard = dashboard;
_settings = settings;
_modules = modules.ToList();
_config = config;
_workerCount = workers.Count();
Text = "Dashboard";
Width = 900;
Height = 560;
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(600, 400);
var modLabel = new Label { Text = "Geladene Module:", Location = new Point(20, 108), AutoSize = true };
var refresh = new Button { Text = "Aktualisieren", Location = new Point(760, 18), Width = 110, Anchor = AnchorStyles.Top | AnchorStyles.Right };
refresh.Click += async (_, _) => await RefreshAsync();
_modulesGrid.Size = new Size(ClientSize.Width - 40, ClientSize.Height - 150);
Controls.Add(_lblMode);
Controls.Add(_lblStats);
Controls.Add(_lblStatus);
Controls.Add(modLabel);
Controls.Add(_modulesGrid);
Controls.Add(refresh);
}
protected override async void OnShown(EventArgs e)
{
base.OnShown(e);
await RefreshAsync();
}
private async Task RefreshAsync()
{
var t = _settings.Settings.Trading;
_lblMode.Text = $"Trading: {t.Mode} {(t.TradingEnabled ? "AKTIV" : "inaktiv")}";
_lblMode.ForeColor = t.TradingEnabled ? Color.SeaGreen : SystemColors.GrayText;
_modulesGrid.DataSource = _modules
.Select(m => new
{
Modul = m.Name,
Präfix = m.DbPrefix,
Status = m.GetActivationBlocker(_config) ?? "aktivierbar"
})
.ToList();
try
{
var snap = await _dashboard.GetSnapshotAsync();
_lblStats.Text = $"Offene Positionen: {snap.OpenPositions} | Exposure: {snap.TotalExposure:N2} | " +
$"Trades gesamt: {snap.TotalTrades} | Worker/Services: {_workerCount}";
_lblStatus.Text = $"Aktualisiert: {DateTime.Now:HH:mm:ss}";
}
catch (Exception ex)
{
_lblStats.Text = $"Kennzahlen n/v | Worker/Services: {_workerCount}";
_lblStatus.Text = $"DB nicht erreichbar: {ex.Message}";
}
}
}
-21
View File
@@ -1,21 +0,0 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.UI.Views;
/// <summary>Core-View: Live-Log (RichTextBox), an den LoggingService gebunden.</summary>
public sealed class LogsView : Form
{
public LogsView(LoggingService logger)
{
Text = "Logs";
Width = 1000;
Height = 650;
StartPosition = FormStartPosition.CenterScreen;
var rtb = new RichTextBox { Dock = DockStyle.Fill };
Controls.Add(rtb);
// Verbindet den Logger mit der RichTextBox (Theme + AttachRichTextBox).
_ = new LogPanelController(rtb, logger);
}
}
-23
View File
@@ -1,23 +0,0 @@
using IBKRTrader.Core.Settings;
namespace IBKRTrader.UI.Views;
/// <summary>Core-View: Einstellungen (PropertyGrid auf AppSettings) mit Speichern-Button.</summary>
public sealed class SettingsView : Form
{
public SettingsView(SettingsService settings)
{
Text = "Settings";
Width = 820;
Height = 720;
StartPosition = FormStartPosition.CenterScreen;
var grid = new PropertyGrid { Dock = DockStyle.Fill, SelectedObject = settings.Settings };
var save = new Button { Text = "Speichern", Dock = DockStyle.Bottom, Height = 36 };
save.Click += (_, _) => settings.Save();
Controls.Add(grid);
Controls.Add(save);
}
}
-20
View File
@@ -1,20 +0,0 @@
using IBKRTrader.Core.Workers;
namespace IBKRTrader.UI.Views;
/// <summary>Core-View: Worker/Services-Übersicht (DataGridView, live an die WorkerEngine gebunden).</summary>
public sealed class WorkersView : Form
{
public WorkersView(WorkerEngine engine)
{
Text = "Workers / Services";
Width = 1200;
Height = 700;
StartPosition = FormStartPosition.CenterScreen;
var dgv = new DataGridView { Dock = DockStyle.Fill };
Controls.Add(dgv);
WorkerListBindingSource.Setup(dgv, engine.WorkerInfos);
}
}
-77
View File
@@ -1,77 +0,0 @@
using System.ComponentModel;
using IBKRTrader.Core.Workers;
namespace IBKRTrader.UI;
/// <summary>
/// Richtet dgv_workerlist vollständig ein:
/// Spalten, Binding, Formatierung, Kontext-Menü.
/// </summary>
public static class WorkerListBindingSource
{
public static void Setup(DataGridView dgv, BindingList<WorkerInfo> source)
{
dgv.AutoGenerateColumns = false;
dgv.ReadOnly = false;
dgv.AllowUserToAddRows = false;
dgv.RowHeadersVisible = false;
dgv.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
dgv.MultiSelect = false;
// ── Spalten definieren ───────────────────────────────────────────────
dgv.Columns.Clear();
dgv.Columns.Add(new DataGridViewCheckBoxColumn
{
DataPropertyName = nameof(WorkerInfo.Active),
HeaderText = "Active",
Width = 65,
ReadOnly = false
});
dgv.Columns.Add(MakeTextColumn(nameof(WorkerInfo.Type), "Type", 80, false));
dgv.Columns.Add(MakeTextColumn(nameof(WorkerInfo.Module), "Module", 90, false));
dgv.Columns.Add(MakeTextColumn(nameof(WorkerInfo.WorkerName), "Worker Name", 180, false));
dgv.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(WorkerInfo.LastRuntime),
HeaderText = "Last Runtime",
Width = 150,
ReadOnly = true,
DefaultCellStyle = { Format = "dd.MM.yyyy HH:mm:ss", NullValue = "" }
});
dgv.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(WorkerInfo.NextRuntime),
HeaderText = "Next Runtime",
Width = 150,
ReadOnly = true,
DefaultCellStyle = { Format = "dd.MM.yyyy HH:mm:ss", NullValue = "" }
});
dgv.Columns.Add(MakeTextColumn(nameof(WorkerInfo.RunEvery), "Run Every", 90, false));
dgv.Columns.Add(MakeTextColumn(nameof(WorkerInfo.Info), "Info", 400, false));
// ── Binding ──────────────────────────────────────────────────────────
var bs = new BindingSource { DataSource = source };
dgv.DataSource = bs;
// ── Styling ──────────────────────────────────────────────────────────
dgv.EnableHeadersVisualStyles = false;
dgv.ColumnHeadersDefaultCellStyle.Font = new Font("Segoe UI", 9f, FontStyle.Bold);
dgv.DefaultCellStyle.Font = new Font("Segoe UI", 9f);
dgv.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(240, 240, 255);
dgv.GridColor = Color.LightGray;
}
private static DataGridViewTextBoxColumn MakeTextColumn(
string prop, string header, int width, bool readOnly) => new()
{
DataPropertyName = prop,
HeaderText = header,
Width = width,
ReadOnly = readOnly
};
}
+115
View File
@@ -0,0 +1,115 @@
# Kopfloser Betrieb auf Linux
Der Dienst `IBKRTrader.Daemon` fährt Trading-Kern, Worker, Accounting, Supervisor, REST-API und
MCP-Light **ohne Oberfläche**. Er nutzt denselben Host-Aufbau wie die Desktop-Shell
(`IBKRTrader.Hosting`), damit beide Varianten nicht auseinanderlaufen.
## Veröffentlichen
```bash
dotnet publish src/IBKRTrader.Daemon -c Release -r linux-x64 --self-contained false -o out/
```
Ergebnis: ~11 MB, ein ELF-Launcher `IBKRTrader.Daemon`, **keine Windows-Abhängigkeiten**.
Mit `--self-contained true` entfällt die Runtime-Installation auf dem Zielhost (dann ~80 MB).
## Voraussetzungen auf dem Host
| Paket | Wofür | Pflicht? |
|---|---|---|
| `dotnet-runtime-10.0` | Laufzeit | ja (außer bei `--self-contained`) |
| `libicu` | Kulturen und Zeitzonen-ID-Umrechnung | **ja siehe Warnung unten** |
| `fonts-dejavu-core` | PDF-Export des Accounting-Moduls | nur für den PDF-Export |
| `mariadb-client` | `mariadb-dump` für den BackupWorker | nur fürs DB-Backup |
> **ICU ist nicht optional.** Ohne ICU (bzw. mit `InvariantGlobalization=true`) passieren zwei
> Dinge beide **lautlos**, ohne Fehlermeldung:
> 1. `AppTimeZone` kann Windows-Zeitzonen-IDs nicht mehr auflösen und fällt auf die
> Systemzeitzone zurück, im Container also meist UTC. Genau die Verschiebung von
> Buchungszeitstempeln, die wir beseitigt haben.
> 2. Der PDF-Export formatiert Beträge fest gegen `de-DE`. Ohne ICU liefert
> `CultureInfo.GetCultureInfo("de-DE")` die invariante Kultur aus `1.234,56` wird
> `1,234.56`, in einem Dokument, das als prüfbare Aufstellung gilt.
>
> Der Daemon setzt deshalb ausdrücklich `InvariantGlobalization=false`.
## Verzeichnisse
Die Anwendung schreibt in drei Verzeichnisse. Aufgelöst wird in dieser Reihenfolge:
1. Umgebungsvariable — `IBKRTRADER_CONFIG_DIR`, `IBKRTRADER_DATA_DIR`, `IBKRTRADER_LOG_DIR`
2. Neben der Binärdatei, **wenn dort geschrieben werden darf** (Entwicklung, portable Installation)
3. Sonst FHS — `/etc/ibkrtrader`, `/var/lib/ibkrtrader`, `/var/log/ibkrtrader`
| Verzeichnis | Inhalt |
|---|---|
| Config | `settings.json`, `master.key`, `openrouter.key` |
| Data | `Backups/` |
| Logs | Textlog je Modul, JSONL je Tag |
Beim Start steht die tatsächliche Ablage im Log (`Ablage: config=…, data=…, logs=…`).
## Einrichtung
```bash
sudo useradd --system --no-create-home --shell /usr/sbin/nologin ibkrtrader
sudo mkdir -p /opt/ibkrtrader /etc/ibkrtrader /var/lib/ibkrtrader /var/log/ibkrtrader
sudo chown -R ibkrtrader:ibkrtrader /var/lib/ibkrtrader /var/log/ibkrtrader /etc/ibkrtrader
sudo chmod 750 /etc/ibkrtrader
```
Master-Key ablegen (**nicht** in die Unit-Datei die ist für alle lesbar):
```bash
sudo install -o ibkrtrader -g ibkrtrader -m 600 /dev/null /etc/ibkrtrader/master.key
```
Der Dienst warnt beim Start, wenn `master.key` oder `openrouter.key` für Gruppe oder andere
zugänglich sind — Windows-ACLs übertragen sich beim Kopieren auf einen Linux-Host nicht.
## Dienst einrichten
```bash
sudo cp deploy/ibkrtrader.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now ibkrtrader
journalctl -u ibkrtrader -f
```
## Vor dem ersten Start prüfen
```bash
/opt/ibkrtrader/IBKRTrader.Daemon --check
```
Baut den Host, fährt alle Startprüfungen (Zeitzone, Ablageorte, Master-Key, DB-TLS) und beendet
sich — **ohne** Worker zu starten oder eine Verbindung zur Börse aufzubauen. Geeignet für
Deployment-Skripte und CI.
```bash
/opt/ibkrtrader/IBKRTrader.Daemon --db-version
```
Gibt die MariaDB-Serverversion aus (für das EF-`ServerVersion`-Pinning).
## Betriebszeitzone
`Trading.ApplicationTimeZoneId` in `settings.json`, IANA-Schreibweise:
| Instanz | Wert |
|---|---|
| EU | `Europe/Berlin` |
| US | `America/New_York` |
**Vor den ersten Trades festlegen und danach nicht mehr ändern.** Ein Wechsel verschiebt
rückwirkend die Tagesgrenzen von Logs, Berichten und Buchungsperioden. Gespeichert wird immer
UTC — nur so bleiben die Daten beider Instanzen vergleichbar.
## Was der Daemon *nicht* löst
Das **IB Gateway** ist eine Java-Anwendung mit Oberfläche. Für den Dauerbetrieb ohne Bildschirm
braucht es IBC plus Xvfb, dazu die Behandlung des täglichen Neustarts und des 2FA-Handlings —
eine eigene Baustelle, unabhängig von diesem Dienst. Alternative: das Gateway bleibt auf dem
Windows-Rechner, der Linux-Dienst verbindet sich über Port 4002. Dann muss in der
TWS-Konfiguration die erlaubte Client-IP eingetragen sein; der TWS-API-Verkehr ist
**unverschlüsselt** und gehört nicht über ein unvertrautes Netz.
+64
View File
@@ -0,0 +1,64 @@
#
# systemd-Unit für den kopflosen IBKRTrader-Dienst.
#
# sudo cp deploy/ibkrtrader.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now ibkrtrader
# journalctl -u ibkrtrader -f
#
# Voraussetzungen auf dem Host:
# - .NET-10-Runtime (dotnet-runtime-10.0)
# - libicu → PFLICHT. Ohne ICU fällt die Auflösung von Windows-Zeitzonen-IDs aus und die
# feste de-DE-Formatierung im PDF-Export kippt auf invariant. Beides würde
# lautlos falsche Ausgaben erzeugen, nicht etwa einen Fehler.
# - fonts-dejavu-core → nur für den PDF-Export des Accounting-Moduls.
# - mariadb-client → nur für den BackupWorker (mariadb-dump).
#
[Unit]
Description=IBKRTrader (kopfloser Handelsdienst)
Documentation=file:///opt/ibkrtrader/docs/ARCHITECTURE.md
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
NotifyAccess=all
User=ibkrtrader
Group=ibkrtrader
WorkingDirectory=/opt/ibkrtrader
ExecStart=/opt/ibkrtrader/IBKRTrader.Daemon
# Ablageorte. Ohne diese Variablen weicht die Anwendung selbst auf die FHS-Pfade aus, sobald
# /opt/ibkrtrader nicht beschreibbar ist ausdrücklich gesetzt ist es aber nachvollziehbarer.
Environment=IBKRTRADER_CONFIG_DIR=/etc/ibkrtrader
Environment=IBKRTRADER_DATA_DIR=/var/lib/ibkrtrader
Environment=IBKRTRADER_LOG_DIR=/var/log/ibkrtrader
# Der Master-Key gehört NICHT in diese Datei (sie ist für alle lesbar). Entweder als Datei
# /etc/ibkrtrader/master.key mit chmod 600, oder über eine EnvironmentFile mit 600:
# EnvironmentFile=/etc/ibkrtrader/secrets.env
Environment=DOTNET_EnableDiagnostics=0
# Geordnetes Herunterfahren: SIGTERM, dann Zeit für offene Broker-Anfragen und den Modul-Stopp.
KillSignal=SIGTERM
TimeoutStopSec=60
Restart=on-failure
RestartSec=15
# Absicherung. Der Dienst braucht nur seine drei Verzeichnisse beschreibbar.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/ibkrtrader /var/log/ibkrtrader
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
+83 -14
View File
@@ -8,18 +8,38 @@
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)
> **Stand seit der Linux-Portierung (2026-08-07):** Alle Projekte sind `net10.0` ohne
> Plattformbindung. Die WinForms-Shell ist entfernt; der letzte Stand liegt im Tag
> `winforms-final`. Einstiegspunkte sind jetzt `IBKRTrader.App` (mit Oberfläche) und
> `IBKRTrader.Daemon` (kopflos, systemd); beide bauen ihren Host über `IBKRTrader.Hosting`.
> Analyse und Vorgehen: [archiv/KONZEPT-Linux-Portierung.md](archiv/KONZEPT-Linux-Portierung.md).
``` ```
IBKRTrader.App (WinExe, Root) Generic Host + Shell (Launcher) + Core-Views src/IBKRTrader.App (WinExe, net10.0) Oberfläche: Launcher, Shell, Core-Views, Modul-Fenster
│ Program.cs: Host.CreateDefaultBuilder, IConfiguration, Module laden, ShellUiHost, Application.Run │ Program.cs: Host bauen (Hosting), Startprüfungen, dann Avalonia; --smoke-ui ohne Anzeigegerät
Ui/: LauncherForm, ShellUiHost, Views/ (Dashboard, Terminal, Settings, Jobs) Shell/: AvaloniaUiHost, CoreViews, ModuleViews, ViewIcons, WindowMenu
│ Views/: LauncherWindow, Dashboard/Workers/Logs/Settings, Views/Modules/ (3 Modul-Fenster)
├── src/IBKRTrader.Core (classlib, net10.0-windows, UseWindowsForms) src/IBKRTrader.Daemon (Exe, net10.0) kopfloser Dienst: --check, --db-version, SIGTERM
src/IBKRTrader.Hosting (classlib, net10.0) AppHostBuilder + RunStartupChecks, von beiden geteilt
├── src/IBKRTrader.Core (classlib, net10.0 plattformneutral, kein UI-Toolkit)
│ ├── Modularity/ IModule (Name, DbPrefix, RegisterServices, RegisterUi, Start/Stop, ActivationBlocker) │ ├── Modularity/ IModule (Name, DbPrefix, RegisterServices, RegisterUi, Start/Stop, ActivationBlocker)
│ │ ModuleView, IModuleUiHost, WindowMenu ← UI-Contract liegt im Core │ │ ModuleView, IModuleUiHost ← toolkit-neutraler UI-Contract (Func<object>, IconKey)
│ ├── Time/ AppTimeZone (Betriebszeitzone der Instanz; Persistenz bleibt UTC)
│ ├── Configuration/ DatabaseOptions, ServerVersion-Pinning │ ├── Configuration/ DatabaseOptions, ServerVersion-Pinning
│ ├── DependencyInjection/ AddCorePersistence(...) │ ├── DependencyInjection/ AddCorePersistence(...)
│ ├── Persistence/Ef/ CoreDbContext + Entities + EF-Repositories (hinter Interfaces) │ ├── Persistence/Ef/ CoreDbContext + Entities + EF-Repositories (hinter Interfaces)
@@ -32,7 +52,7 @@ IBKRTrader.App (WinExe, Root) Generic Host + Shell (Launcher) + C
│ ├── CongressTradingModule : IModule │ ├── CongressTradingModule : IModule
│ ├── Persistence/Ef/ eigener DbContext (ct_) + Repos │ ├── Persistence/Ef/ eigener DbContext (ct_) + Repos
│ ├── Services/ Scraper + Jobs (IHostedService) │ ├── Services/ Scraper + Jobs (IHostedService)
│ └── Ui/ CongressTradingMainForm (Tabs) via RegisterUi │ └── (kein UI-Code das Modul-Fenster liegt in der Shell, s. App/Shell/ModuleViews.cs)
└── tests/IBKRTrader.Tests (xUnit, referenziert Core + Module) └── tests/IBKRTrader.Tests (xUnit, referenziert Core + Module)
``` ```
@@ -45,9 +65,10 @@ IBKRTrader.App (WinExe, Root) Generic Host + Shell (Launcher) + C
- **Config**: `appsettings.json` + `appsettings.Local.json` (gitignored, hält Connection-String/Secrets). - **Config**: `appsettings.json` + `appsettings.Local.json` (gitignored, hält Connection-String/Secrets).
- **Modul-Vertrag** `IModule`: `Name`, `DbPrefix`, `RegisterServices(services, config)`, - **Modul-Vertrag** `IModule`: `Name`, `DbPrefix`, `RegisterServices(services, config)`,
`RegisterUi(host, sp)`, `StartAsync/StopAsync`, `GetActivationBlocker(config)`. `RegisterUi(host, sp)`, `StartAsync/StopAsync`, `GetActivationBlocker(config)`.
- **UI = Shell + Views**: Core und Module registrieren `ModuleView`s beim `IModuleUiHost`. - **UI = Shell + Views**: Der Core stellt den toolkit-neutralen Contract (`ModuleView`,
Der Launcher öffnet je View ein Fenster (Einzelinstanz, Re-Open fokussiert). Gemeinsames `IModuleUiHost`), die Shell setzt ihn in Avalonia um. Der Launcher öffnet je View ein Fenster
„Fenster"-Menü (`WindowMenu`) auf jedem Form. Views sind designbare Forms mit `Initialize(sp)`. (Einzelinstanz, erneutes Öffnen fokussiert), jedes Fenster trägt das gemeinsame „Fenster"-Menü.
Layout deklarativ in `.axaml`; Module tragen keinen UI-Code.
- **Persistenz**: EF Core (Pomelo/MySQL), `AddDbContextFactory`, Repositories hinter Interfaces. - **Persistenz**: EF Core (Pomelo/MySQL), `AddDbContextFactory`, Repositories hinter Interfaces.
- **Sicherheit**: Master-Key + AES-256-GCM-Verschlüsselung von Credentials at-rest; TLS-Warnung. - **Sicherheit**: Master-Key + AES-256-GCM-Verschlüsselung von Credentials at-rest; TLS-Warnung.
- **Headless-Test**: `--smoke-ui` konstruiert jede View + Launcher ohne Message-Loop. - **Headless-Test**: `--smoke-ui` konstruiert jede View + Launcher ohne Message-Loop.
@@ -85,7 +106,7 @@ IBKRTrader.App (WinExe, Root) Generic Host + Shell (Launcher) + C
- [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`,
@@ -119,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
@@ -129,8 +150,56 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin
- [x] Gegen Paper-Konto DUR371528 verifiziert: Verbindung, Konto (NetLiquidation 100.105,50 EUR), Kurse (AAPL/MSFT/NVDA, verzögert) und Fehlerpfade - [x] Gegen Paper-Konto DUR371528 verifiziert: Verbindung, Konto (NetLiquidation 100.105,50 EUR), Kurse (AAPL/MSFT/NVDA, verzögert) und Fehlerpfade
- [x] Orderpfad bis zur Broker-Annahme per **What-If-Order** verifiziert (Aktie + Option, keine Ausführung); **Optionsberechtigung im Paper-Konto bestätigt** - [x] Orderpfad bis zur Broker-Annahme per **What-If-Order** verifiziert (Aktie + Option, keine Ausführung); **Optionsberechtigung im Paper-Konto bestätigt**
- [x] **`IBrokerPortfolioReader`** (Bestand + Ausführungen beim Broker) eigener Seam neben `IBrokerClient`, Grundlage für den Abgleich der eigenen Buchführung; gegen DUR371528 verifiziert (2 Positionen, 2 Ausführungen inkl. Kommissionen) - [x] **`IBrokerPortfolioReader`** (Bestand + Ausführungen beim Broker) eigener Seam neben `IBrokerClient`, Grundlage für den Abgleich der eigenen Buchführung; gegen DUR371528 verifiziert (2 Positionen, 2 Ausführungen inkl. Kommissionen)
- [ ] **Offen:** `PlaceOrderAsync` mit echter Ausführung verifizieren (Fill → Buchung); asynchrone Fill-Verfolgung (Orders ohne sofortige Ausführung) - [ ] **Offen → Roadmap [H1](ROADMAP.md) / [H2](ROADMAP.md):** `PlaceOrderAsync` mit echter Ausführung verifizieren (Fill → Buchung); asynchrone Fill-Verfolgung (Orders ohne sofortige Ausführung)
- [ ] IBKR-Account-Credentials mit `EncryptedStringConverter` speichern - [ ] **Offen → Roadmap [H3](ROADMAP.md):** IBKR-Account-Credentials mit `EncryptedStringConverter` speichern
### L0L5 Linux-Portierung: Avalonia statt WinForms ✅ (2026-08-07)
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`.
- [x] **L0** `NuGet.config` repariert drei Pakete hatten kein `packageSourceMapping`-Muster; ein frischer Klon konnte nicht wiederherstellen (auf dem Entwicklungsrechner unsichtbar, weil gecacht)
- [x] **L1a** Core und Module von WinForms entkoppelt: `net10.0` statt `net10.0-windows`. UI-Contract toolkit-neutral (`Func<object> CreateView`, `IconKey` statt `System.Drawing.Image` letzteres ist seit .NET 7 Windows-only). `LoggingService` meldet über `EntryWritten` statt eine `RichTextBox` zu halten
- [x] **L1b** **Betriebszeitzone** (`AppTimeZone`, `Trading.ApplicationTimeZoneId`): EU- und US-Instanzen sauber getrennt, Persistenz bleibt UTC. `ParseExecutionTime` verwirft die von TWS gemeldete Börsenzeitzone nicht mehr, sondern rechnet gegen sie nach UTC. Kultur-Fixes (PDF-Beträge fest `de-DE`, Scraper-Datum `TryParseExact`), `BackupWorker` plattformunabhängig, DB-Passwort über `MYSQL_PWD` statt Kommandozeile
- [x] **L2** Kopfloser Dienst `IBKRTrader.Daemon` (systemd, `--check`, `--db-version`) + `IBKRTrader.Hosting` als geteilte Host-Zusammenstellung. `AppPaths` (Umgebungsvariable → Binärverzeichnis wenn beschreibbar → FHS)
- [x] **L3** Avalonia-Shell (11.3.19, DataGrid 11.3.13) + Core-Ansichten. `PropertyGrid` ersetzt durch eine aus den Attributen erzeugte Einstellungsmaske (11 Abschnitte, 41 Felder)
- [x] **L4** Die drei Modul-Fenster portiert; CI-Matrix ubuntu + windows
- [x] **L5** WinForms vollständig entfernt `IBKRTrader.App`, `LauncherForm`, `UI/`, `Properties/Resources.*`
**Ergebnis:** Alle Projekte `net10.0` ohne Plattformbindung. `publish -r linux-x64` liefert Daemon (11 MB)
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.
**Offen → Roadmap [H4](ROADMAP.md) / [T3](ROADMAP.md):** IB Gateway kopflos betreiben (IBC + Xvfb)
eigene Baustelle, unabhängig vom Code; LiveCharts2 kommt mit den neuen Modulen (Avalonia deshalb
auf der 11er-Linie gepinnt).
### L6 Namensgebung bereinigt ✅ (2026-08-07)
- [x] `IBKRTrader.App.Avalonia`**`IBKRTrader.App`**. Das Suffix gab es nur, solange daneben eine
WinForms-`IBKRTrader.App` stand; seit L5 ist die weg. Assembly, Wurzel-Namensraum und die
`avares://`-Ressourcen-URI sind mitgezogen, Git erkennt alles als Umbenennung.
- [x] Die `global::Avalonia`-Qualifizierungen entfallen sie waren nur nötig, weil der Namensraum
`IBKRTrader.App.Avalonia` das gleichnamige Paket verdeckte.
- [x] **Inhaltlich falsch gewordene Aussagen berichtigt** das waren die eigentlichen Überbleibsel,
nicht die Kommentare: `.agents/rules/grundregeln.md` schrieb weiterhin „C# .NET 10 WinForms",
RichTextBox-Logging, `LauncherForm` und `PropertyGrid` vor und hätte die Portierung Stück für
Stück rückgängig gemacht. Dazu Core-Kommentare (`LogEntry`, `IWorker`, `ModuleView`) und Doku.
### DC Deploymentcenter-Integration ✅ (2026-08-23)
Konzept und Begründung: [archiv/KONZEPT-Deploymentcenter-Integration.md](archiv/KONZEPT-Deploymentcenter-Integration.md).
Schritte 08 der dortigen Reihenfolge sind umgesetzt; der Stand je Schritt steht in §9 dieses Konzepts.
- [x] Lizenz (Sperrbetrieb statt Abbruch), Watchdog-Heartbeat, Fehler-Stream inkl. globaler
Ausnahmebehandler, Update-Prüfung mit `ReleaseCredentials`, `setup.json` + Release-Pipeline.
Einbauort ist `IBKRTrader.Hosting/Deploymentcenter/` den Host teilen sich Shell und Daemon.
- [x] Vier Projekt-Befunde vorab bereinigt: Zugangsdaten aus `AppSettings` (P1), `AppPaths`-Rückfall
unter Windows (P2), globale Handler (P3), Version zentral in `Directory.Build.props` (P4).
- [ ] **Offen (P5) → Roadmap [D1](ROADMAP.md):** Die Gitea-CI checkt das Schwester-Repo `Deploymentcenter` nicht aus. Solange
das SDK als Cross-Repo-`ProjectReference` hängt, ist der CI-Lauf rot. Behebt sich mit
Schritt 10 (SDK als NuGet-Paket in der Gitea-Registry).
- [ ] **Offen (Schritt 9) → Roadmap [D2](ROADMAP.md) / [D5](ROADMAP.md):** Bugtracker-Baustein setzt voraus, dass das Projekt `ibkrtrader` im
DC-WebUI angelegt ist und ein Token mit `bugtracker:report` vorliegt (serverseitige Handarbeit).
- [ ] **Offen → Roadmap [D3](ROADMAP.md):** Das Anwenden eines gefundenen Updates ist nicht verdrahtet. `DcUpdateService.LaunchAgent`
ist fertig und dokumentiert, es fehlt der Aufrufer, der danach geordnet herunterfährt.
--- ---
@@ -138,7 +207,7 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin
IBKRTrader entspricht jetzt strukturell dem PolytraderSharp-Konzept: Multi-Projekt (Core + Modul + App + Tests), IBKRTrader entspricht jetzt strukturell dem PolytraderSharp-Konzept: Multi-Projekt (Core + Modul + App + Tests),
Generic Host + `IHostedService`, `IConfiguration`, `IModule`/`ModuleView`/`ShellUiHost`, EF Core (extern migriert), Generic Host + `IHostedService`, `IConfiguration`, `IModule`/`ModuleView`/`ShellUiHost`, EF Core (extern migriert),
Trading-Kern (Risk/Execution/Portfolio, `NullBroker`-Default), CongressTrading-Strategie, Security (Master-Key/AES-GCM). Trading-Kern (Risk/Execution/Portfolio, `NullBroker`-Default), CongressTrading-Strategie, Security (Master-Key/AES-GCM).
**Offen für später:** echte IBKR-Broker-Anbindung (Paper-Gateway), DB-Passwort-Rotation, EF-Schema anwenden. **Offen für später:** siehe [Roadmap](ROADMAP.md) die IBKR-Broker-Anbindung ist inzwischen gebaut, DB-Passwort-Rotation (B1) und EF-Schema (B3) stehen weiterhin aus.
--- ---
+15 -6
View File
@@ -92,13 +92,13 @@ komplette Prüfkette inklusive Handelsberechtigung und verwirft die Orde
Fehlte die Berechtigung, hätte IBKR die What-If-Order mit einem Berechtigungsfehler abgelehnt statt Fehlte die Berechtigung, hätte IBKR die What-If-Order mit einem Berechtigungsfehler abgelehnt statt
eine Margin zu liefern. Für **Realtime**-Optionskurse wäre zusätzlich ein OPRA-Abo nötig; ohne Abo eine Margin zu liefern. Für **Realtime**-Optionskurse wäre zusätzlich ein OPRA-Abo nötig; ohne Abo
kommen verzögerte Daten (siehe Marktdaten unten). Modul-Konzept: kommen verzögerte Daten (siehe Marktdaten unten). Modul-Konzept:
[konzepte/KONZEPT-Modul-OptionsWheel.md](konzepte/KONZEPT-Modul-OptionsWheel.md). [archiv/KONZEPT-Modul-OptionsWheel.md](archiv/KONZEPT-Modul-OptionsWheel.md).
> **What-If als Testwerkzeug:** Damit lässt sich der gesamte Orderpfad bis zur Broker-Annahme prüfen, > **What-If als Testwerkzeug:** Damit lässt sich der gesamte Orderpfad bis zur Broker-Annahme prüfen,
> ohne eine Position zu eröffnen. Der Adapter nutzt es nicht produktiv für Vorabprüfungen > ohne eine Position zu eröffnen. Der Adapter nutzt es nicht produktiv für Vorabprüfungen
> (Margin-Deckung vor einer echten Order) wäre es aber ein naheliegender Ausbau. > (Margin-Deckung vor einer echten Order) wäre es aber ein naheliegender Ausbau.
Welche Daten die API auf diesem Konto tatsächlich liefert und welche Strategien das trägt Welche Daten die API auf diesem Konto tatsächlich liefert und welche Strategien das trägt
steht gemessen in [konzepte/KONZEPT-Datenlage-und-Strategien.md](konzepte/KONZEPT-Datenlage-und-Strategien.md). steht gemessen in [archiv/KONZEPT-Datenlage-und-Strategien.md](archiv/KONZEPT-Datenlage-und-Strategien.md).
Kurz: Kurshistorie (30 Jahre), Volatilitätshistorie, Optionsketten und Griechen ja; Kurz: Kurshistorie (30 Jahre), Volatilitätshistorie, Optionsketten und Griechen ja;
Fundamentaldaten und Marktscanner nein (Abo nötig). Fundamentaldaten und Marktscanner nein (Abo nötig).
@@ -126,7 +126,16 @@ Später zu entscheiden:
[TWS-Setup-Checkliste](TWS-Setup-Checkliste.md), Abschnitt Verifikation. [TWS-Setup-Checkliste](TWS-Setup-Checkliste.md), Abschnitt Verifikation.
## Offen ## Offen
1. `PlaceOrderAsync` gegen das Paper-Konto verifizieren (Order → Fill → Buchung). Die offenen Punkte dieses Adapters werden seit dem 2026-08-23 in der
2. IBC für Auto-Login/Neustart einrichten (Server-Betrieb). [Roadmap](ROADMAP.md) gefuehrt, nicht mehr hier sie haengen mit Aufgaben aus anderen Konzepten
3. Asynchrone Fill-Verfolgung, siehe „Bekannte Grenze" oben. zusammen und standen deshalb doppelt. Es sind:
4. Entscheiden, ob die Marktdaten-Historie von der CP Web API auf `reqHistoricalData` wandert.
| Roadmap | Punkt |
|---|---|
| **H1** | `PlaceOrderAsync` gegen das Paper-Konto verifizieren (Order → Fill → Buchung) |
| **H2** | Asynchrone Fill-Verfolgung, siehe „Bekannte Grenze" oben zugleich Voraussetzung fuer OptionsWheel |
| **H4** | IBC fuer Auto-Login/Neustart einrichten (Server-Betrieb) |
| **T1** | Entscheiden, ob die Marktdaten-Historie von der CP Web API auf `reqHistoricalData` wandert |
Das **Design** und die **bekannten Grenzen** stehen weiterhin in diesem Dokument es bleibt die
technische Referenz des Adapters.
+235
View File
@@ -0,0 +1,235 @@
# IBKRTrader Roadmap
> **Dies ist das einzige Dokument, das sagt, was noch zu tun ist.** Bis zum 2026-08-23 war der
> offene Stand über sieben Konzepte, zwei Referenzdokumente und die Phasen-Checkliste der
> Architektur verteilt; dieselbe Aufgabe stand teils doppelt unter zwei Namen. Diese Roadmap führt
> alles zusammen. Die Quelldokumente bleiben vollständig erhalten und liegen unter
> [archiv/](archiv/) dort steht das **Warum** und die Herleitung, hier das **Was** und das **Wann**.
>
> Regel für die Zukunft: **Ein offener Punkt gehört hierher.** Steht er nur im Konzept, wird er
> vergessen. Wird er erledigt, bekommt er ein ✅ mit Datum die Zeile bleibt stehen, damit
> nachvollziehbar ist, wann etwas fertig wurde.
**Stand: 2026-08-23**
---
## Legende
| Zeichen | Bedeutung |
|---|---|
| ⬜ | Geplant, noch nicht angefangen |
| 🔶 | Angefangen, halbfertig der genaue Rest steht in der Zeile |
| ✅ | Fertig, mit Datum |
| 🧊 | **Zurückgestellt** bewusst nicht jetzt, Idee wird vorgehalten. Kein Versehen. |
| ❌ | **Verworfen** geprüft und entschieden. Nicht erneut vorschlagen, ohne den Grund zu entkräften. |
Die Spalte *Herkunft* nennt die ursprüngliche Kennung im Quelldokument, damit die Herleitung
auffindbar bleibt (z. B. `W-2` im OptionsWheel-Konzept, `P5` im Deploymentcenter-Konzept).
---
## Wo wir stehen
Gebaut und grün: Multi-Projekt-Aufbau nach PolytraderSharp, Generic Host, EF Core, Trading-Kern,
drei Module (CongressTrading, Accounting, Supervisor), Avalonia-Oberfläche auf Windows **und**
Linux, kopfloser Daemon, Deploymentcenter-Anbindung. 198 Tests, Build ohne Warnungen.
Nicht gebaut: **Es hat noch nie eine echte Order gegeben.** Der Broker-Adapter ist gegen das
Paper-Konto verifiziert Verbindung, Konto, Kurse, Optionskette, Greeks, What-If-Order aber
`PlaceOrderAsync` mit echter Ausführung steht aus. Alles, was danach kommt (Fill-Buchung,
Kapitalmodell, OptionsWheel), hängt an diesem einen Nachweis.
Zwei Sicherungen sind absichtlich getrennt und beide stehen auf „aus": `IBKR.UseTwsApi` schaltet
den Adapter, `TradingEnabled` den Handel.
---
## Stufe 1 Fundament schließen
*Diese Punkte blockieren oder gefährden alles Weitere. Sie kommen zuerst.*
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **B1** | **DB-Passwort rotieren.** Das echte Passwort der produktiven MariaDB stand als Vorgabewert in `AppSettings.cs` und liegt damit **in der Git-Historie** (ab Commit `ebeb035`). Der Quelltext trägt seit 2026-08-23 Platzhalter das ändert an der Historie nichts. Betriebsaktion, nur vom Betreiber ausführbar. | ⬜ | R6, DC-P1 |
| **B2** | **Gitea-Zugangstoken aus der Remote-URL nehmen.** `git remote -v` zeigt den Token im Klartext in der URL; er landet so in jedem Log, jeder Fehlermeldung und jedem Screenshot. Auf Credential-Helper oder SSH umstellen. | ⬜ | Befund 2026-08-23 |
| **B3** | **EF-Schema auf die Datenbank anwenden** (`dotnet ef database update`). Migrationen werden bewusst extern angewendet, nie zur Laufzeit. | ⬜ | R6 |
| **D1** | **CI wieder grün bekommen.** Das Deploymentcenter-SDK hängt als Cross-Repo-`ProjectReference` am Schwester-Repo; `.gitea/workflows/build.yml` checkt nur IBKRTrader aus, also ist der CI-Lauf seit dem 2026-08-23 rot. Das war die bewusst in Kauf genommene Folge der Interimslösung. Auflösung: `dotnet pack` im Deploymentcenter-Repo → Gitea-Registry → `PackageReference` statt Cross-Repo-Pfad → CI-Workflow anpassen. | ⬜ | DC-P5, DC-Schritt 10 |
| **H1** | **`PlaceOrderAsync` gegen das Paper-Konto verifizieren** echte Order, echter Fill, korrekte Buchung. Der einzige Teil des Broker-Adapters, der nie unter realen Bedingungen lief. | ⬜ | R7, IBKR-Integration §Offen 1 |
---
## Stufe 2 Den Handelspfad belastbar machen
*Ohne das ist kein unbeaufsichtigter Betrieb zu verantworten.*
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **H2** | **Asynchrone Order- und Fill-Verfolgung.** `PlaceOrderAsync` ist synchron gedacht: Ausführung oder Fehlschlag innerhalb `OrderTimeoutSeconds`. Eine Limit-Order im Buch oder eine Market-Order außerhalb der Handelszeiten gilt damit als Fehlschlag **ist bei IBKR aber weiter aktiv.** Nötig: Order-Zustand persistieren (`core_order_state`), `orderStatus`/`execDetails` dauerhaft mitschreiben, Fill nachbuchen, Zustand über einen App-Neustart hinweg rekonstruieren. Ändert den Seam. **Zugleich Voraussetzung für OptionsWheel (dort `W-2`)** die Aufgabe stand doppelt in zwei Konzepten. | ⬜ | IBKR-Integration, W-2 |
| **H3** | **IBKR-Zugangsdaten mit `EncryptedStringConverter` ablegen.** Der Konverter ist gebaut und getestet, die Credentials nutzen ihn noch nicht. | ⬜ | R7 |
| **H4** | **IBC/IBController einrichten** IBKR erzwingt 2FA und täglichen Neustart des Gateways. Ohne Auto-Login gibt es keinen unbeaufsichtigten Betrieb. Für Linux zusätzlich kopflos (IBC + Xvfb). Reine Betriebsarbeit, unabhängig vom Code. | ⬜ | L-Ergebnis, IBKR-Integration §Offen 2 |
| **T1** | **Entscheiden, welcher IBKR-Zugangsweg bleibt.** Es gibt zwei parallele: den TWS-API-Adapter (`Trading/Ibkr/`, der handelnde Pfad) und `IBKRGatewayService` (Client Portal REST, versorgt Instrument-Sync, Kurshistorie und den Watchdog-Heartbeat). Zwei Broker-APIs bedeuten zwei Fehlerbilder, zwei Authentifizierungen und zwei Betriebsvoraussetzungen. Entweder die Historie auf `reqHistoricalData` umziehen und den REST-Weg aufgeben oder die Doppelung ausdrücklich begründen. | ⬜ | IBKR-Integration §Offen 4, Befund 2026-08-23 |
---
## Stufe 3 Options-Fundament im Core
*Reine Core-Arbeit. Nützt allen Modulen, nicht nur dem Wheel. Der Aktienpfad bleibt unverändert
neue Felder sind optional, `Kind = Stock` ist der Default.*
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **O1** | `InstrumentKind`/`OptionSpec`, `IbkrMapping.Option`, Positionen mit Multiplikator. Heute rechnet `Position.Notional` als `Quantity × AvgPrice` bei Optionen um Faktor 100 falsch, womit **jedes Risikolimit wirkungslos wäre**. | ⬜ | W-0 |
| **O2** | Optionskette + Greeks im Core (`reqSecDefOptParams`, `tickOptionComputation`). Gegen das Paper-Gateway bereits nachgemessen: 24 Verfallstermine, 127 Strikes, Greeks auch mit **verzögerten** Daten über die Tick-Felder 8083 (Feld 83 = Modell ist die maßgebliche Variante). | ⬜ | W-1 |
| **O3** | *(= **H2**, siehe Stufe 2 dieselbe Aufgabe, hier als Wheel-Voraussetzung geführt)* | ⬜ | W-2 |
| **O4** | Positionsabgleich (Zuteilung & Verfall ändern Positionen **ohne** Order von uns) + `RiskService` um sell-to-open und Deckungsprüfung erweitern. Heute lehnt `EvaluateSell` einen Verkauf ohne Bestand grundsätzlich ab das ist die Kernoperation des Wheels. **Niemals nackt:** Short Call nur mit 100 freien Aktien je Kontrakt, Short Put nur mit reserviertem Cash über Strike × 100. Die Prüfung gehört in den Core, damit ein Modulfehler keine ungedeckte Option schreiben kann. | ⬜ | W-3 |
| **O5** | **IV-Rank als Core-Baustein**, nicht als Modul-Interna. Die Kennzahl braucht jede Prämienstrategie und sie gehört neben die Kurshistorie in die Datenschicht. | ⬜ | Datenlage §4 |
---
## Stufe 4 Kapital- und Buchmodell
*Wird scharf, sobald ein **zweites** Modul handelt. Heute handelt nur CongressTrading; mit dem
Wheel sind es zwei, die sich ein Konto und ein Guthaben teilen. Das vollständig ausgearbeitete
Konzept liegt im [Archiv](archiv/Kapital-und-Buchmodell.md) es ist Referenz, gegen die die
Umsetzung geprüft wird, und war nie umgesetzt.*
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **K1** | **Bücher + Eigentumsregeln.** Jede Position hat genau einen Eigentümer; kein Modul fasst die Position eines anderen oder eine manuell angelegte an. Im Core erzwungen, nicht per Konvention. | ⬜ | Kapitalmodell §2 |
| **K2** | **Ein Pool, Obergrenzen.** Kein Kapitaltransfer zwischen Büchern, niemals aktives Schließen zum Balancieren. Limit-Verletzung ist ein hartes Gate mit strukturiertem Feedback die Reaktion entscheidet die Strategie, nicht der Core. | ⬜ | Kapitalmodell §3, §6 |
| **K3** | **Margin-Sperre in vier Schichten:** richtige Bemessungsgrundlage (`min(TotalCashValue, AvailableFunds)` Hausreserve offene Reservierungen), kein Short, Währungstrennung, Watchdog. Modul-Handel ist strikt cash-only; manueller Handel darf Margin nutzen. | ⬜ | Kapitalmodell §4 |
| **K4** | **Reservierungen über den Order-Lebenszyklus** inkl. Crash-Recovery: beim Start alle nicht-terminalen Reservierungen gegen die offenen Broker-Orders abgleichen. Hängt an **H2**. | ⬜ | Kapitalmodell §5 |
| **K5** | **Abgleich (Reconciliation)** gegen den Broker: `Broker < Ledger` heißt Break betroffene Bücher und Symbol für neue Orders sperren, Alarm. Unzugeordnete Positionen in Quarantäne, Eskalation nach 30 Minuten mit Backoff. | ⬜ | Kapitalmodell §8, §9 |
| **K6** | **Benachrichtigungen** über Outbox + Sink-Abstraktion. Zielkanal Matrix, Telegram optional. | ⬜ | Kapitalmodell §10 |
| **K7** | **Startwerte festlegen** und in `settings.example.json` dokumentieren: `MaxSymbolPercent`, `HouseReserve`, FX-Haircut, Slippage-Puffer, Reservierungs-TTL, Mindestordergröße je Buch, Handelsplatz-Whitelist je Währung. Beim Bauen zu entscheiden. | ⬜ | Kapitalmodell §15 |
---
## Stufe 5 Modul OptionsWheel
*Erst ab hier entsteht das Modul selbst. Vollautomatisch von Anfang an Sicherungen sind Schalter
und Limits, keine Klick-Freigabe. Strike-Wahl delta-basiert im Band 0,150,30. Nur Watchlist,
kein Screening.*
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **W1** | Modul-Gerüst: `IModule`, `ow_`-DbContext + Migration, UI-Tabs, Watchlist. Abschluss: App startet, `--smoke-ui` grün, **kein Handel**. | ⬜ | W-4 |
| **W2** | Reine Strategie-Logik + Tests: `StrikeSelector`, Zustandsautomat je Ticker, `RollDecider`, `PremiumMath`, `CoverageCalculator`. Hohe Testabdeckung ohne Broker. | ⬜ | W-5 |
| **W3** | Verdrahtung + vollautomatischer **Paper**-Betrieb über mehrere Verfallszyklen. Abschluss: mindestens ein vollständiger Wheel-Durchlauf im Paper. | ⬜ | W-6 |
| **W4** | **Earnings-Sperre über den IV-Behelf.** Keine neuen Legs, wenn die IV des Basiswerts deutlich über ihrem 30-Tage-Mittel liegt. Die saubere Lösung (echte Termine für Quartalszahlen) ist über die TWS API **nicht** erreichbar Fehler 10358, Refinitiv-Abo nötig. Einzige Stelle, an der uns eine externe Quelle ernsthaft fehlt. | ⬜ | W-§7.4, Datenlage §2 |
| **W5** | **Accounting-Anschluss für Optionen:** eigene Buchungskategorien im `AccountingClassifier` für Prämien, Zuteilungen und Abrufe. Der `RealizedPnlEngine` (FIFO) kennt heute weder Multiplikator noch die Einstandsverschiebung durch Zuteilung. Arbeit im Accounting-Modul, nicht im Wheel. | ⬜ | W-§7.5 |
| **W6** | **Live-Freigabe.** Eigene Entscheidung nach W3, kein technischer Schritt. | ⬜ | W-7 |
---
## Laufende Bahnen
*Hängen an keiner Stufe und können jederzeit dazwischen laufen.*
### Auslieferung / Deploymentcenter
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **D2** | **Projekt `ibkrtrader` im DC-WebUI anlegen** + Token mit `watchdog:ping` und `bugtracker:report`. Serverseitige Handarbeit; ohne sie bleiben Heartbeat, Lizenz und Bugtracker wirkungslos. | ⬜ | DC-Schritt 9 |
| **D3** | **Update-Anwenden verdrahten.** Die Update-**Prüfung** läuft beim Start. `DcUpdateService.LaunchAgent` ist fertig und dokumentiert (`exitCurrentApp` bewusst immer `false`), aber **kein Aufrufer** fährt danach geordnet herunter. Gefundene Updates werden also nie eingespielt. | 🔶 | DC-Schritt 7 |
| **D4** | **Erstes echtes Release fahren.** Pipeline (`scripts/release.*`, `setup.json`) steht, ist aber nie gelaufen. Dabei entsteht `packager.config.json` unter `.dc-tools/`. **Achtung:** Die `preservePatterns` müssen beim *ersten* Release stimmen ein Update, das `settings.json` überschreibt, nimmt einer laufenden Installation Datenbank, Token und Flex-Zugang gleichzeitig. Ebenso muss der erste Build bereits den `licenseKey` mitgeben, sonst fällt die Tür hinter dem ersten Release zu (bei Predictalytics genau so passiert). | ⬜ | DC-Schritt 8, P6, D2 |
| **D5** | **Bugtracker-Baustein** nach `AGENTS.md` / `.agents/rules`. Braucht **D2**. | ⬜ | DC-Schritt 9 |
| **D6** | **Lizenzfenster für die Shell.** `LicenseGuard` läuft mit `allowPrompt: false` weder Shell noch Daemon dürfen auf eine Konsoleneingabe warten, die nie kommt. Ein eigenes Fenster für die Desktop-Shell war als Folgeschritt vorgesehen. | ⬜ | DC §5.2 |
### Accounting
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **A1** | **Live-IBKR-Flex-Abruf** (Token + Query-Id) und Balance-Anker. Heute liegen dort Offline-Null-Stubs: Das Modul läuft vollständig und bucht dabei korrekt nichts. Ohne diesen Schritt entstehen **keine echten Buchungen** das Modul ist lauffähig, aber nicht in Betrieb. | ⬜ | Accounting §6 |
| **A2** | **EZB-FX-Ingest** (`acc_fx_rates` füllen) für die EUR-Ansicht. USD als Basis ist sofort verfügbar. | ⬜ | Accounting §6 |
| **A3** | **Steuerschicht.** Jurisdiktion (DE-Kapitalertragsteuer / US Form 8949) ist **nicht festgelegt**. Der neutrale Ledger und die Abrechnung gelten unabhängig davon; die Steuer-Engine ist als klar abgetrennter Platzhalter angelegt. Das Jurisdiktionsprofil wird einmalig gesetzt und danach in der DB verankert. **Keine Steuerberatung.** | ⬜ | Accounting §6, Kapitalmodell §11 |
### Supervisor
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **S1** | **Counterfactual-Kursauflösung für Aktien** (späterer Kurs vs. Signalpreis) Interface und Stub sind vorhanden, die Auflösung fehlt. | ⬜ | Supervisor §offen |
| **S2** | **Externer Versand des Tagesberichts.** Heute nur Persistenz und Log. Sollte denselben Outbox-/Sink-Weg nehmen wie **K6**, statt einen zweiten zu bauen. | ⬜ | Supervisor §offen |
### Technische Schulden
| # | Aufgabe | Stand | Herkunft |
|---|---|---|---|
| **T2** | **Log-Level ist nicht konfigurierbar.** `LoggingService.SetMinLevel` ist der einzige Setter und wird nie gerufen; `_minLevel` steht damit fest auf `Info`, und da `Info = 0` der kleinste Wert ist, filtert die Prüfung `level < _minLevel` nie etwas. Entweder an die Einstellungen anbinden oder den Filter aufgeben. | ⬜ | Befund 2026-08-23 |
| **T3** | **Es gibt kein einziges Diagramm.** LiveCharts2 ist in der NuGet-Allowlist vorgesehen und **Avalonia ist deswegen auf der 11er-Linie festgehalten** (11.3.19 / DataGrid 11.3.13). Diese Pinnung kostet uns Avalonia 12, ohne dass bisher ein Diagramm existiert. Entweder mit den neuen Modulen einlösen oder die Pinnung aufgeben. | ⬜ | L-Ergebnis, grundregeln |
---
## 🧊 Zurückgestellt vorgehalten, aber nicht jetzt
*Bewusste Entscheidungen, keine Versäumnisse. Jede Zeile nennt, **was sie wieder aktuell macht**.*
| Idee | Warum nicht jetzt | Wird aktuell, wenn … | Herkunft |
|---|---|---|---|
| **Trendfolge-/Momentum-Modul** auf Tagesbasis als zweites Standbein | Datenlage ist komfortabel (10 J Tagesbars, dividendenbereinigt), das Risiko liegt in der Strategie. Aber ein zweites handelndes Modul vor dem Kapitalmodell wäre fahrlässig. | Wheel im Paper läuft **und** Stufe 4 steht | Datenlage §4 |
| **Marktdaten-Abo US-Realtime** (NYSE/AMEX/NASDAQ) | Schaltet Scanner und Realtime frei. Für Tages- und Prämienstrategien **nicht nötig** unsere Signale kommen aus abgeschlossenen Bars, die 15-Minuten-Verzögerung ist dabei irrelevant. | wir Screening oder Intraday wollen | Datenlage §4 |
| **OPRA-Abo** (Optionen-Realtime) | Verbessert die Ausführungsqualität beim Wheel; für die Strike-Auswahl nachweislich nicht erforderlich (Greeks funktionieren verzögert). | die Ausführungsqualität im Paper-Betrieb messbar stört | W-§7.3 |
| **Refinitiv-Fundamentaldaten** | Der Kandidat mit dem größten qualitativen Sprung: löst die Earnings-Sperre und öffnet fundamentale Ansätze. Kostet aber Geld für ein System, das noch nie eine Order platziert hat. | der IV-Behelf (**W4**) sich als zu unscharf erweist | Datenlage §4 |
| **Self-Cross-Netting** (Modul A kauft, B verkauft dasselbe Symbol) | Bei unserer Handelsfrequenz unrealistisch. **Billige Vorstufe stattdessen:** ist im Order-Gateway eine gegenläufige Order für dasselbe Symbol pending, wird protokolliert und gewarnt der Lock ist ohnehin da. Das liefert Daten darüber, ob das Problem je real wird. | die Warnung tatsächlich anschlägt | Kapitalmodell §14 |
| **Corporate Actions** (Splits, Spin-offs) in der Buchzuordnung | Vorerst über den Flex-Abgleich als Break sichtbar, manuelle Zuordnung. | ein Break real auftritt | Kapitalmodell §14 |
| **Web-UI** über die interne REST-API | Die REST-API existiert, ein Web-UI ist reine Zusatzarbeit ohne Betriebsnutzen, solange Shell und Daemon reichen. | Fernzugriff nötig wird | grundregeln |
| **Desktop-Distribution der Shell** über die Release-Pipeline | Die Vorlage nimmt genau eine csproj für alle Runtimes, und das Skript bleibt laut Anleitung unverändert. Release-Kandidat ist deshalb `IBKRTrader.Daemon`. | die Shell auf fremden Rechnern laufen soll | release.config.json |
| **Ungeprüfte Datenfragen**: `reqNewsArticle` (Volltext), Reichweite der Nachrichtenhistorie, Markttiefe (`reqMktDepth`), leeres `reqHistogramData`, Verlässlichkeit verzögerter Greeks außerhalb der Handelszeiten, Ratenbegrenzung bei Historienabrufen | Keine dieser Fragen blockiert die geplanten Strategien. | eine Strategie sie braucht die Ratenbegrenzung schon bei einem nächtlichen Watchlist-Abruf | Datenlage §5 |
---
## ❌ Verworfen geprüft und entschieden
*Damit nichts davon in sechs Monaten erneut als gute Idee auftaucht.*
| Idee | Warum verworfen |
|---|---|
| **Echte IBKR-Sub-Accounts** (Advisor-/Family-Struktur) zur brokerseitigen Trennung der Bücher | Erfordert Kontotypwechsel und feste Vorabaufteilung des Kapitals deutlich unflexibler als virtuelle Bücher, die denselben Zweck erfüllen. |
| **`InvariantGlobalization=true`** für ein schlankes Linux-Image | In der Portierungsanalyse noch empfohlen, **wäre ein Fehler gewesen**: ohne ICU fällt die Auflösung von Windows-Zeitzonen-IDs aus und die feste `de-DE`-Formatierung des PDF-Exports kippt auf invariant. Beides lautlos. Der Daemon setzt es ausdrücklich auf `false`. |
| **Fundamentales Screening** (Value, Quality, Growth) | Keine Fundamentaldaten über die TWS API. |
| **Earnings-Strategien** (Straddle vor Zahlen, Post-Earnings-Drift) | Keine Termine für Quartalszahlen erreichbar (Fehler 10358). |
| **Marktweite Anomalie-Suche / Screener-getriebene Auswahl** | Der Marktscanner ist ohne Realtime-Abo gesperrt. Jede Strategie arbeitet auf einer gepflegten Watchlist. |
| **Daytrading, Scalping, Orderbuch-Strategien** | Verzögerte Kurse (~15 Min). Ausführung träfe den Markt zu spät. |
| **Gap-Strategien auf Eröffnung** | Eröffnungskurs kommt verzögert nur mit Realtime-Abo sinnvoll. |
| **Strike-Wahl über prozentualen Abstand** statt delta-basiert | Als Ersatzlösung gedacht, falls Greeks mit verzögerten Daten nicht funktionieren. Sie funktionieren (am 2026-08-04 gemessen). Bleibt im `StrikeSelector` nur als Rückfalllinie. |
| **Agent-zu-Agent-Orchestrierung im Supervisor** | Bewusst nicht: Profile sind System-Prompt + Tool-Subset über *einer* Infrastruktur. Einfacher und nachvollziehbarer. |
| **Modulzuordnung als Korrektur des Steuerbuchs** | Harte Regel: Das Managementbuch beeinflusst das Steuerbuch **niemals**. Die Zuordnung ist eine optionale, nicht-autoritative Beistelltabelle fehlt sie oder ist sie falsch, ändert sich am Steuerergebnis exakt nichts. |
---
## Fertig der Weg bis hierher
*Kurzfassung. Die vollständigen Phasen-Checklisten stehen weiterhin in [ARCHITECTURE.md](ARCHITECTURE.md).*
| Abschnitt | Inhalt | Fertig |
|---|---|---|
| **R1R7** | Kurskorrektur auf das PolytraderSharp-Konzept: Multi-Projekt, Generic Host, EF Core, Trading-Kern, CongressTrading-Strategie, Security, Dashboard, TWS-Broker-Adapter | 2026-07 |
| **S-0S-4** | Supervisor: Datenfundament (Entscheidungsjournal, Order-Events, `SignalId`, JSONL-Logs), Dossiers, OpenRouter-Agent mit read-only Tools, Berichte, MCP-Light | 2026-07-30 |
| **Accounting** | `acc_`-Schema, append-only Ingest, Klassifizierung, Abrechnung, FX, CSV-/PDF-Export mit Offline-Null-Stubs | 2026-07-30 |
| **L0L6** | Linux-Portierung: Avalonia statt WinForms, Betriebszeitzone, kopfloser Daemon, geteiltes Hosting, WinForms vollständig entfernt, Namensgebung bereinigt | 2026-08-07 |
| **DC 08** | Deploymentcenter: Lizenz mit Sperrbetrieb, Watchdog-Heartbeat, globale Ausnahmebehandler + Fehler-Stream, Update-Prüfung, `setup.json`, Release-Pipeline | 2026-08-23 |
| **Frühjahrsputz** | Toter Code entfernt, ungenutzte Symbole und Paketmuster raus, Dokumentenstand an die Wirklichkeit angeglichen | 2026-08-23 |
---
## Archiv
Die Quelldokumente sind **vollständig erhalten** und liegen unter [archiv/](archiv/). Sie werden
nicht mehr gepflegt der Stand steht hier , aber sie tragen die Herleitung, die Messwerte und die
Begründungen, die eine Roadmap nicht fassen kann.
| Dokument | Was darin steht, das hier fehlt |
|---|---|
| [Kapital-und-Buchmodell.md](archiv/Kapital-und-Buchmodell.md) | Die vollständige Spezifikation: die drei Wahrheiten, Eigentumsregeln, Reservierungs-Lebenszyklus, Invarianten und Testkatalog. Referenz für Stufe 4. |
| [KONZEPT-Modul-OptionsWheel.md](archiv/KONZEPT-Modul-OptionsWheel.md) | Zustandsautomat, Regelwerk mit Vorgabewerten, `ow_`-Datenmodell, Sicherungen. Referenz für Stufe 5. |
| [KONZEPT-Datenlage-und-Strategien.md](archiv/KONZEPT-Datenlage-und-Strategien.md) | **Gegen das laufende Paper-Gateway gemessen**, nicht aus der Doku übernommen: was die TWS API liefert und welche Strategien das trägt. Grundlage der Zurückgestellt- und Verworfen-Listen. |
| [KONZEPT-Deploymentcenter-Integration.md](archiv/KONZEPT-Deploymentcenter-Integration.md) | Die bewussten Abweichungen vom DC-Leitfaden (Sperrbetrieb statt Prozessende, `exitCurrentApp: false`) samt Begründung, die Befunde P1P6 und D1D7. |
| [KONZEPT-Linux-Portierung.md](archiv/KONZEPT-Linux-Portierung.md) | Die Analyse vor dem Umbau: Fundstellenverzeichnis, Aufwandsschätzung und was anders kam als geschätzt. |
| [KONZEPT-Modul-Accounting.md](archiv/KONZEPT-Modul-Accounting.md) | Leitprinzipien (unabhängige Quelle, Idempotenz, append-only), Datenbeschaffung über Flex Query. |
| [KONZEPT-Modul-Supervisor.md](archiv/KONZEPT-Modul-Supervisor.md) | S-0 bis S-4 im Detail, Tool-Registry, Sicherheitsgrenzen des Agenten. |
**Weiter gepflegt** werden sie sind Referenz, keine Planung:
[ARCHITECTURE.md](ARCHITECTURE.md) (Aufbau und Phasen-Historie),
[IBKR-Integration.md](IBKR-Integration.md) (Adapter-Design und Grenzen),
[TWS-Setup-Checkliste.md](TWS-Setup-Checkliste.md) (Einrichtung eines neuen Systems).
+100 -1
View File
@@ -68,7 +68,9 @@ der Adapter ab und bleibt inaktiv, statt auf dem falschen Konto zu handeln.
## 7. Verifikation ## 7. Verifikation
- [ ] Port erreichbar? `Test-NetConnection 127.0.0.1 -Port 4002``TcpTestSucceeded: True` - [ ] Port erreichbar?
Windows: `Test-NetConnection 127.0.0.1 -Port 4002``TcpTestSucceeded: True`
Linux: `ss -ltn '( sport = :4002 )'` bzw. `nc -zv 127.0.0.1 4002`
- [ ] API-Verbindung: Verbindungstest ausführen (Konto-ID, NetLiquidation, Positionen müssen - [ ] API-Verbindung: Verbindungstest ausführen (Konto-ID, NetLiquidation, Positionen müssen
kommen). Hängt der Handshake > 10 s → Trusted IP fehlt (Punkt 3) oder Popup offen. kommen). Hängt der Handshake > 10 s → Trusted IP fehlt (Punkt 3) oder Popup offen.
@@ -108,6 +110,103 @@ bei jeder Kursanfrage der Normalfall, die Kurse kommen danach trotzdem.
- Einstellungsänderungen im API-Dialog immer mit **Übernehmen/OK** abschließen; solange der - Einstellungsänderungen im API-Dialog immer mit **Übernehmen/OK** abschließen; solange der
Dialog offen ist, gelten sie nicht. Dialog offen ist, gelten sie nicht.
## Unterschiede unter Linux
Gilt für TWS bzw. IB Gateway auf einem Linux-Host. **Am Protokoll ändert sich nichts**: dieselbe
Java-Anwendung, dieselben Ports, derselbe Einstellungsdialog, dieselbe API-Version. Der Adapter
(`IBKRTrader.Core/Trading/Ibkr/`) braucht keine Anpassung das TWS-API-Paket referenziert nur
`mscorlib`, `System` und `System.Core`, keine Windows-Assembly, und der Core baut fehlerfrei für
`linux-x64` (geprüft 2026-08-04).
Anders sind Betrieb und Umgebung:
| Thema | Windows | Linux |
|---|---|---|
| Installationsverzeichnis | `C:\Jts` | `~/Jts` |
| Einstellungen (inkl. Trusted IPs) | verschleiertes Unterverzeichnis je Login | genauso, unter `~/Jts` |
| Start | Desktop-Sitzung vorhanden | **X-Server nötig** headless: `Xvfb` |
| Auto-Login/Neustart | IBC als geplanter Task | IBC als **systemd**-Unit (der besser unterstützte Weg) |
| Port prüfen | `Test-NetConnection` | `ss -ltn` / `nc -zv` |
### Worauf konkret zu achten ist
- [x] **Grafische Sitzung.** TWS ist eine GUI-Anwendung und startet ohne Display nicht.
**Unser Aufbau:** Ubuntu-Desktop-VM mit gespiegelter Bildschirmfreigabe per RDP also eine
echte, dauerhaft laufende X-Sitzung. Damit entfällt die Xvfb-Frage, und der Einstellungsdialog
(Punkt 3) ist jederzeit erreichbar. **Wichtig:** gespiegelte Freigabe, **keine**
RDP-Remoteanmeldung eine eigene Anmeldesitzung startet einen zweiten Desktop, in dem das
laufende TWS nicht sichtbar ist und die Sitzung beim Abmelden mitgeht.
(Nur für einen echten headless Server wäre `Xvfb` + `x11vnc` nötig.)
- [ ] **Schriftarten installieren** (`fontconfig` plus z.B. `dejavu`). Fehlen sie, startet die
Java-Oberfläche gar nicht oder rendert leer die häufigste Stolperfalle bei schlanken Images.
- [ ] **Einstellungen neu setzen, nicht kopieren.** Die API-Einstellungen hängen am Login-Profil
unter `~/Jts`. Auf dem neuen Host Punkte 35 dieser Checkliste einmal komplett durchgehen.
- [ ] **Offline-Installer bevorzugen.** Der selbstaktualisierende Installer kann TWS unbemerkt auf
eine neue Version heben, die eine andere API-Serverversion spricht.
- [ ] **Zeitzone je Instanz setzen.** `Trading.ApplicationTimeZoneId` steuert die Betriebszeitzone
und ist **unabhängig** von der des Hosts. EU-Instanzen `Europe/Berlin`, US-Instanzen
`America/New_York`. TWS meldet Ausführungszeiten teils mit, teils ohne Zonenangabe ohne
Angabe greift dieser Wert als Rückfall. Wie sich das im Betrieb beobachten lässt, steht
unten unter „Zeitverhalten beobachten".
- [ ] **ICU im Image sicherstellen** (`libicu` / `icu-data-full`). Die Zonenauflösung nutzt
IANA-IDs (`US/Eastern`); ohne ICU wirft `FindSystemTimeZoneById`. Die Projekte setzen
deshalb bewusst `InvariantGlobalization=false` siehe `IBKRTrader.Daemon.csproj`.
- [ ] **Schreibrechte des Dienstbenutzers** auf `~/Jts` prüfen. Bei systemd mit eigenem `User=`
braucht dieser ein echtes Home-Verzeichnis. Für die App selbst regelt `AppPaths` die
Ablage bereits FHS-konform.
- [ ] **Java-Heap** in `tws.vmoptions` (im Installationsverzeichnis) prüfen, wenn viele
Instrumente abonniert werden gleiche Datei wie unter Windows, anderer Pfad.
- [ ] **Wenn TWS und App auf verschiedenen Rechnern laufen:** „Nur Verbindungen vom lokalen Host“
aus, IP der App-Maschine als Trusted IP eintragen und weil der API-Socket **unverschlüsselt**
ist, nur über VPN oder SSH-Tunnel, nie offen übers Netz.
> Diese Liste beruht auf Erfahrungswerten zum TWS-Betrieb, **nicht** auf einer Messung auf einem
> Linux-Host anders als der Rest dieser Checkliste. Beim ersten Aufsetzen entsprechend prüfen
> und die Punkte hier korrigieren.
## Zeitverhalten beobachten (Paper-Phase)
Zwischen einer EU- und einer US-Instanz ist die Zeitzone die gefährlichste Stelle: Ein falscher
Wert wirft keinen Fehler, er verschiebt nur Buchungszeiten. Damit das während der Paper-Phase
auffällt statt später im Echtbetrieb, schreibt der Adapter drei Dinge mit (Modul `IBKR`).
**1. Zeitkontext bei jedem Verbindungsaufbau** eine Info-Zeile, die den gesamten Rahmen festhält:
```
Zeitkontext: Betriebszeitzone Europe/Berlin, Systemzeitzone Europe/Berlin,
TWS-Serverzeit 2026-08-04 15:39:18Z, Uhrenversatz +0.2 s.
```
Damit lässt sich jeder spätere Zeitfehler an einer Zeile aufklären, statt im Nachhinein zu raten,
wie die Instanz konfiguriert war.
**2. Uhrenversatz gegen den TWS-Server.** Mehr als 5 s Abweichung ergeben eine **Warnung**. In
virtuellen Maschinen ist eine driftende Uhr ein häufiger Fehler besonders nach Snapshots oder
Pausieren der VM.
**3. Herkunft jedes Ausführungs-Zeitstempels.** Nach jedem Abruf steht im Log, wie viele Zeitpunkte
TWS **mit** Zonenangabe gemeldet hat und wie viele über die Betriebszeitzone **angenommen** wurden:
```
Zeitstempel von 2 Ausführung(en): 0 mit gemeldeter Zone, 2 über die Betriebszeitzone Europe/Berlin.
```
Beobachtungsstand 2026-08-04: `execDetails` lieferte die Zeit **ohne** Zonenangabe
(`"20260804 17:39:18"`), `reqCompletedOrders` dagegen **mit** (`"... Europe/Berlin"`). Der
Normalfall beim Ausführungsabruf ist also die Annahme genau deshalb wird sie gezählt.
### Worauf zu achten ist
| Logmeldung | Bedeutung | Reaktion |
|---|---|---|
| `Uhrenversatz` über 5 s | Host-Uhr läuft auseinander | Zeitsynchronisation der VM prüfen |
| `... wurden gegen die Betriebszeitzone X gerechnet, das System läuft aber auf Y` | Betriebs- und Systemzeitzone gehen auseinander | Prüfen, gegen welche Uhr TWS meldet; einmal mit dem TWS-Fenster gegenlesen |
| `Ausführung(en) mit unlesbarem Zeitstempel` | TWS-Format hat sich geändert | Defekt `IbkrMapping.ResolveExecutionTime` anpassen |
**Gegenprobe beim Aufsetzen einer Instanz:** Eine Ausführung im TWS-Fenster ansehen und die dort
angezeigte Uhrzeit mit der gebuchten vergleichen. Stimmen beide, ist die Zeitzone richtig gesetzt.
Das kostet zwei Minuten und ist die einzige verlässliche Probe alles andere ist Papier.
## Unterschiede Live-Betrieb (später) ## Unterschiede Live-Betrieb (später)
| Punkt | Paper | Live | | Punkt | Paper | Live |
@@ -1,3 +1,13 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es **gegen das laufende Paper-Gateway gemessen** ist und nicht aus der IBKR-Doku uebernommen.
> Die Zurueckgestellt- und Verworfen-Listen der Roadmap stuetzen sich auf diese Messwerte. Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# Analyse: Datenlage über die TWS API und welche Strategien sie trägt # Analyse: Datenlage über die TWS API und welche Strategien sie trägt
> Stand: 2026-08-04. **Alle Angaben in Abschnitt 1 und 2 sind gegen das laufende Paper-Gateway > Stand: 2026-08-04. **Alle Angaben in Abschnitt 1 und 2 sind gegen das laufende Paper-Gateway
@@ -0,0 +1,672 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) (Bahn „Auslieferung / Deploymentcenter") dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es die bewussten Abweichungen vom DC-Leitfaden begruendet Sperrbetrieb statt
> Prozessende, `exitCurrentApp: false` und die Befunde P1P6 und D1D7 nachweist. Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# KONZEPT: Deploymentcenter-Integration
> Stand: 2026-08-14 · Deploymentcenter-Version **2.5.1** · Quelle: `J:\Softwareprojekte\Deploymentcenter\docs`,
> `GET /api/updateservice/v1/changelog?since=2.4`
> Zielprojekt: IBKRTrader (App + Daemon), Projekt-Slug **`ibkrtrader`**
> SDK-Zielversion: `Deploymentcenter.Client` **2.5.1** (`DeploymentcenterSdk.Version` mitführen, damit
> künftige Anbindungen über `?since=2.5.1` direkt sehen, was sich seither geändert hat)
> **Nachtrag 2026-08-14:** Die ursprüngliche Fassung dieses Dokuments (Stand DC 2.4) enthielt sieben
> Befunde/Bedenken in §8, die dem Deploymentcenter-Entwickler mitgeteilt wurden. Alle sieben sind mit
> 2.5.0/2.5.1 behoben — Einzelheiten weiterhin in §8, jetzt mit Fix-Vermerk. Zwei Neuerungen aus
> derselben Runde gehen über die ursprünglichen Befunde hinaus und sind in §5.1, §7.2 und §2.2
> eingearbeitet: `exitCurrentApp` ist jetzt als harter Kill dokumentiert, und `BuildInfo.targets` liegt
> im NuGet-Paket selbst.
Das Deploymentcenter (`https://dc.mhdf.de`) bündelt fünf Dienste, die IBKRTrader bisher
alle nicht hat: **Lizenz**, **Watchdog**, **Error-Reporting**, **Bugtracker** und
**UpdateService inkl. Erstinstallation**. Dieses Dokument beschreibt, in welcher
Reihenfolge sie angebunden werden, was dabei am Projekt vorher aufgeräumt werden muss
und wo die vom Deploymentcenter vorgeschlagenen Muster für ein *handelndes* System
nicht ohne Weiteres passen.
---
## 0. Ausgangslage
| | Zustand heute |
|---|---|
| DC-Anbindung | **keine** — kein Treffer für `Deploymentcenter`, `dc.mhdf.de` oder `DC_TOKEN` im Quelltext |
| Version | Zeichenkette `"1.0.0"` in `App/Program.cs:34` und `Daemon/Program.cs:43`, keine `<Version>`, keine `Directory.Build.props` |
| Globale Fehlerbehandlung | **keine** — weder `AppDomain.UnhandledException` noch `TaskScheduler.UnobservedTaskException` |
| Auslieferung | `dotnet publish` von Hand, `deploy/README.md`, systemd-Unit |
| CI | Gitea Actions, Matrix `ubuntu-latest` / `windows-latest`, Checkout **nur** von IBKRTrader |
Referenzintegration ist **Predictalytics** (`src/Predictalytics.Hosting/Dc*.cs`,
`docs/BETRIEB-Deploymentcenter.md`). Sie liegt auf SDK-Stand 2.2 und ist damit
unvollständig — siehe [§8](#8-befunde-und-offene-punkte), Punkt D2. Übernommen wird
das Muster, nicht der Code eins zu eins.
Der Einbauort ist **`IBKRTrader.Hosting`**: den Host teilen sich Shell und Daemon
(`AppHostBuilder`), damit gilt jede Anbindung automatisch für beide Betriebsformen.
`IBKRTrader.Core` bleibt frei davon — der Core soll keine Netzabhängigkeit zu einem
Verwaltungsserver bekommen.
---
## 1. Voraussetzungen im Deploymentcenter (vor jedem Code)
Ohne diese Schritte läuft nichts von dem, was danach kommt:
1. **Projekt `ibkrtrader` anlegen** (WebUI → Projekte). Slug wird von Lizenz,
UpdateService, Fehler-Stream und Bugtracker gemeinsam benutzt.
2. **Offline-Gnadenfrist festlegen** (`default_cache_ttl_hours`). Vorgabe 168 h.
Für einen Handelsdienst, der auch bei gestörter Internetverbindung weiterlaufen
soll, ist das die entscheidende Stellschraube — siehe [§5](#5-lizenz).
3. **Lizenz für `ibkrtrader` ausstellen.** Ohne sie kommt die Anwendung nach dem
ersten Release nicht mehr an ihre Updates (Zugangsschutz seit 2.4).
4. **Master-Token** mit `watchdog:ping`, `bugtracker:report` — für die Anwendung.
Zweites Token mit `updateservice:publish` — für die Release-Pipeline, gehört
**nicht** in die Anwendung.
5. **Installationskonto** (Rolle `installer`) für die Erstinstallation.
6. **Evaluator-Cron prüfen.** `* * * * * php cli/tick.php --quiet`. Ohne ihn bleibt
ein abgestürzter Trading-Daemon im Dashboard dauerhaft grün — der Watchdog wäre
dekorativ. Derselbe Job erzeugt auch den Release-Zugangsschutz.
---
## 2. Build-Grundlage
### 2.1 `Directory.Build.props` mit `<Version>`
```xml
<Project>
<PropertyGroup>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
```
`pack-and-deploy` liest die Version aus der Hauptassembly und **bricht bei einer
Abweichung zu `--version` ab**. Der Grund ist unangenehm genug dafür: Wird `1.0.1`
als `1.0.2` veröffentlicht, aktualisiert die gesamte Installationsbasis, meldet
danach weiterhin `1.0.1`, hält das Release erneut für neu — und aktualisiert bei
jedem Start wieder.
Gleichzeitig entfallen die beiden hartkodierten `"1.0.0"` in den `Program.cs`;
sie werden durch `BuildInfo.Version` ersetzt.
### 2.2 SDK-Anbindung — entschieden, in zwei Stufen
`Deploymentcenter.Client` (`netstandard2.0;net8.0`) wird von `net10.0` problemlos
konsumiert. Seit SDK 2.5.0 liegt `BuildInfo.targets` im Paket selbst
(`build/`-Ordner, von NuGet automatisch importiert) — der frühere `<Import>` per
Cross-Repo-Pfad ist damit **nicht mehr nötig**. Das entscheidet die Frage
grundsätzlich zugunsten eines echten NuGet-Bezugs. Geprüft (2026-08-14): Weder
`https://dc.mhdf.de/nuget/` noch die Gitea-Package-Registry unter
`http://192.168.178.10:8418/api/packages/Richard/nuget/` führen aktuell ein
Paket `Deploymentcenter.Client` — es gibt also noch keine Quelle, gegen die eine
`PackageReference` auflösen könnte.
**Deshalb zweistufig:**
| Stufe | Weg | Zustand |
|---|---|---|
| **Jetzt (umgesetzt)** | Cross-Repo-`ProjectReference` (Muster Predictalytics), `Deploymentcenter.BuildInfo.targets` weiterhin per `<Import>` eingebunden — der pfadbasierte Import funktioniert laut Leitfaden unverändert weiter, auch wenn er nicht mehr nötig wäre | funktioniert sofort, keine neue Infrastruktur, keine Push-Entscheidung nötig |
| **Folgeschritt (offen)** | `dotnet pack` im Deploymentcenter-Repo, Push nach `.../api/packages/Richard/nuget/`, `NuGet.config` um Quelle `gitea` mit `packageSourceMapping` auf `Deploymentcenter.*` ergänzen, `<Import>` durch `<PackageReference Include="Deploymentcenter.Client" Version="2.5.1" />` ersetzen | braucht einen Push in ein fremdes Repo (Deploymentcenter) und Package-Registry-Zugangsdaten — bewusst nicht Teil dieses Durchlaufs |
Der Cross-Repo-Bezug bricht die Gitea-CI von IBKRTrader (der Runner checkt nur
dieses eine Repo aus) — siehe **P5**. Das ist mit der Wahl von Stufe 1 bewusst in
Kauf genommen, weil eine funktionierende lokale Integration wichtiger ist als eine
grüne CI, die eine noch nicht existierende Paketquelle voraussetzt.
### 2.3 `BuildInfo`
`Deploymentcenter.BuildInfo.targets` in die csproj von `IBKRTrader.Hosting`
importieren. Die Klasse entsteht in `$(RootNamespace)`, also `IBKRTrader.Hosting.BuildInfo`
— erreichbar ohne `using`. Liefert `Version`, `GitCommit`, `GitCommitShort`,
`BuildDateUtc`, `Channel`, `Summary`.
> **Erledigt mit 2.5.1** (vormals §8 D4): `BuildDateUtc` trug die volle Uhrzeit,
> `WriteOnlyWhenDifferent` griff dadurch nie, und jedes importierende Projekt wurde
> bei jedem Build neu übersetzt. Seit 2.5.1 ist das Feld tagesgenau — höchstens ein
> Neubau pro Tag. Vorbedingung: die Targets-Datei im Schwester-Repo muss auf 2.5.1
> oder neuer stehen (`git -C ../Deploymentcenter log -1 --oneline -- client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets`).
---
## 3. Konfiguration und gemeinsame Bausteine
### 3.1 `DcConfig` (kompiliert, nicht einstellbar)
```csharp
namespace IBKRTrader.Hosting.Deploymentcenter;
public static class DcConfig
{
public const string BaseUrl = "https://dc.mhdf.de"; // bewusst KEINE Einstellung
public const string ProductSlug = "ibkrtrader";
public const string MonitorGroup = "Trading";
public static string AppVersion => BuildInfo.Version;
public static string GitCommitShort => BuildInfo.GitCommitShort;
}
```
Die Basis-URL entscheidet, wohin die Lizenzprüfung geht und woher ausführbarer
Code kommt. Einstellbar wäre sie eine Einladung, die Anwendung auf einen
gefälschten Update-Server zu zeigen.
### 3.2 Neuer Abschnitt in `AppSettings`
Ergänzt `src/IBKRTrader.Core/Settings/AppSettings.cs` um
`DeploymentcenterSettings` (gleiches Muster wie `LoggingSettings`, mit
`Category`/`DisplayName`/`Description`, damit es in der Settings-Ansicht auftaucht):
| Feld | Vorgabe | Bedeutung |
|---|---|---|
| `ApiToken` | *(leer)* | `Authorization: Bearer`. Leer = kein Heartbeat, keine Fehlermeldung. `[PasswordPropertyText]` |
| `HeartbeatEnabled` | `true` | Dead-Man's-Switch |
| `MonitorSource` | *(Hostname)* | Monitorname im Dashboard |
| `MonitorInstance` | `default` | mehrere Instanzen (EU/US) desselben Dienstes |
| `HeartbeatIntervalSeconds` | `60` | `warning` nach 2×, `down` nach 4× |
| `ReportErrors` | `true` | Error/Fatal an den Fehler-Stream |
| `CheckUpdatesOnStart` | `true` | prüft nur, installiert nie von selbst |
| `UpdateChannel` | `prod` | `prod`, `beta`, `dev` |
| `LicenseKey` | *(leer)* | nur Rückfall; Regelfall ist der verschlüsselte Cache |
`ApiToken` und `LicenseKey` sind schutzbedürftig und laufen über
`SecretProtection` (AES-256-GCM at rest), wie die übrigen Geheimnisse des Projekts.
### 3.3 `DcApiClient`
Schlanker JSON-Client für Heartbeat und Fehler-Stream (Lizenz und Update bringen
ihren eigenen mit). Übernimmt von Predictalytics:
- `DcApiException` mit stabilem `error.code` aus dem Umschlag
`{"status":"error","error":{"code":"…"}}` — reagiert wird auf den Code, nie auf den Text.
- `IsPermanent` für `401`/`403`/`unauthorized`: danach wird nicht weiter versucht.
- Zeitgrenze 10 s. **Ein nicht erreichbares Deploymentcenter darf den Handel
niemals beeinflussen** — jeder Aufruf ist best effort.
---
## 4. Watchdog
Neuer Worker `DcHeartbeatWorker` in `IBKRTrader.Hosting`, registriert wie die
übrigen (`IWorker` + `AddHostedService`), damit er in der Worker-Ansicht
auftaucht und manuell auslösbar ist.
`POST /api/watchdog/v1/ping` alle *n* Sekunden mit `source`, `instance`,
`interval`, `os`, `version = BuildInfo.Version`, `group = "Trading"`.
### 4.1 `checks` — was IBKRTrader wirklich über sich weiß
Ein Heartbeat beweist nur, dass ein Timer läuft. Der klassische Fehlerfall dieses
Projekts ist ein anderer: der Prozess lebt, aber die TWS-Verbindung ist seit einer
Stunde tot und es kommen keine Kurse mehr.
| Check | Quelle | `ok` wenn |
|---|---|---|
| `db` | EF/`MySqlConnector`, `SELECT 1`, gedeckelt auf 5 s | Verbindung steht |
| `ibkr_gateway` | `IBKRGatewayService` | verbunden **oder** `UseTwsApi = false` (NullBroker ist ein gewollter Zustand, kein Fehler) |
| `market_data` | `IBKRMarketDataRepository` | letzter Tick jünger als *x* min — nur wenn TWS aktiv |
| `worker_engine` | `WorkerEngine` | kein Worker im Fehlerzustand |
Bewusst **kein** Check auf „Trading aktiv": `TradingEnabled = false` ist ein
gewollter Betriebszustand und würde den Monitor sonst dauerhaft auf `warning`
halten. Er wandert als Metrik mit.
### 4.2 `metrics`
`uptime_sec`, `trading_enabled` (0/1), `open_positions`, `orders_today`,
`account_value`, `workers_running`, `memory_mb`, `decision_journal_lag_sec`.
Serverseitig 14 Tage Verlauf mit Abweichung zum 7-Tage-Mittel desselben Monitors —
damit lässt sich „heute plötzlich 40 Orders statt 3" erkennen, ohne im
Deploymentcenter Schwellwerte für jedes Projekt zu pflegen.
### 4.3 Sauberes Beenden
Beim geordneten Herunterfahren (SIGTERM/systemd, Fenster schließen) geht ein Ping
mit `status: "stopped"` raus. Ohne ihn folgt wenige Minuten nach jedem
planmäßigen Neustart ein Fehlalarm. Der Aufruf gehört in `StopAsync`
**vor** das Beenden der übrigen Dienste und braucht eine eigene, kurze Zeitgrenze
(3 s), damit ein hängender Monitoring-Server das Herunterfahren nicht blockiert.
### 4.4 Hierarchie
Läuft der Daemon auf einem eigenen Host, gehört im WebUI unter
**WatchDog → System-Hierarchie** die *Übergeordnete Entität* auf diesen Host
gesetzt. Sonst erzeugt ein Maschinenausfall zwei Meldungen für ein Problem.
---
## 5. Lizenz
`LicenseGuard` in `IBKRTrader.Hosting`, aufgerufen aus `RunStartupChecks`
nach der Zeitzone (Logausgaben) und **vor** dem Start der Worker.
- Produkt-Slug `ibkrtrader`, Hardware-ID v2 (`2:win:…` / `2:lin:…`).
- `LicenseClient.DefaultAppVersion = BuildInfo.Version` einmalig beim Start,
sonst trägt jede Installation in der Aktivierungsliste dieselbe `1.0.0`.
- Revalidierung zur Laufzeit alle 12 h; Warnung, wenn die Gnadenfrist in unter
48 h abläuft.
- **`LicenseConfig.SetStorageDirectory(AppPaths.ConfigFile("license"))`** —
ausdrücklich setzen. Die Vorgabe des SDK löst unter Linux über `$XDG_CONFIG_HOME`
bzw. `$HOME` auf; ein systemd-Dienst mit `User=ibkrtrader` und ohne Heimatverzeichnis
landet im Rückfall auf `AppContext.BaseDirectory/license`, also unter `/opt`
dort ist nichts schreibbar, und es gäbe **keinen Offline-Cache**. Mit `AppPaths`
fügt sich die Ablage in die bereits vorhandene Verzeichnisauflösung des Projekts ein.
**Erledigt/entschärft mit 2.5.1** (vormals §8 D4): der Rückfall ohne `$HOME` zeigt
jetzt serverseitig auf `/var/lib/<slug>` statt ins Installationsverzeichnis — unser
eigenes explizites Setzen bleibt trotzdem die robustere Wahl und ist unabhängig vom
SDK-Rückfall.
⚠️ **Breaking Change 2.5.1:** `SetStorageDirectory` hängt jetzt selbst `<slug>/license`
an (vorher wurde der Pfad roh benutzt). Der Aufruf wird zu
`LicenseConfig.SetStorageDirectory(AppPaths.Config)`**ohne** das eigene
`"license"`-Suffix, sonst entstünde `…/license/ibkrtrader/license`.
### 5.1 Kopflose Schalter
Konvention aus dem Lizenz-Leitfaden, im Daemon umzusetzen (das SDK bringt keine CLI mit):
```
IBKRTrader.Daemon --license-status
IBKRTrader.Daemon --license-set-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
```
`--license-deactivate` **nicht** verdrahten: es verlangt den `shared_key` des
Servers, der gehört nicht in eine ausgelieferte Anwendung. Freigeben läuft über
das WebUI (Hardware-Liste → „Freigeben").
> **Geändert mit 2.5.0:** der `update-agent` nimmt den Lizenzschlüssel nicht mehr
> als `--license-key`-Argument entgegen — `ps` zeigt Prozessargumente jedem Nutzer
> der Maschine, dieselbe Begründung wie beim Crontab-Verzicht in UPGRADE §5. Der
> Weg ist jetzt die Umgebungsvariable `DC_LICENSE_KEY` (`LaunchUpdateAgent` setzt
> sie selbst, wenn `licenseKey` übergeben wird — kein eigener Code nötig). Unsere
> `--license-set-key`-Implementierung ruft also `LicenseClient.ValidateAsync`
> direkt auf und schreibt **nicht** `--license-key` in einen eigenen Aufruf des
> Agenten.
>
> Ebenfalls neu: `EnsureLicensedAsync()` auf `LicenseClient` — nimmt den
> zwischengespeicherten Schlüssel, fragt bei Bedarf über `ILicensePrompt` nach,
> prüft, fragt bei Ablehnung erneut. `allowPrompt:false` lehnt ohne Cache ab, statt
> im Daemon auf eine Eingabe zu warten, die nie kommt — **das ist für den Daemon
> die richtige Einstiegsmethode**, nicht das manuelle Zusammensetzen aus
> `TryGetCachedKey` + `ValidateAsync`.
### 5.2 Reaktion — hier weicht IBKRTrader bewusst vom Leitfaden ab
Die DC-Doku empfiehlt bei einem endgültigen Urteil `Environment.Exit(1)`.
**Für einen Dienst mit offenen Positionen ist das die falsche Reaktion.** Ein
abgelaufener Vertrag ist kein Grund, ein Portfolio unbeaufsichtigt zu lassen —
Stop-Loss- und Exit-Logik müssen weiterlaufen, sonst richtet die Lizenzdurchsetzung
mehr Schaden an als der unlizenzierte Betrieb.
Vorschlag — drei Stufen statt zwei:
| Ergebnis | Zustände | Reaktion |
|---|---|---|
| `IsValid` | `valid`, `valid_offline` | Normalbetrieb |
| `IsTransient` | `server_unavailable`, `cache_expired` | **kein Urteil, nur keine Auskunft.** Warnen, weiterlaufen, beim nächsten Durchlauf erneut fragen |
| Urteil | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | **Sperrbetrieb:** `TradingEnabled` hart auf `false`, keine neuen Einstiege, Risiko-/Exit-Worker und Buchhaltung laufen weiter, Meldung an Watchdog (`status: "warning"`) und Fehler-Stream. Beendet wird erst, wenn keine Position mehr offen ist — oder auf ausdrückliche Anweisung |
Bei der Desktop-Shell darf es beim Start weiterhin ein Lizenzfenster geben; der
**Daemon** darf niemals auf eine Eingabe warten.
---
## 6. Error-Reporting
Zwei Wege in `POST /api/errors/v1/report`, beide in `IBKRTrader.Hosting`:
1. **Senke am `LoggingService`.** `LoggingService.EntryWritten` existiert bereits
und ist genau dafür gedacht (die Oberfläche hängt schon dran). Ein
`DcErrorSink` filtert auf `AppLogLevel.Error` und meldet mit
`level: "error"`, `build: BuildInfo.Version`, `file`/`line` aus dem Stacktrace,
`context` mit `GitCommitShort` und `CorrelationId`/`SignalId`, wenn vorhanden.
Die `SignalId` mitzuschicken ist der eigentliche Gewinn: eine Fehlermeldung
lässt sich damit im Deploymentcenter direkt gegen das Entscheidungsjournal halten.
2. **Globale Handler**`AppDomain.UnhandledException` (`level: "fatal"`,
**blockierend** gesendet, der Prozess ist gleich weg) und
`TaskScheduler.UnobservedTaskException`. Beide fehlen im Projekt heute
vollständig; sie müssen sowieso her.
### 6.1 Bremse
Verpflichtend, sonst verbrennt ein Fehler in einer Handelsschleife das
serverseitige Rate-Limit binnen Sekunden:
- höchstens 20 Meldungen pro Minute,
- derselbe Fehler höchstens alle 5 Minuten,
- bei `401`/`403` dauerhaft abschalten,
- bei `429` Intervall verdoppeln.
Betriebsbedingtes Rauschen gehört nicht in diese Bremse, sondern in die
**Ignore-Regeln** des WebUI: dort wird weitergezählt, aber nicht gemeldet — und
eine Alarmschwelle schlägt an, wenn ein bekannter Fehler plötzlich hundertmal so
oft auftritt.
> **Nicht melden:** alles, was Kontodaten, Positionen oder Schlüsselmaterial im
> Text führen könnte. Der Fehler-Stream ist eine externe Ablage. Vor dem Senden
> wird der Text durch dieselbe Maskierung geschickt, die schon für die Logs gilt.
---
## 7. UpdateService, Erstinstallation und Release-Pipeline
### 7.1 Prüfen, nicht installieren
`DcUpdateService.CheckAsync` beim Start (`GET /api/updateservice/v1/check`).
**Mit Zugangsdaten**, sonst gibt es seit 2.4 nur noch `401`:
```csharp
var key = settings.Deploymentcenter.LicenseKey is { Length: > 0 } k
? k
: LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
var check = await new UpdateClient().CheckForUpdateAsync(
baseUrl: DcConfig.BaseUrl,
projectId: DcConfig.ProductSlug,
currentVersion: BuildInfo.Version,
channel: settings.Deploymentcenter.UpdateChannel,
credentials: ReleaseCredentials.FromLicenseKey(key),
cancellationToken: ct);
if (check.Unauthorized) { /* Lizenzproblem, KEIN Netzwerkfehler — eigene Meldung */ }
```
Gefunden wird nur, installiert wird nichts von selbst. Ein Trading-Dienst, der
sich unangekündigt neu startet, ist keine akzeptable Vorgabe.
- Kein Update → Info ins Log.
- Update verfügbar → Warnung + Hinweis in der Shell.
- Kritisch → zusätzlich Dialog (Shell) bzw. Watchdog-Event (Daemon).
### 7.2 Anwenden
Über `UpdateClient.LaunchUpdateAgent(..., licenseKey: key)`; der Agent bekommt
`--restart`, `--wait-for-pid`, `--platform` von selbst, den Lizenzschlüssel seit
2.5.0 **nicht mehr** als `--license-key`-Argument, sondern über die
Umgebungsvariable `DC_LICENSE_KEY` (siehe §5.1). Vorher geht ein
`status: "stopped"` an den Watchdog.
**Vorbedingung, die die Anwendung selbst prüfen muss:** kein offener Auftrag,
keine laufende Order. Ein Update, das den Prozess zwischen Order-Absendung und
Statusrückmeldung abräumt, hinterlässt eine Order bei IBKR ohne Gegenstück im
Journal. Das Update wird also erst nach dem Handelsschluss bzw. bei leerem
Auftragsbuch angeboten.
> **Verschärft mit 2.5.0:** `exitCurrentApp: true` ist jetzt ausdrücklich als
> **harter Kill** dokumentiert — laufende `finally`-Blöcke, `IHostApplicationLifetime`
> und Destruktoren kommen nicht mehr zum Zug. Für IBKRTrader heißt das: Solange
> die Vorbedingung oben nicht sicher geprüft ist (offenes Auftragsbuch, laufende
> Buchungstransaktion), muss `exitCurrentApp: false` gesetzt werden. Der Agent
> wartet über `--wait-for-pid` ohnehin auf das Prozessende; das eigene
> Herunterfahren läuft dann über `host.StopAsync(...)` wie in `App/Program.cs`
> bereits vorhanden — **nicht** über `LaunchUpdateAgent` selbst. Zusätzlich
> `waitTimeoutSeconds` heraufsetzen, falls das geordnete Beenden (EF-Flush,
> Kestrel, Log-Flush, DB-Backup-Worker) länger als die Vorgabe von 60 s dauern
> kann — läuft die Zeit ab, bricht der Agent ohne Änderung ab.
Für den Linux-Dienst siehe [§8 D3](#8-befunde-und-offene-punkte) — dafür gibt es
im Deploymentcenter derzeit keinen fertigen Weg.
### 7.3 Was ausgeliefert wird — Ausschließen vs. Schützen
Das ist die Stelle, an der Updates in anderen Projekten schon Konfigurationen
zerstört haben. Für IBKRTrader gilt:
| Datei | Liste | Begründung |
|---|---|---|
| `settings.example.json` | *(im Paket)* | Vorlage mit Platzhaltern — Erstinstallation braucht sie |
| `settings.json` | `preservePatterns` | enthält DB-Zugang, DC-Token, Flex-Token. Muss ein Update überleben |
| `appsettings.Local.json` | `preservePatterns` | Verbindungszeichenfolge des Zielsystems |
| `master.key`, `openrouter.key` | `excludePatterns` | **darf nie ins Paket** |
| `Logs/**`, `Backups/**` | `excludePatterns` | Laufzeitdaten |
| `*.pdb`, `*.xml` | `excludePatterns` | Build-Artefakte |
| `update-agent*` | bewusst entscheiden | mitliefern = aktualisiert sich selbst mit |
`pack-and-deploy` warnt von sich aus bei Dateien, die nach Zugangsdaten aussehen.
Die Warnung ist ernst zu nehmen: Release-Pakete liegen zwar hinter einem
Zugangsschutz, aber jeder lizenzierte Kunde kann sie auspacken.
### 7.4 `setup.json`
Beschreibt der Erstinstallation, was abzufragen ist; wird ins Paket kopiert.
Entwurf für IBKRTrader:
```json
{
"schema": 1,
"displayName": "IBKRTrader",
"targets": [
{ "id": "app", "file": "settings.json", "location": "install", "format": "json" }
],
"fields": [
{ "key": "Database:Host", "label": "MySQL-Host", "type": "string" },
{ "key": "Database:Database", "label": "Datenbank", "type": "string" },
{ "key": "Database:User", "label": "Benutzer", "type": "string" },
{ "key": "Database:Password", "label": "Passwort", "type": "secret" },
{ "key": "IBKR:Host", "label": "TWS/Gateway", "default": "127.0.0.1" },
{ "key": "IBKR:Port", "label": "Port (Paper 4002 / Live 4001)", "type": "int", "default": "4002" },
{ "key": "Trading:ApplicationTimeZoneId", "label": "Betriebszeitzone", "default": "Europe/Berlin" },
{ "key": "Deploymentcenter:BaseUrl", "source": "detect:baseurl", "type": "url" },
{ "key": "Deploymentcenter:ApiToken", "label": "Token dieser Installation",
"source": "provision", "scopes": ["watchdog:ping", "bugtracker:report"] },
{ "key": "Deploymentcenter:MonitorSource", "source": "detect:hostname" }
]
}
```
Zwei Punkte dazu:
- **`location: "install"` ist nur unter Windows richtig** — und auch dort nur,
solange nicht nach `C:\Program Files` installiert wird. Siehe
[§8 P2](#8-befunde-und-offene-punkte); solange `AppPaths` nicht korrigiert ist,
passen `setup.json` und die tatsächliche Leseposition der Anwendung unter Linux
nicht zusammen. **Dieser Punkt blockiert die Erstinstallation und gehört vor §7.4.**
- Der Installer schreibt **Klartext**. `SecretProtection` muss also Klartext
unverändert durchreichen und beim ersten Speichern verschlüsseln — die Weiche
ist zu prüfen und im Test ausdrücklich als *aktiv genutzt* zu beschreiben, damit
sie nicht eines Tages als tot entfernt wird.
### 7.5 Release-Pipeline
`scripts/release.ps1` und `scripts/release.sh` sind per `curl` von
`/docs/release-template/` geholt (wie `release.md` empfiehlt, statt aus dem
lokalen Schwester-Repo kopiert — damit ist sichergestellt, dass die kanonische
Fassung im Projekt liegt) und **unverändert** gelassen. Nur
`scripts/release.config.json` ist ausgefüllt:
```json
{
"project": "ibkrtrader",
"csproj": "src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj",
"runtimes": ["win-x64", "linux-x64"],
"selfContained": true,
"setupJson": "setup.json"
}
```
**Entschieden: nur der Daemon, keine Zwei-Produkte-Aufteilung.** IBKRTrader hat
zwei Einstiegspunkte (`App` = Avalonia-Shell, `Daemon` = kopflos). Die Vorlage
nimmt aber genau **eine** `csproj` für alle Runtimes in der Liste — sie ruft
`dotnet publish <csproj> -r <rid>` in einer Schleife auf, es gibt keine
Möglichkeit, App für `win-x64` und Daemon für `linux-x64` in **demselben** Lauf
zu veröffentlichen, ohne das (laut Anleitung unveränderte) Skript anzufassen.
Ursprünglich in diesem Dokument vorgeschlagen war genau das ("Windows = App,
Linux = Daemon") — das ist mit der Vorlage so nicht umsetzbar und wurde beim
Umsetzen korrigiert.
`IBKRTrader.Daemon` ist der bewusste Kandidat: laut README „plattformneutral"
(`net10.0` ohne Plattformbindung), läuft kopflos auf beiden Zielsystemen und
ist ohnehin die für den Serverbetrieb vorgesehene Form (`deploy/README.md`).
Eine automatisierte Auslieferung der Avalonia-Shell (`App`) als
Windows-Desktop-Distribution ist damit **nicht** Teil dieses Durchlaufs — dafür
bräuchte es entweder einen zweiten Produkt-Slug (`ibkrtrader-app`, doppelte
Lizenz-/Update-Pflege) oder ein angepasstes Release-Skript. Zurückgestellt, bis
tatsächlich Bedarf an einer automatisierten Desktop-Auslieferung besteht — bis
dahin bleibt `dotnet run --project src/IBKRTrader.App` bzw. eine manuelle
`dotnet publish` der übliche Weg für die Shell.
Zugangsdaten kommen aus `DC_FTP_HOST` / `DC_FTP_USER` / `DC_FTP_PASS` / `DC_TOKEN`,
`.dc-tools/` und `artifacts/` sind in der `.gitignore`.
**`packager.config.json` (`excludePatterns`/`preservePatterns`) ist nicht Teil
dieses Durchlaufs.** Sie liegt "neben dem Werkzeug", also unter `.dc-tools/`
das entsteht erst, wenn `pack-and-deploy` beim ersten echten Lauf von
`release.ps1`/`release.sh` selbst heruntergeladen wird (Prüfsummen-geprüft).
Ein erster Release-Lauf braucht `DC_FTP_*`/`DC_TOKEN` und ist damit eine
credentialierte, vom Betreiber auszulösende Aktion. **Sicherheitsnetz bis
dahin:** Der Packager bricht seit 2.5.0 ohnehin hart ab, wenn er im
Publish-Verzeichnis nach Zugangsdaten aussehende Dateien findet (Name *und*
Inhalt) — die in §7.3 beschriebenen Dateien (`settings.json`,
`appsettings.Local.json`) sind ohnehin gitignored und landen nur dann im
Publish-Output, wenn sie lokal im Projektverzeichnis liegen (`<None Update
Condition="Exists(...)">` in den `.csproj`-Dateien kopiert sie dann mit). Bricht
`release.ps1` mit einem Hinweis auf eine vermutete Zugangsdatei ab, ist das der
Packager, der genau das abfängt — kein Fehlschlag, sondern die Absicherung, die
2.5.0 dafür eingeführt hat.
---
## 8. Befunde und offene Punkte
> **Status 2026-08-23:** P1P4 sind umgesetzt (Einzelheiten in §9). **P5 ist eingetreten**,
> P6 entscheidet sich erst beim ersten Release. Der ursprüngliche Vermerk von 2026-08-14:
>
> **Status 2026-08-14:** Alle sieben ursprünglich an den Deploymentcenter-Entwickler
> gemeldeten Befunde (D1D7) sind mit den Versionen 2.5.0/2.5.1 behoben — bestätigt über
> `GET /api/updateservice/v1/changelog?since=2.4`. Der Abschnitt bleibt als Nachweis
> stehen, jeder Punkt trägt jetzt einen Fix-Vermerk. Die Projekt-Befunde (P1P6) sind
> davon unabhängig und weiterhin offen — sie werden in diesem Durchlauf abgearbeitet.
### Am Projekt (P) — muss vor der Integration bereinigt werden
**P1 · Echte Zugangsdaten als Vorgabewerte im Quelltext.**
`src/IBKRTrader.Core/Settings/AppSettings.cs:14-35` trägt Host, Datenbankname,
Benutzer und **Passwort** einer produktiven MariaDB als Vorgabe. Die Datei ist
versioniert, die Werte stehen damit auch in der Git-Historie — `settings.json`
im `.gitignore` hilft dagegen nicht. Das Passwort ist als kompromittiert zu
behandeln und zu wechseln; die Vorgaben gehören auf Platzhalter. Zusätzlich
brisant, weil die Vorlage künftig im Release-Paket ausgeliefert wird.
> **Verschärft mit 2.5.0:** der Packager **bricht jetzt hart ab**, wenn er
> Zugangsdaten im Publish-Verzeichnis erkennt (Dateiname *und* Inhalt, u. a.
> gefülltes `Password=`) — vorher nur eine Warnung, die auf der Preserve-Liste
> sogar unterdrückt wurde. Damit ist P1 nicht mehr „sollte bereinigt werden",
> sondern **release-blockierend**: Ohne Fix lässt sich `settings.example.json`
> gar nicht mehr veröffentlichen.
**P2 · `AppPaths` fällt unter Windows auf einen Linux-Pfad zurück.**
`Configuration/AppPaths.cs:57-71`: ist das Binärverzeichnis nicht beschreibbar,
gilt der FHS-Rückfall `/etc/ibkrtrader`. Unter Windows löst .NET das zu
`C:\etc\ibkrtrader` auf. Genau dieser Fall tritt ein, sobald der DC-Installer
seinen Windows-Vorschlag `Programme\<slug>` benutzt — dann liegen `settings.json`,
`master.key` und `Logs/` in `C:\etc\`. Nötig: plattformabhängiger Rückfall
(`%ProgramData%\IBKRTrader` bzw. `%APPDATA%`).
**P3 · Keine globalen Ausnahmebehandler.** Ohne
`AppDomain.UnhandledException` und `TaskScheduler.UnobservedTaskException` gibt es
keine `fatal`-Meldungen — und heute auch keinen Logeintrag, wenn der Prozess
wegbricht. Unabhängig vom Deploymentcenter überfällig.
**P4 · Version an zwei Stellen hartkodiert.** `App/Program.cs:34` und
`Daemon/Program.cs:43`. Ohne `Directory.Build.props` bricht `pack-and-deploy` beim
ersten Release ab — zu Recht.
**P5 · CI kennt das Schwester-Repo nicht.** `.gitea/workflows/build.yml` checkt
nur IBKRTrader aus. Eine `ProjectReference` nach `..\..\..\..\Deploymentcenter\…`
macht den Build auf beiden Matrix-Zielen rot. Siehe die Entscheidung in §2.2.
> **Seit 2026-08-23 eingetreten, nicht mehr nur vorhergesagt.** Die
> `ProjectReference` ist mit der Integration auf `main` gelandet, die CI ist
> damit rot. Das war die bewusst in Kauf genommene Folge der Interimslösung aus
> §2.2 — lokal baut die Projektmappe, sobald das Schwester-Repo daneben liegt.
> Auflösung ist Schritt 10: SDK als NuGet-Paket in die Gitea-Registry, dann
> `PackageReference` statt Cross-Repo-Pfad.
**P6 · Konfiguration liegt im Installationsverzeichnis.** Unter Windows schreibt
`AppPaths` neben die Binärdatei. Das ist mit `preservePatterns` beherrschbar
(§7.3), muss aber beim ersten Release stimmen — ein Update, das `settings.json`
überschreibt, nimmt einer laufenden Installation Datenbank, Token und Flex-Zugang
gleichzeitig.
### Am Deploymentcenter (D) — gemeldet, Stand vor 2.5.0
**D1 · Der Lizenzschlüssel als Update-Zugang war nirgends vollständig erklärt.**
**Behoben (2.5.1, Doku).** Laut Changelog explizit als „Lückenschluss" genannt:
„Woher die Anwendung den Lizenzschlüssel für den Update-Zugang nimmt
(`TryGetCachedKey`)". Für uns bleibt zusätzlich `EnsureLicensedAsync()` (neu in
2.5.0) relevant — siehe §5.1.
**D2 · Predictalytics wurde vom Zugangsschutz ausgesperrt.**
**Bestätigt und behoben (2.5.1).** Der Changelog bestätigt den Verdacht wörtlich:
„bei Predictalytics ist genau das passiert". Fix: Zugangsschutz ist jetzt je
Produkt abschaltbar (WebUI → UpdateService → Zugangsschutz) — für Installationsbasen
ohne SDK 2.4 lässt sich das Produkt ausnehmen, mit Schlüssel neu ausliefern, danach
wieder einschalten. **Für IBKRTrader heißt das:** genau diese Falle von Anfang an
vermeiden, indem der erste ausgelieferte Build bereits `licenseKey` mitgibt (§7.5,
§9 Reihenfolge).
**D3 · Für einen systemd-Dienst gab es keinen dokumentierten Update-Weg.**
**Behoben (2.5.1, Doku).** Laut Changelog ergänzt: „wie ein laufender
systemd-Dienst aktualisiert wird". Bleibt in §7.2/§8-P laufend zu beobachten, ob
die neue Beschreibung mit unserem tatsächlichen Vorgehen (`--target-dir`,
Dienstbenutzer-Rechte) übereinstimmt — bei der Umsetzung von Aufgabe „Update-Prüfung
mit ReleaseCredentials" gegenprüfen.
**D4 · `LicenseConfig.GetStorageDirectory`/`SetStorageDirectory` war für Dienste
unglücklich.** ✅ **Behoben, mit Breaking Change (2.5.1).** Zwei Korrekturen:
(a) Rückfall ohne `$HOME` zeigt jetzt auf `/var/lib/<slug>` statt ins
Installationsverzeichnis. (b) `SetStorageDirectory` hängt jetzt selbst
`<slug>/license` an, statt den Pfad roh zu verwenden — **das ändert unseren
Aufruf**, siehe §5 (`AppPaths.Config` statt `AppPaths.ConfigFile("license")`).
**D5 · `BuildInfo.targets` verhinderte inkrementelle Builds.**
**Behoben (2.5.1).** `BuildDateUtc` ist jetzt tagesgenau statt mit voller Uhrzeit,
`WriteOnlyWhenDifferent` greift wieder. Siehe §2.3.
**D6 · Die Empfehlung „bei einem Urteil beenden" passte für handelnde Systeme nicht.**
**Behoben (2.5.1, Doku)** — als Lückenschluss genannt: „was statt
`Environment.Exit(1)` zu tun ist, wenn Beenden die gefährlichere Option wäre".
Unser Sperrbetrieb-Ansatz (§5.2) bleibt die konkrete Umsetzung für IBKRTrader.
**Zusätzlich, über D6 hinausgehend:** 2.5.0 dokumentiert `exitCurrentApp: true`
jetzt als harten Kill ohne `finally`/Lifecycle-Hooks — praktisch relevanter für uns
als die Lizenz-Frage allein, siehe §7.2.
**D7 · Kleinigkeiten** (README-Verweis auf `AGENT_RELEASE_GUIDE.md`, Rate-Limits
nicht durchgängig beziffert). ✅ **Behoben (2.5.1, Doku)** — Changelog nennt
„die Drosselungsgrenzen aller Endpunkte" als ergänzt; `docs/README.md` führt jetzt
`/docs/release.md` und den Changelog-Endpunkt im Index.
---
## 9. Reihenfolge
| Schritt | Inhalt | Abhängig von | Stand |
|---|---|---|---|
| 0 | **P1** Zugangsdaten wechseln, Vorgaben auf Platzhalter | — | ✅ Vorgaben sind Platzhalter. **Die Rotation des geleakten Passworts steht weiterhin aus** (Nutzer-Aktion) |
| 1 | **P2** `AppPaths`-Rückfall plattformabhängig, **P4** `Directory.Build.props` | — | ✅ 2026-08-23 |
| 2 | SDK-Bezug Stufe 1: Cross-Repo-`ProjectReference` (§2.2) | — | ✅ 2026-08-23, inkl. Prüf-Target mit lesbarer Fehlermeldung |
| 3 | `DcConfig`, `DeploymentcenterSettings`, `DcApiClient`, `BuildInfo` | 1, 2 | ✅ 2026-08-23 |
| 4 | **P3** globale Handler + Fehler-Stream | 3 | ✅ 2026-08-23 (`DcCrashHandlers`, `DcErrorSink`, `DcErrorReporter`) |
| 5 | Watchdog-Heartbeat inkl. `stopped` | 3 | ✅ 2026-08-23 (`DcHeartbeatWorker`) |
| 6 | Lizenz mit Sperrbetrieb statt Abbruch (§5.2), `EnsureLicensedAsync` | 3 | ✅ 2026-08-23 (`LicenseGuard`) |
| 7 | Update-Prüfung **mit** `ReleaseCredentials`, `exitCurrentApp:false` bei offenem Zustand | 6 (braucht den Schlüssel) | 🔶 Die **Prüfung** läuft beim Start. Das **Anwenden** ist es nicht: `LaunchAgent` ist fertig, aber kein Aufrufer fährt danach geordnet herunter |
| 8 | `setup.json`, `preserve`/`exclude`, Release-Pipeline (Vorlage per `curl` von `/docs/release-template/`) | 17 | ✅ 2026-08-23 (`setup.json`, `scripts/release.*`). **Noch nie ausgeführt** das erste Release steht aus |
| 9 | Bugtracker-Baustein nach `AGENTS.md` / `.agents/rules` | Projekt-Slug angelegt | ⬜ offen — Projekt `ibkrtrader` muss im WebUI erst angelegt werden |
| 10 | **Folgeschritt, nicht Teil dieses Durchlaufs:** `Deploymentcenter.Client` als NuGet-Paket in die Gitea-Registry pushen, `<Import>` durch `PackageReference` ersetzen, Gitea-CI (**P5**) auf den Paketbezug umstellen | 2 | ⬜ offen — siehe §2.2 |
Schritt 7 und 8 hängen zusammen: **der erste ausgelieferte Build muss die
Zugangsdaten schon mitbringen.** Für ein Produkt, das noch nie veröffentlicht hat,
entsteht `/releases/ibkrtrader/` mit dem ersten Upload und wird vom nächsten
`tick.php`-Lauf geschützt — es gibt kein Zeitfenster, das SDK danach nachzurüsten.
Wird Schritt 7 übersprungen, fällt die Tür hinter dem ersten Release zu (genau der
Fall, der laut Changelog bei Predictalytics bereits eingetreten ist, siehe D2).
Schritt 9 (Bugtracker) setzt voraus, dass das Projekt `ibkrtrader` im
Deploymentcenter-WebUI angelegt ist und ein Token mit `bugtracker:report`
vorliegt — beides serverseitige Handarbeit, die dieser Durchlauf nicht auslösen
kann. Wird zurückgestellt.
+714
View File
@@ -0,0 +1,714 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es die Analyse vor dem Umbau ist: Fundstellenverzeichnis, Aufwandsschaetzung und
> was anders kam als geschaetzt. Die Portierung selbst ist abgeschlossen (L0L6). Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# Analyse: Linux-Fähigkeit des IBKRTrader
> **UMGESETZT am 2026-08-07 (L0L5).** Dieses Dokument ist die Analyse, die der Portierung
> vorausging, und bleibt als Begründung erhalten es beschreibt den Stand **vor** dem Umbau.
> Was tatsächlich gebaut wurde, steht in der Phasen-Checkliste von
> [../ARCHITECTURE.md](../ARCHITECTURE.md#l0l5--linux-portierung-avalonia-statt-winforms--2026-08-07);
> die Pfadangaben im Fundstellenverzeichnis unten beziehen sich auf den alten Aufbau.
>
> Zwei Punkte sind gegenüber der Schätzung anders gekommen:
> * Der Aufwand lag deutlich unter den veranschlagten 2125 Personentagen, weil PolytraderSharp
> dieselbe Portierung bereits durchlaufen hatte und als Vorlage diente (Avalonia-Pinnung,
> toolkit-neutraler Contract, `AppTimeZone`).
> * `InvariantGlobalization=true` in der Analyse noch als Empfehlung für ein schlankes Image
> genannt 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 deshalb ausdrücklich auf `false`.
> Stand: 2026-08-06. **Reine Analyse es wurde kein Code geändert.**
> Grundlage ist der Commit `b96a207` (main): 154 C#-Dateien, ~16.200 LOC, 6 Projekte, 165 Tests.
> Alle Aussagen in Abschnitt 19 sind am Quelltext bzw. an einem Probe-Restore verifiziert;
> Stellen, die nur plausibel und ungeprüft sind, stehen ausdrücklich als solche gekennzeichnet.
---
## 0. Kurzfassung
**Die gute Nachricht:** Der portabilitätskritische Teil der Anwendung ist bereits sauber. Kein
einziges `DllImport`, keine Registry, kein WMI, kein DPAPI, kein `TimeZoneInfo.FindSystemTimeZoneById`.
Die Verschlüsselung (`AesGcm`), die Datenbank (Pomelo/EF Core) und der TWS-Adapter laufen ohne
Änderung auf Linux. Die WinForms-Kopplung im **Core** beschränkt sich auf **drei Dateien**, und
jedes Modul trägt **genau eine** Form-Datei.
**Die eigentliche Arbeit** liegt an zwei Stellen und sie sind unterschiedlich groß:
| | Umfang | Aufwand |
|---|---|---|
| **A. Headless-Linux** (Worker, Trading, Accounting, Supervisor, REST/MCP ohne UI) | ~15 Fundstellen, 3 Core-Dateien entkoppeln | **57 Personentage** |
| **B. Desktop-Linux** (zusätzlich die komplette UI auf Avalonia) | ~1.300 LOC WinForms neu bauen | **+1014 Personentage** |
| **C. LiveCharts2** (heute existiert **kein einziges** Diagramm) | Neubau, keine Migration | **+13 Personentage** |
**Empfehlung:** Die beiden Schritte trennen. Eine headless Linux-Variante ist mit ~1 Woche
erreichbar und liefert sofort den größten praktischen Nutzen (Dauerbetrieb auf einem Server statt
auf dem Windows-Desktop). Der Avalonia-Umbau ist danach eine unabhängige Etappe, die man ohne
Zeitdruck und ohne laufenden Betrieb zu gefährden angehen kann. Details in Abschnitt 11.
**Ein Punkt ist unabhängig von der UI der gefährlichste:** die Zeitzonen-Behandlung (Abschnitt 6).
Der Code mischt heute `DateTime.Now` und `DateTime.UtcNow` und verwirft in `ParseExecutionTime`
bewusst die Zeitzone. Auf einem Windows-Rechner mit `Europe/Berlin` und in einem Linux-Container
mit `UTC` liefert **derselbe Code unterschiedliche Werte** lautlos, ohne Fehler, in
Buchungszeitstempeln. Das muss vor der Portierung geklärt werden, nicht danach.
---
## 1. Was **nicht** geändert werden muss
Diese Prüfungen sind negativ ausgefallen das sind ersparte Personentage:
| Geprüft | Ergebnis |
|---|---|
| `DllImport` / `LibraryImport` / `Marshal` | **keine Fundstelle** |
| Windows Registry, WMI, EventLog, `WindowsIdentity` | **keine Fundstelle** |
| DPAPI / `ProtectedData` | **keine Fundstelle** `SecretProtection` nutzt `AesGcm` + `SHA256`, voll portabel |
| `TimeZoneInfo.FindSystemTimeZoneById` (Windows- vs. IANA-IDs) | **keine Fundstelle** |
| Hartkodierte Laufwerksbuchstaben im Produktivpfad | nur in `BackupWorker` (s. 7.1) |
| EF Core / Pomelo / MySqlConnector | voll portabel, Migrationen unberührt |
| WinForms-Designer-Dateien (`*.Designer.cs`, Layout-`.resx`) | **keine** die gesamte UI ist handgeschriebener Code-Behind. Das erspart die übliche Designer-Konvertierung vollständig. |
### 1.1 Die TWS-API läuft auf Linux (geprüft)
Das Paket `IB.TWS.CSharpApi 9.76.1` liefert `lib/net45/CSharpAPI.dll` deshalb steht heute
`NoWarn="NU1701"` im Core-csproj. Die Assembly-Referenzen wurden ausgelesen:
```
mscorlib 4.0.0.0
System 4.0.0.0
System.Core 4.0.0.0
```
Nur diese drei, alle auf .NET 10 vollständig typweitergeleitet. Es gibt keine Abhängigkeit auf
`System.Configuration`, `System.Web` oder sonst etwas Windows-Gebundenes. Die DLL wird auf Linux
laufen. **Verbleibendes Restrisiko: gering, aber ungeprüft** ein Verbindungs-Smoke-Test gegen
das Gateway von einem Linux-Host aus gehört in die erste Etappe.
> **Sauberere Alternative:** IBKR liefert im offiziellen TWS-API-Download den C#-Quelltext mit.
> Den als eigenes `netstandard2.0`-Projekt in `src/` aufzunehmen, ersetzt das NuGet-Mirror-Paket,
> beseitigt `NU1701` und macht die Herkunft nachvollziehbar. Halber Tag, optional.
---
## 2. Sofort-Blocker: der Restore schlägt heute auf **jedem** frischen Rechner fehl
Das ist keine Linux-Besonderheit, aber es ist das Erste, worüber man auf einer neuen Maschine
stolpert und deshalb Teil dieser Analyse. `NuGet.config` nutzt `packageSourceMapping` als
Allowlist mit `<clear/>`. Drei Pakete haben kein passendes Muster. Verifiziert mit einem Restore
gegen einen leeren Paket-Ordner:
```
error NU1100: "PDFsharp-MigraDoc (>= 6.2.4)" kann für "net10.0-windows" nicht aufgelöst werden.
error NU1100: "Microsoft.EntityFrameworkCore (>= 8.0.13)" kann für "net10.0-windows" nicht aufgelöst werden.
error NU1100: "Microsoft.CodeAnalysis.CSharp.Workspaces (>= 4.5.0)" kann für "net10.0-windows" nicht aufgelöst werden.
```
Auf dem Entwicklungsrechner fällt das nicht auf, weil alle drei längst im globalen Paket-Cache
liegen. Die Ursachen:
- `PDFsharp-MigraDoc` gar kein Muster vorhanden.
- `Microsoft.EntityFrameworkCore` das Muster lautet `Microsoft.EntityFrameworkCore.*`; der Glob
matcht das Paket **ohne** Suffix nicht.
- `Microsoft.CodeAnalysis.CSharp.Workspaces` transitiv über `EntityFrameworkCore.Design`, kein Muster.
**Aufwand: 10 Minuten.** Muss vor allem anderen erledigt sein, sonst startet der erste
Linux-Build nicht.
---
## 3. Block A Projekt- und Build-Ebene
**Alle sechs Projekte** stehen heute auf `net10.0-windows` mit `UseWindowsForms=true` auch der
Core, alle drei Module und das Testprojekt.
| Projekt | heute | Ziel |
|---|---|---|
| `IBKRTrader.Core` | `net10.0-windows`, WinForms | `net10.0`, **keine** UI-Abhängigkeit |
| `Modules.CongressTrading` | `net10.0-windows`, WinForms | `net10.0` |
| `Modules.Accounting` | `net10.0-windows`, WinForms | `net10.0` |
| `Modules.Supervisor` | `net10.0-windows`, WinForms | `net10.0` |
| `IBKRTrader.Tests` | `net10.0-windows`, WinForms | `net10.0` |
| `IBKRTrader.App` | `WinExe`, `net10.0-windows` | neu: `IBKRTrader.Desktop` (Avalonia) + `IBKRTrader.Daemon` (Konsole) |
Zwei Nebenwirkungen, die man kennen muss:
1. **`ImplicitUsings` + `UseWindowsForms` fügt `System.Windows.Forms` und `System.Drawing` als
globale Usings hinzu.** Sobald das wegfällt, brechen Dateien, die unbemerkt `Point`, `Size`,
`Color`, `Font` oder `Padding` benutzt haben. Das ist *nützlich* der Compiler findet die
Arbeit für uns aber es erklärt, warum die erste Umstellung mehr Fehler wirft als die 3
bekannten Core-Dateien vermuten lassen.
2. `ApplicationHighDpiMode`, `Properties/Resources.resx` (24 `System.Drawing.Bitmap`-Icons) und
`ApplicationConfiguration.Initialize()` verschwinden mit dem App-Projekt. Die 24 PNGs unter
`Resources/` bleiben brauchbar, werden aber in Avalonia über `AvaloniaResource` + `Bitmap`
eingebunden statt über den `ResourceManager`.
**Aufwand Block A: 0,5 Tage.**
---
## 4. Block B den Core von WinForms lösen
Das ist der architektonisch wichtigste Schritt und erfreulich klein. **Genau drei Dateien** im
Core kennen WinForms:
### 4.1 `Core/Logging/LoggingService.cs` (139 LOC, davon ~40 betroffen)
Hält direkt ein `RichTextBox?`, benutzt `System.Drawing.Color` und marshallt selbst per
`InvokeRequired`/`BeginInvoke`:
```csharp
private RichTextBox? _rtb;
public void AttachRichTextBox(RichTextBox rtb) => _rtb = rtb;
private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150);
```
**Umbau:** `AttachRichTextBox` durch ein `event Action<LogEntry>? EntryWritten` bzw. ein
`ILogSink`-Interface ersetzen. Das Einfärben und das Thread-Marshalling wandern in die UI-Schicht
(Avalonia: `Dispatcher.UIThread.Post`). Der `LogEntry`-Record ist bereits sauber und braucht
keine Änderung.
Nebenbei zu bereinigen: `e.Level.ToString().ToUpper()` in Zeile 115 ist kulturabhängig
(s. Abschnitt 5).
### 4.2 `Core/Modularity/ModuleView.cs` (57 LOC)
```csharp
public System.Drawing.Image? Icon { get; set; }
public Func<Form> CreateForm { get; init; } = () => new Form();
```
**Umbau:** Der Contract muss toolkit-neutral werden. Zwei Wege:
- **Pragmatisch:** `Func<object> CreateWindow` + `string IconKey` (Ressourcenname statt Bitmap).
Der Core kennt dann keine UI-Typen mehr, die Shell castet.
- **Sauber:** ein `IModuleWindow`-Marker-Interface, das die Desktop-Schicht auf `Window` abbildet.
Das `IModuleUiHost`-Interface selbst (`RegisterView`, `IsOpen`, `OpenView`, `ActivateMain`,
`RequestShutdown`, `OpenStateChanged`) ist **bereits toolkit-neutral** und kann unverändert bleiben.
### 4.3 `Core/Modularity/WindowMenu.cs` (79 LOC)
Vollständig WinForms (`MenuStrip`, `ToolStripMenuItem`, `Font`, `FontStyle`). Wird ersatzlos
gelöscht und in der Avalonia-Schicht neu gebaut. Die *Logik* dahinter (Launcher + alle Views +
kontextabhängige rechte Aktion) ist trivial und in ~60 Zeilen XAML/C# nachgebaut.
### 4.4 Die Module
**Je Modul genau eine betroffene Datei:**
- `Modules.Accounting/Ui/AccountingMainForm.cs` (302 LOC)
- `Modules.CongressTrading/UI/CongressTradingForm.cs` (129 LOC)
- `Modules.Supervisor/Ui/SupervisorMainForm.cs` (188 LOC)
`Modules.Accounting/Logic/PdfExporter.cs` sieht in einer naiven Suche nach WinForms aus, ist es
aber nicht `Font` und `Colors` stammen dort aus `MigraDoc.DocumentObjectModel`. (Der PdfExporter
hat ein *anderes* Linux-Problem, s. 7.2.)
**Das heißt:** Zieht man diese drei Dateien heraus, sind Core und alle Module sofort headless-fähig.
Genau darauf baut die Empfehlung in Abschnitt 11 auf.
**Aufwand Block B: 1 Tag.**
---
## 5. Block C Kultur und Stringformatierung
Der Bestand ist überwiegend gut: `StringComparison.Ordinal`/`OrdinalIgnoreCase` wird konsequent
benutzt, der `IbkrMapping` und der `CsvExporter` arbeiten korrekt mit `CultureInfo.InvariantCulture`.
Es gibt aber fünf konkrete Fundstellen.
### 5.1 `PdfExporter` formatiert Geldbeträge kulturabhängig — **relevant, weil Finanzdokument**
`src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs:25`
```csharp
string M(decimal baseAmount) => V(baseAmount).ToString("N2") + " " + currencyCode;
```
Ebenso Zeile 86: `e.Quantity.ToString("0.###")`, `e.PriceNative.ToString("0.###")`.
Alle ohne `IFormatProvider`, also **CurrentCulture**. Auf dem heutigen deutschen Windows kommt
`1.234,56` heraus. In einem Linux-Container mit `LANG=C` oder mit
`InvariantGlobalization=true` wird daraus `1,234.56` dieselbe Zahl, andere Bedeutung für einen
Leser, und der PDF-Export ist ausdrücklich als *prüfbare Aufstellung* gedacht.
Auffällig: der `CsvExporter` im selben Modul macht es richtig (`CultureInfo.InvariantCulture`).
Der PDF-Export sollte bewusst festgelegt werden entweder fest `de-DE` (Leserfreundlichkeit) oder
fest invariant (Maschinenlesbarkeit), aber nicht "was der Host gerade meint".
### 5.2 `CapitolTradesScraper.ParseDate` parst kulturabhängig
`src/IBKRTrader.Modules.CongressTrading/Scraper/CapitolTradesScraper.cs:333`
```csharp
return DateOnly.TryParse(datePart, out var d) ? d : null;
```
Quelle ist capitoltrades.com mit ISO-Datum, das unter den meisten Kulturen durchgeht aber
`TryParse` ohne `IFormatProvider` ist bei einem externen, unkontrollierten Eingabeformat die
falsche Wahl. `TryParseExact("yyyy-MM-dd", InvariantCulture)` ist hier auch fachlich richtiger:
ein Formatwechsel bei der Quelle soll *auffallen*, nicht stillschweigend zu einem falschen Datum
werden. Gleiches gilt für `int.Parse(match.Groups[1].Value)` in Zeile 124.
### 5.3 Kulturabhängiges `ToUpper()` / `ToLower()`
- `Core/Logging/LoggingService.cs:115` `e.Level.ToString().ToUpper()`
- `Core/IBKR/IBKRGatewayService.cs:247` `outsideRth.ToString().ToLower()`, fließt in eine URL
Beide sind das klassische Türkisch-I-Problem und beide mit `…Invariant()` in einer Minute erledigt.
Der zweite ist der unangenehmere, weil er in einen HTTP-Query-String geht.
### 5.4 Die Grundsatzentscheidung: ICU oder Invariant?
Auf Linux kommt die Kulturdatenbank aus **ICU** (`libicu`). Das muss entschieden und im csproj
festgeschrieben werden:
- **Mit ICU** (`libicu` im Image installieren): Kulturen verhalten sich weitgehend wie auf
Windows ab .NET 5, das ebenfalls ICU benutzt. Größeres Image.
- **`InvariantGlobalization=true`**: schlankes Image, keine ICU-Abhängigkeit aber *jede*
kulturabhängige Formatierung wird lautlos invariant. Genau dann schlagen 5.1 und 5.2 durch.
Solange 5.1/5.2 nicht behoben sind, ist die Wahl sicherheitsrelevant. Danach ist sie beliebig.
Empfehlung: Fundstellen explizit machen, dann `InvariantGlobalization=true` (schlank und
deterministisch).
**Aufwand Block C: 0,5 Tage.**
---
## 6. Block D Zeit und Zeitzonen ⚠ **der kritischste Punkt**
Hier liegt das einzige Risiko, das *stillschweigend falsche Daten* erzeugt statt einen Fehler.
### 6.1 Die Ausgangslage
Der Code mischt drei Konventionen:
| Konvention | Fundstellen (Auswahl) |
|---|---|
| `DateTime.UtcNow` korrekt für Persistenz | ~25 Stellen: alle EF-Entitäten, `ExecutionService`, `PortfolioService`, `TradeHistoryService`, `AccountingIngestService`, `BudgetService` |
| `DateTime.Now` Ortszeit des Hosts | `LoggingService:51`, `BackupWorker:34`, `WorkerBase:114/118/146`, `DailyReportService:44`, drei UI-Statuszeilen, `PdfExporter:51` |
| `Kind = Unspecified` weder noch | `IbkrMapping.ParseExecutionTime` |
### 6.2 `ParseExecutionTime` verwirft die Zeitzone bewusst, aber jetzt folgenreich
`src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs:99` (aus R10, gerade committet):
```csharp
/// Die Zeitzone wird verworfen der Wert bleibt Ortszeit der Börse, wie ihn TWS meldet.
```
TWS liefert je nach Aufruf `20260804 17:52:56` oder `20260804 17:52:56 Europe/Berlin`. Der
Suffix wird abgeschnitten, es entsteht ein `DateTime` mit `Kind = Unspecified`. Dieser Wert
landet in `BrokerExecution.Time` und von dort perspektivisch in der Buchführung wo er neben
`DateTime.UtcNow`-Feldern liegt.
Solange alles auf **einem** Windows-Rechner mit `Europe/Berlin` läuft, ist das konsistent genug,
um nicht aufzufallen. Auf einem Linux-Container mit `TZ=UTC` bedeutet derselbe abgeschnittene
Zeitstempel plötzlich etwas anderes als vorher **ohne dass sich eine Zeile Code ändert.** Es
gibt keine Exception, keinen Log-Eintrag, nur um 12 Stunden verschobene Ausführungszeiten.
**Das ist zu klären, bevor die erste Zeile portiert wird.** Der saubere Weg: die von TWS
gemeldete Zeitzone *nicht* verwerfen, sondern über `TimeZoneInfo` (IANA-IDs, die TWS liefert
bereits `Europe/Berlin`-Format) nach UTC konvertieren und als `DateTimeOffset` führen. Das ist
auch unabhängig von Linux die bessere Lösung, weil Ausführungen an US-Börsen sonst
Berliner Ortszeit tragen.
> **Nebenbefund:** IANA-IDs (`Europe/Berlin`) funktionieren mit `TimeZoneInfo` auf .NET 6+ auch
> auf Windows. Es braucht also keine ID-Übersetzung ein Problem, das man bei solchen
> Portierungen sonst regelmäßig hat, entfällt hier.
### 6.3 `DailyReportService` feuert zur falschen Uhrzeit
`src/IBKRTrader.Modules.Supervisor/Services/DailyReportService.cs:56`
```csharp
var candidate = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0, DateTimeKind.Local);
```
`IBKRTRADER_SUPERVISOR_DAILY=18` heißt heute "18 Uhr deutscher Zeit". Auf einem UTC-Container
heißt es "20 Uhr deutscher Zeit" (Sommerzeit). Fachlich meint man aber eine Börsen- bzw.
Ortszeit. Lösung: eine explizite Report-Zeitzone konfigurierbar machen statt `Local` zu benutzen.
Der Test `NextRun` existiert bereits und lässt sich mitziehen.
### 6.4 Logdatei- und Backup-Namen
`LoggingService` benennt Dateien nach `DateTime.Now` (`{Level}-dd-MM-yy.txt` bzw.
`{yyyy-MM-dd}.jsonl`), `BackupWorker` nach `DateTime.Now` (`yyyy-MM-dd_HH-mm`). Beim Umzug auf
UTC entsteht ein einmaliger Bruch in der Dateibenennung: der Tageswechsel liegt woanders, es
kann für einen Tag zwei Teil-Dateien geben. Nicht kritisch, aber der `SupervisorTools`-Zugriff
`Logs/{date}.jsonl` und `DossierService` lesen genau diese Namen man sollte es wissen und
bewusst umstellen, statt es zu entdecken.
### 6.5 Datenbank
MariaDB speichert `DATETIME` ohne Offset. Solange die App UTC schreibt und UTC liest, ist der
Server-`time_zone` egal. Wandert die DB später auch, ist das der Punkt, an dem man `SET time_zone`
prüfen muss. **Ungeprüft** für den reinen App-Umzug nicht relevant.
**Aufwand Block D: 12 Tage**, davon der größere Teil Audit und Tests, nicht Code.
---
## 7. Block E Dateisystem, Pfade, Prozesse
### 7.1 `BackupWorker` ist der einzige echt Windows-gebundene Codeteil
`src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs:95-118`
```csharp
var candidates = new[] {
"mysqldump.exe",
@"C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqldump.exe",
@"C:\Program Files\MySQL\MySQL Server 8.4\bin\mysqldump.exe",
@"C:\xampp\mysql\bin\mysqldump.exe"
};
foreach (var dir in pathVar.Split(';'))
var full = Path.Combine(dir.Trim(), "mysqldump.exe");
```
Drei Fehler auf einmal: `.exe`-Endung, Windows-Installationspfade, und `PATH` mit `;` getrennt
Linux nutzt `:`. Letzteres ist `Path.PathSeparator`. Lösung: Kandidatenliste und Endung über
`OperatingSystem.IsWindows()` verzweigen, `mariadb-dump` als Kandidat aufnehmen (heißt auf
aktuellen MariaDB-Versionen so), Trennzeichen aus `Path.PathSeparator`.
> **Zusätzlicher Sicherheitsbefund, der erst auf Linux entsteht:** Zeile 66 übergibt das
> DB-Passwort als Kommandozeilenargument (`--password={db.Password}`). Unter Linux ist
> `/proc/<pid>/cmdline` für **jeden lokalen Nutzer lesbar** das Passwort steht damit für die
> Dauer des Dumps offen im Prozessbaum. Auf Windows ist das weniger exponiert. Beim Umzug also
> gleich auf `MYSQL_PWD` (Umgebungsvariable) oder eine temporäre Options-Datei mit `chmod 600`
> umstellen. Halber Tag, und unabhängig von Linux ohnehin die bessere Lösung.
### 7.2 PDF-Export findet auf Linux keine Schriftart
`src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs:30`
```csharp
style.Font.Name = "Segoe UI";
```
"Segoe UI" ist eine Windows-Schrift und auf Linux nicht vorhanden. PDFsharp 6.x löst Schriften
auf Nicht-Windows-Plattformen nicht automatisch auf es braucht einen eigenen
`GlobalFontSettings.FontResolver` (`IFontResolver`), der die Schriftdatei liefert. Ohne ihn
scheitert der Export zur Laufzeit.
Lösung: eine freie Schrift (z. B. DejaVu Sans oder Inter) als `EmbeddedResource` ins
Accounting-Modul legen und einen kleinen `IFontResolver` implementieren. Das macht den PDF-Export
gleichzeitig **plattformunabhängig reproduzierbar** dasselbe Dokument sieht auf jedem Host
identisch aus, was für ein prüfbares Finanzdokument ein Gewinn ist. Rund ein halber Tag.
### 7.3 Schreibzugriff neben der Binärdatei
`Logs/`, `Backups/`, `settings.json`, `master.key` und `openrouter.key` liegen alle unter
`AppDomain.CurrentDomain.BaseDirectory` bzw. `AppContext.BaseDirectory`. Auf Windows neben der
`.exe` üblich. Auf Linux liegt eine Anwendung typischerweise unter `/opt/…` oder `/usr/local/…`
und der Dienstbenutzer hat dort **keinen Schreibzugriff**. Erwartet werden `/var/log/ibkrtrader`,
`/var/lib/ibkrtrader`, `/etc/ibkrtrader`.
Das ist kein Einzeiler, sondern eine kleine Entwurfsentscheidung: die vier Pfade sollten aus einer
zentralen `IAppPaths`-Abstraktion kommen, die unter Windows das heutige Verhalten beibehält und
unter Linux den FHS-Konventionen folgt (oder per Umgebungsvariable überschreibbar ist).
Betroffen sind `LoggingService:18`, `SettingsService:13`, `BackupWorker:35/126`,
`Program.cs:227`, `OpenRouterClient:39`, `SupervisorTools:30`, `DossierService:29`.
Dazu: `master.key` und `openrouter.key` brauchen auf Linux `chmod 600`. Ein Startup-Check, der
zu weite Rechte meldet, wäre angemessen Windows-ACLs übertragen sich nicht.
### 7.4 Groß-/Kleinschreibung
Linux-Dateisysteme sind case-sensitiv. Zwei Stellen sind zu beachten:
- Der Ordner heißt bei CongressTrading `UI/`, bei Accounting und Supervisor `Ui/`. MSBuild-Globbing
stört das nicht, aber es ist eine Inkonsistenz, die man bei der Gelegenheit begradigen sollte.
- `LoggingService:63` baut Log-Verzeichnisse aus dem `Module`-String: `Logs/Core`, `Logs/IBKR`,
`Logs/CT`, `Logs/Supervisor`, `Logs/Accounting`, `Logs/AI`. Diese Strings sind über den Code
verstreut (~90 Aufrufstellen). Auf Windows wären `Logs/CT` und `Logs/ct` dasselbe Verzeichnis,
auf Linux zwei. Die Schreibweisen sind heute konsistent aber es ist eine Fußangel, die eine
Konstantenklasse (`LogModules.Core` etc.) endgültig entschärfen würde.
### 7.5 Kleinigkeiten
- `LoggingService:69/72` schreibt hartkodiert `"\r\n"` in die `.txt`-Logs. Auf Linux kosmetisch
störend; `Environment.NewLine` wäre richtig. (Das JSONL nutzt korrekt `"\n"`.)
- `.gitattributes` ist vorhanden und korrekt konfiguriert (`* text=auto`, Binärdateien ausgenommen).
**Kein Handlungsbedarf** gemischte Zeilenenden werden beim Arbeiten von Linux aus nicht churnen.
- `scripts/provision-db.ps1` ist PowerShell. `pwsh` gibt es auf Linux, aber ein `.sh`-Pendant wäre
freundlicher. Optional, 1 Stunde.
- Der Code mischt `AppDomain.CurrentDomain.BaseDirectory` (ältere Dateien) und
`AppContext.BaseDirectory` (neuere). Identischer Wert, rein kosmetisch erledigt sich mit 7.3.
**Aufwand Block E: 11,5 Tage.**
---
## 8. Block F Netzwerk und Dienste
Drei `HttpListener`-basierte Dienste:
| Datei | Prefix |
|---|---|
| `Core/Workers/BuiltIn/WebserverService.cs:38` | `http://localhost:{port}/` |
| `Core/Workers/BuiltIn/WebApiService.cs:46` | `http://localhost:{port}/api/` |
| `Modules.Supervisor/Mcp/McpLightServer.cs:44` | `http://127.0.0.1:{port}/mcp/` |
`HttpListener` **funktioniert auf Linux** (dort als verwaltete Socket-Implementierung statt über
`http.sys`). Zu beachten:
- Ports unter 1024 brauchen root. Genutzt werden 5001 und ein per Env gesetzter MCP-Port, der
bereits auf `102465535` geprüft wird **passt**.
- Kein HTTPS ohne Zusatzarbeit. Alle drei binden auf localhost, also derzeit kein Thema.
- Auf Linux entfällt die `netsh urlacl`-Registrierung eine Erleichterung, kein Problem.
`IBKRGatewayService:39` setzt `ServerCertificateCustomValidationCallback = (_,_,_,_) => true`, akzeptiert
also jedes Zertifikat. Das ist für den lokalen Client-Portal-Gateway mit Selbstsignat gedacht und
funktioniert auf Linux identisch. Es ist unabhängig von dieser Portierung eine Stelle, die man
irgendwann auf Pinning des Gateway-Zertifikats einengen sollte hier nur der Vollständigkeit halber.
**Aufwand Block F: 0 Tage** (nur Verifikation). Langfristig wäre ein Umstieg von `HttpListener`
auf Kestrel/Minimal-API sauberer `HttpListener` gilt als Altlast , das ist aber **nicht**
Voraussetzung für Linux.
---
## 9. Block G Die UI: Avalonia + LiveCharts2
### 9.1 Umfang
| Datei | LOC |
|---|---|
| `LauncherForm.cs` | 162 |
| `UI/ShellUiHost.cs` | 107 |
| `UI/WorkerListBindingSource.cs` | 77 |
| `UI/LogPanelController.cs` | 33 |
| `UI/Views/DashboardView.cs` | 98 |
| `UI/Views/{Logs,Settings,Workers}View.cs` | 64 |
| `Core/Modularity/WindowMenu.cs` + `ModuleView.cs` | 136 |
| `Modules.Accounting/Ui/AccountingMainForm.cs` | 302 |
| `Modules.Supervisor/Ui/SupervisorMainForm.cs` | 188 |
| `Modules.CongressTrading/UI/CongressTradingForm.cs` | 129 |
| **Summe** | **~1.300** |
Erfahrungsgemäß wächst das bei einer Umsetzung mit XAML + ViewModels auf 2.0002.500 LOC, weil
MVVM Struktur kostet, die im Code-Behind heute implizit ist.
**Kein einziges Diagramm im Bestand.** Die Suche nach `Chart`/`Series`/`Plot` liefert null Treffer.
LiveCharts2 ist damit **kein Migrationsposten, sondern ein Feature-Neubau** und sollte auch so
geplant und geschätzt werden, getrennt vom Rest.
### 9.2 Was gut übertragbar ist
Der Shell-Entwurf passt bemerkenswert gut auf Avalonia:
- `ShellUiHost` (Dictionary offener Fenster, eine Instanz je View, `OpenStateChanged`-Event) ist
**fast vollständig toolkit-neutral**. Nur der Typ `Form` und `MessageBox.Show` müssen getauscht
werden. Die 107 LOC überleben zu ~80 %.
- Mehrere gleichrangige Top-Level-Fenster + Launcher entsprechen exakt Avalonias
`IClassicDesktopStyleApplicationLifetime` mit mehreren `Window`-Instanzen.
- `WorkerInfo` implementiert bereits `INotifyPropertyChanged` das ist genau das, was Avalonias
Binding erwartet. `WorkerListBindingSource` (`BindingList<T>`) wird zu `ObservableCollection<T>`.
- Das UI-Thread-Marshalling (`InvokeRequired`/`BeginInvoke`, 6 Stellen) wird zu
`Dispatcher.UIThread.Post` ein mechanischer 1:1-Ersatz.
### 9.3 Was echte Arbeit ist
| WinForms | Avalonia | Anmerkung |
|---|---|---|
| `DataGridView` (4×) | `DataGrid` | **eigenes Paket** `Avalonia.Controls.DataGrid` + Theme-Include in `App.axaml`. `AccountingMainForm:187` bindet heute anonyme Typen die müssen zu echten Record-ViewModels werden. |
| `MessageBox.Show` (`ShellUiHost:46`) | | Avalonia hat **keine** eingebaute MessageBox. Eigener Dialog oder Zusatzpaket. |
| `SaveFileDialog` (2×) | `IStorageProvider.SaveFilePickerAsync` | asynchron, anderer API-Zuschnitt |
| `RichTextBox` mit `SelectionColor` | `ItemsControl`/`SelectableTextBlock` | Das farbige Log-Panel muss anders gebaut werden (eingefärbte Items statt Selection-Färbung) im Ergebnis sauberer. |
| `ToolStrip`/`MenuStrip`/`StatusStrip` | `Menu` + Panel-Layout | Direkte Entsprechungen fehlen; wird handgebaut. |
| `DateTimePicker`, `ComboBox` | `DatePicker`, `ComboBox` | unkritisch |
| `SystemColors.GrayText` etc. | Theme-Ressourcen | Fluent-Theme, gleichzeitig Hell/Dunkel möglich |
| `Dock`/`Anchor`, absolute `Point`-Positionen | Grid/DockPanel/StackPanel | Layout muss neu gedacht, nicht übersetzt werden |
Die Steuerelement-Inventur über alle UI-Dateien: 12 `Label`, 10 `Button`, 9 `Panel`,
4 `FlowLayoutPanel`, 4 `DataGridView`, 2 `ToolStrip`, 2 `TabControl`, 2 `SaveFileDialog`,
2 `MenuStrip`, 1 `StatusStrip`, 1 `RichTextBox`, 1 `BindingSource`. Überschaubar es gibt keine
exotischen Controls und kein Custom-Drawing.
### 9.4 Lizenz und Laufzeitabhängigkeiten
- **Avalonia ist MIT-lizenziert**, ohne Kosten und ohne Umsatzschwelle. "Avalonia Accelerate" ist
nur ein optionales kommerzielles Support-/Tooling-Paket. Für das Projekt entstehen keine
Lizenzkosten. (Das passt zur bereits getroffenen Linie PDFsharp wurde ausdrücklich statt
QuestPDF gewählt, um Umsatzschwellen zu vermeiden.)
- **LiveCharts2** (`LiveChartsCore.SkiaSharpView.Avalonia`) ist ebenfalls MIT.
- Beide bringen **SkiaSharp** mit. Auf Linux braucht das `libfontconfig1` und (für die Desktop-UI)
X11- oder Wayland-Bibliotheken im Image. Bei einer headless Variante entfällt das komplett
ein weiteres Argument für die Trennung in Abschnitt 11.
- Zusätzliche `packageSourceMapping`-Muster: `Avalonia*`, `LiveChartsCore*`, `SkiaSharp*`,
`HarfBuzzSharp*`, `Tmds.DBus*`, `MicroCom*`.
**Aufwand Block G: 1014 Personentage** für jemanden, der Avalonia kennt. Ohne Vorerfahrung
realistisch +35 Tage Einarbeitung. LiveCharts2 zusätzlich 13 Tage je nach gewünschtem Umfang.
---
## 10. Block H Tests, Smoke-Check und CI
165 Tests, 2.302 LOC. Das Testprojekt steht auf `net10.0-windows` + WinForms **wegen genau einer
Datei**: `UiConstructionTests.cs` (87 LOC), die die Modul-Fenster auf einem STA-Thread konstruiert.
Dieselbe Idee steckt hinter dem `--smoke-ui`-Schalter in `Program.cs:270`.
Nach der Portierung:
- Die restlichen ~2.200 LOC Tests laufen **unverändert** auf `net10.0`/Linux. Sie testen Mapping,
Risiko, Portfolio, Accounting, Supervisor, Krypto, Logging alles portabel, mit
EF-InMemory statt echter DB.
- `UiConstructionTests` und `--smoke-ui` werden auf `Avalonia.Headless` umgestellt. **Das ist ein
Gewinn, kein Verlust:** WinForms lässt sich in CI ohne Desktop-Session nicht sinnvoll
instanziieren, Avalonia.Headless ist genau dafür gebaut. Der Smoke-Check wird damit CI-fähig,
was er heute nicht ist.
- Eine Test-Fixture hängt an einem Pfad: `tests/…csproj` bindet `..\..\ct_raw.html` mit
Backslashes ein. MSBuild normalisiert das **kein Problem**.
Empfehlung für die CI: eine GitHub-Actions-/Gitea-Actions-Matrix `ubuntu-latest` + `windows-latest`
einrichten, sobald Etappe 1 steht. Das hält die Portabilität dauerhaft und fängt Rückfälle
(neues `DateTime.Now`, neues `.ToString("N2")`) sofort ab.
**Aufwand Block H: 12 Tage.**
---
## 11. Betrieb: der Punkt, den man leicht übersieht
**Die App auf Linux zu bringen, macht das IB Gateway nicht Linux-tauglich.** Das ist eine getrennte
Baustelle:
- TWS bzw. IB Gateway ist eine Java-Anwendung und läuft auf Linux aber mit GUI. Für den
Dauerbetrieb ohne Bildschirm braucht es **IBC** (IBController) plus **Xvfb** als virtuellen
X-Server, dazu die Behandlung des täglichen Auto-Restarts und des 2FA-Handlings.
- Das ist erfahrungsgemäß **12 Tage** eigener Arbeit und hat mit dem C#-Code nichts zu tun.
- Alternative: Gateway bleibt auf dem Windows-Rechner, die Linux-App verbindet sich über das Netz
auf Port 4002. Dann muss in der TWS-Konfiguration die erlaubte Client-IP eingetragen werden
(heute steht in `settings.example.json` `127.0.0.1`) und der TWS-API-Verkehr ist unverschlüsselt,
gehört also nicht über ein unvertrautes Netz.
Weitere Betriebspunkte: `systemd`-Unit statt Autostart, Log-Rotation über `logrotate` statt
`BackupWorker`-Kopien, Dienstbenutzer ohne Login-Shell.
**Aufwand Block I: 13 Tage**, je nachdem ob das Gateway mitwandert.
---
## 12. Aufwandsübersicht
| Block | Inhalt | Tage |
|---|---|---:|
| **0** | `NuGet.config` reparieren (blockiert alles andere) | 0,1 |
| **A** | Zielframeworks, csproj-Aufteilung, Projektstruktur | 0,5 |
| **B** | Core von WinForms lösen (3 Dateien + 3 Modul-Forms herauslösen) | 1,0 |
| **C** | Kultur/Formatierung (5 Fundstellen + Globalisierungsentscheidung) | 0,5 |
| **D** | Zeit/Zeitzonen ⚠ (Audit, `ParseExecutionTime`, `DailyReportService`, Tests) | 1,5 |
| **E** | Dateisystem (`BackupWorker`, PDF-Fonts, `IAppPaths`, Secrets-Rechte) | 1,5 |
| **F** | Netzwerk/Dienste (nur Verifikation) | 0,0 |
| **H** | Tests auf `net10.0`, Smoke-Check headless, CI-Matrix | 1,5 |
| | **Zwischensumme: headless Linux lauffähig** | **~6,5** |
| **G1** | Avalonia: Shell, Launcher, 4 Core-Views | 5,0 |
| **G2** | Avalonia: 3 Modul-Fenster (Accounting ist das größte) | 5,0 |
| **G3** | Theming, Feinschliff, Dialoge, Icons | 2,0 |
| | **Zwischensumme: Desktop-Linux** | **~12** |
| **G4** | LiveCharts2 Neubau, kein Bestand vorhanden | 13 |
| **I** | Betrieb: systemd, Deployment, ggf. IB Gateway headless (IBC/Xvfb) | 13 |
| | **Gesamt** | **~2125 Personentage** |
Ohne Avalonia-Vorerfahrung auf Block G realistisch **+35 Tage** aufschlagen.
Zum Vergleich in Wochen: **headless in gut einer Woche**, **komplett mit Desktop-UI in etwa
45 Wochen** Vollzeit.
---
## 13. Vorschlag: in zwei Etappen, nicht in einem Zug
### Etappe 1 Headless Linux (~1,5 Wochen)
Ziel: Core + alle drei Module + Worker + REST/MCP laufen als `systemd`-Dienst auf Linux. Die
Windows-UI bleibt **unverändert bestehen** und läuft weiter.
1. `NuGet.config` reparieren.
2. Die drei Core-Dateien und die drei Modul-Forms herauslösen; Core und Module auf `net10.0`.
3. Neues Projekt `IBKRTrader.Daemon` (Konsole, `net10.0`) nimmt `Program.cs` ab Zeile 51
(`Host.CreateDefaultBuilder`) fast unverändert auf. Der Generic Host und die
`IHostedService`-Worker sind dafür bereits die richtige Grundlage; das wurde in R4 gelegt.
4. Blöcke C, D, E abarbeiten.
5. Testprojekt auf `net10.0`, CI-Matrix Linux + Windows.
Danach ist die App auf Linux **im Dauerbetrieb einsatzfähig** ohne dass ein einziges Fenster
angefasst wurde. Für ein System, das rund um die Uhr Marktdaten zieht und Signale verarbeitet, ist
das der eigentliche Gewinn.
### Etappe 2 Avalonia-Desktop (~2,53 Wochen)
Ziel: `IBKRTrader.Desktop` ersetzt `IBKRTrader.App` und läuft auf beiden Plattformen.
6. Shell + Launcher + Core-Views.
7. Die drei Modul-Fenster.
8. Erst danach LiveCharts2 als eigenständiges Feature mit eigener Anforderung, nicht als
Nebenprodukt der Portierung.
**Warum diese Reihenfolge:** Etappe 1 bringt den vollen Betriebsnutzen bei einem Sechstel des
Aufwands, und sie ist reversibel wenn Etappe 2 liegen bleibt, steht trotzdem ein funktionierendes
System da. Umgekehrt (erst UI) hätte man nach drei Wochen eine schöne Oberfläche und immer noch
keinen Linux-Betrieb.
---
## 14. Was du entscheiden musst
Vier Punkte, die nicht aus dem Code ableitbar sind:
1. **Zeitzonen-Konvention.** Alles UTC in der Persistenz und nur an der Oberfläche umrechnen? Oder
eine feste "Betriebszeitzone"? Das entscheidet den Zuschnitt von Block D und es entscheidet,
wie `ParseExecutionTime` künftig aussieht. **Das ist der einzige Punkt, der vor dem ersten
Handgriff geklärt sein muss.**
2. **Wandert das IB Gateway mit auf Linux** (IBC + Xvfb, eigene 12 Tage) oder bleibt es auf dem
Windows-Rechner und die Linux-App verbindet sich über das lokale Netz?
3. **Soll die Windows-Desktop-Version erhalten bleiben?** Avalonia läuft auf beiden Plattformen
die Frage ist nur, ob Windows weiter *getestet* werden muss (CI-Matrix) oder ob Linux das
alleinige Ziel wird.
4. **Zahlenformat im PDF-Export:** fest deutsch oder fest invariant? Für ein prüfbares
Finanzdokument sollte es festgelegt und nicht vom Host abhängig sein (5.1).
---
## Anhang: Fundstellenverzeichnis
Kurzliste aller konkret zu ändernden Stellen außerhalb der UI, nach Datei sortiert:
| Datei | Zeile | Befund | Block |
|---|---|---|---|
| `NuGet.config` | | 3 fehlende `packageSourceMapping`-Muster → Restore schlägt fehl | 0 |
| alle 6 `*.csproj` | | `net10.0-windows` + `UseWindowsForms` | A |
| `Core/Logging/LoggingService.cs` | 12, 22, 100138 | `RichTextBox`, `System.Drawing.Color` | B |
| `Core/Logging/LoggingService.cs` | 115 | `.ToUpper()` kulturabhängig | C |
| `Core/Logging/LoggingService.cs` | 18, 63, 66, 88 | Pfade unter `BaseDirectory`, Modulnamen als Verzeichnisse | E |
| `Core/Logging/LoggingService.cs` | 69, 72 | hartkodiertes `\r\n` | E |
| `Core/Modularity/ModuleView.cs` | 27, 30 | `System.Drawing.Image`, `Func<Form>` | B |
| `Core/Modularity/WindowMenu.cs` | gesamt | vollständig WinForms, wird ersetzt | B |
| `Core/Trading/Ibkr/IbkrMapping.cs` | 99113 | verwirft Zeitzone, `Kind = Unspecified` ⚠ | D |
| `Core/IBKR/IBKRGatewayService.cs` | 247 | `.ToLower()` kulturabhängig, geht in URL | C |
| `Core/Workers/BuiltIn/BackupWorker.cs` | 95118 | `mysqldump.exe`, `C:\`-Pfade, `PATH.Split(';')` | E |
| `Core/Workers/BuiltIn/BackupWorker.cs` | 66 | DB-Passwort in der Kommandozeile (auf Linux exponiert) | E |
| `Core/Workers/BuiltIn/BackupWorker.cs` | 34, 35, 126 | `DateTime.Now`, `BaseDirectory` | D/E |
| `Core/Workers/WorkerBase.cs` | 114, 118, 146 | `DateTime.Now` in der Ablaufsteuerung | D |
| `Core/Settings/SettingsService.cs` | 13 | `settings.json` neben der Binärdatei | E |
| `Modules.Accounting/Logic/PdfExporter.cs` | 30 | `"Segoe UI"` auf Linux nicht vorhanden | E |
| `Modules.Accounting/Logic/PdfExporter.cs` | 25, 86 | `ToString("N2")`/`("0.###")` kulturabhängig | C |
| `Modules.Accounting/Ui/AccountingMainForm.cs` | gesamt (302) | WinForms, 3 `DataGridView`, 2 `SaveFileDialog` | G |
| `Modules.CongressTrading/Scraper/CapitolTradesScraper.cs` | 124, 333 | `int.Parse`/`DateOnly.TryParse` ohne `IFormatProvider` | C |
| `Modules.CongressTrading/UI/CongressTradingForm.cs` | gesamt (129) | WinForms | G |
| `Modules.Supervisor/Services/DailyReportService.cs` | 44, 56 | `DateTime.Now` + `DateTimeKind.Local` → falsche Uhrzeit auf UTC-Host | D |
| `Modules.Supervisor/Agent/OpenRouterClient.cs` | 39 | `openrouter.key` neben der Binärdatei, Dateirechte | E |
| `Modules.Supervisor/Ui/SupervisorMainForm.cs` | gesamt (188) | WinForms, `RichTextBox` | G |
| `Program.cs` | 31, 48, 227, 270320 | `[STAThread]`, `ApplicationConfiguration`, `master.key`, Smoke-UI | A/B/E |
| `LauncherForm.cs`, `UI/**` | gesamt (541) | WinForms-Shell | G |
| `tests/…csproj` + `UiConstructionTests.cs` | | `net10.0-windows` nur wegen einer Datei | H |
@@ -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
@@ -56,7 +76,7 @@ Registrierung in `Program.cs`. Referenziert nur den Core. Eigener `AccountingDbC
`PdfExporter` (PDFsharp/MigraDoc, MIT). `PdfExporter` (PDFsharp/MigraDoc, MIT).
- Realisierte GuV nutzt den Core-`RealizedPnlEngine` (FIFO) — kein Duplikat. - Realisierte GuV nutzt den Core-`RealizedPnlEngine` (FIFO) — kein Duplikat.
## 5. UI (WinForms, ein Fenster mit Tabs) ## 5. UI (Avalonia, ein Fenster mit Registerkarten)
Übersicht/BWA (KPI-Kacheln + Monatsvergleich, Zeitraum-/Konto-/Währungswahl), Ledger (filterbar), Übersicht/BWA (KPI-Kacheln + Monatsvergleich, Zeitraum-/Konto-/Währungswahl), Ledger (filterbar),
Steuer (Platzhalter, s. u.), Abrechnung/Export (CSV/PDF), Abruf/Status (Ingest-Läufe, Soll-Ist, manueller Steuer (Platzhalter, s. u.), Abrechnung/Export (CSV/PDF), Abruf/Status (Ingest-Läufe, Soll-Ist, manueller
Trigger). DB-Zugriff nur auf Interaktion (Smoke-UI-sicher). Trigger). DB-Zugriff nur auf Interaktion (Smoke-UI-sicher).
@@ -1,3 +1,13 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) (Stufe 5, dazu die Core-Voraussetzungen in Stufe 3) dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es den Zustandsautomaten, das Regelwerk mit allen Vorgabewerten und die Begruendung enthaelt,
> warum das Modul nicht additiv auf den heutigen aktienbasierten Core passt. Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# Konzept: Modul „OptionsWheel" (Covered Call / Cash-Secured Put) # Konzept: Modul „OptionsWheel" (Covered Call / Cash-Secured Put)
> Stand: 2026-08-03 > Stand: 2026-08-03
@@ -1,5 +1,23 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) (Bahn „Supervisor") dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es S-0 bis S-4 im Einzelnen beschreibt, samt Tool-Registry und den
> Sicherheitsgrenzen des Agenten. Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# Konzept: Modul „Supervisor" (KI-gestützte Handels-Analyse & Forensik) # Konzept: Modul „Supervisor" (KI-gestützte Handels-Analyse & Forensik)
> **UMGESETZT (S-0 bis S-4).** Datenfundament im Core (`core_decision_journal`, `core_order_events`,
> durchgereichte `SignalId`, JSONL-Log-Sink), `DossierService`/`DossierBuilder`, der
> OpenRouter-Agent mit read-only Tool-Registry und Profilen, `sup_reports`, `CounterfactualJob`,
> `DailyReportService` und der MCP-Server (`McpLightServer`, `McpJsonRpc`).
>
> **Weiterhin offen** sind die beiden Punkte am Ende dieses Dokuments: die Counterfactual-Kursauflösung
> (Interface + Stub vorhanden) und der externe Versand des Tagesberichts.
> Stand: 2026-07-30 > Stand: 2026-07-30
> Ziel: ALLES, was IBKRTrader getan (und bewusst NICHT getan) hat, detailliert analysierbar machen — > Ziel: ALLES, was IBKRTrader getan (und bewusst NICHT getan) hat, detailliert analysierbar machen —
> Entscheidungen, Orders, Trades und Logs — und die Analyse durch ein KI-Modell (OpenRouter) durchführen > Entscheidungen, Orders, Trades und Logs — und die Analyse durch ein KI-Modell (OpenRouter) durchführen
@@ -1,3 +1,13 @@
> ### 📦 Archiviert am 2026-08-23
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
> [Roadmap](../ROADMAP.md) (Stufe 4) dort und nur dort wird der Stand nachgeführt.
>
> Es bleibt erhalten, weil es die vollstaendige Spezifikation ist, gegen die die Umsetzung geprueft wird
> die drei Wahrheiten, Eigentumsregeln, Reservierungs-Lebenszyklus, Invarianten und Testkatalog. Zum Nachschlagen also weiterhin richtig,
> als Aufgabenliste nicht mehr.
---
# Kapital- und Buchmodell # Kapital- und Buchmodell
> **Status: Konzept (2026-08-03) noch nicht implementiert.** > **Status: Konzept (2026-08-03) noch nicht implementiert.**
+22
View File
@@ -0,0 +1,22 @@
{
"_comment": "Kopie als scripts/release.config.json angelegt und ausgefuellt. Diese Datei enthaelt KEINE Zugangsdaten - die kommen aus Umgebungsvariablen (DC_FTP_HOST, DC_FTP_USER, DC_FTP_PASS, DC_TOKEN) oder aus einer packager.config.json neben dem Werkzeug. release.config.json darf deshalb versioniert werden.",
"_project_comment": "IBKRTrader.Daemon ist der Release-Kandidat: plattformneutral (net10.0 ohne Plattformbindung, siehe README), laeuft kopflos auf beiden Zielsystemen. IBKRTrader.App (Avalonia-Shell) ist bewusst NICHT Teil dieser automatisierten Pipeline - das Vorlagenskript nimmt genau eine csproj fuer alle Runtimes, und das Skript bleibt laut Anleitung unveraendert. Eine Desktop-Distribution der Shell ist ein separater, spaeterer Schritt.",
"project": "ibkrtrader",
"csproj": "src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj",
"_runtimes_comment": "Je Eintrag entsteht ein eigenes Release - ohne Plattformangabe wuerden sie sich gegenseitig ueberschreiben.",
"runtimes": ["win-x64", "linux-x64"],
"_selfContained_comment": "true nimmt die .NET-Laufzeit ins Paket, damit ein frisch aufgesetztes System kein vorinstalliertes .NET braucht (siehe deploy/README.md zur ICU-Warnung).",
"selfContained": true,
"publishSingleFile": false,
"setupJson": "setup.json",
"mainAssembly": "",
"baseUrl": "https://dc.mhdf.de"
}
+233
View File
@@ -0,0 +1,233 @@
<#
.SYNOPSIS
Veroeffentlicht dieses Projekt im Deploymentcenter.
.DESCRIPTION
Vorlage zum Kopieren nach scripts/release.ps1 des eigenen Projekts.
Anzupassen ist nur der Kopf von release.config.json - dieses Skript
selbst bleibt unveraendert.
Der Ablauf je Zielplattform:
dotnet publish -r <rid> -> pack-and-deploy --platform <rid>
pack-and-deploy uebernimmt dabei Pruefsummen, Dateimanifest, das
Fortschreiben der latest.json und die Anmeldung beim Deploymentcenter.
Das hier nachzubauen waere ein zweiter Ort fuer dieselben Fehler; das
Skript orchestriert nur.
Fehlt das Werkzeug, wird es geholt und die Pruefsumme geprueft.
.EXAMPLE
.\scripts\release.ps1 -Version 1.4.3 -Changelog "Behebt den Login-Fehler."
.EXAMPLE
.\scripts\release.ps1 -Version 1.5.0 -Channel beta -WhatIf
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
# Ohne Angabe wird die Version aus Directory.Build.props bzw. der csproj gelesen.
[string] $Version,
[ValidateSet('prod', 'beta', 'dev')]
[string] $Channel = 'prod',
[string] $Changelog,
# Als kritisches Update kennzeichnen (Rollout priorisieren).
[switch] $Critical,
[string] $ConfigFile = (Join-Path $PSScriptRoot 'release.config.json')
)
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------- Konfiguration
if (-not (Test-Path $ConfigFile)) {
throw "Konfiguration fehlt: $ConfigFile`nVorlage kopieren: release.config.example.json -> release.config.json"
}
$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
foreach ($required in @('project', 'csproj', 'runtimes')) {
if (-not $config.$required) {
throw "In $ConfigFile fehlt der Eintrag '$required'."
}
}
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
$csprojRel = $config.csproj
$csproj = Join-Path $repoRoot $csprojRel
if (-not (Test-Path $csproj)) {
throw "Projektdatei nicht gefunden: $csproj"
}
$baseUrl = if ($config.baseUrl) { $config.baseUrl.TrimEnd('/') } else { 'https://dc.mhdf.de' }
$toolDir = Join-Path $repoRoot '.dc-tools'
# ---------------------------------------------------------------------- Version
function Get-ProjectVersion {
# Directory.Build.props zuerst: Steht <Version> nur in einem von mehreren
# Projekten, laufen die Angaben frueher oder spaeter auseinander - und
# pack-and-deploy bricht dann zu Recht mit einem Versionskonflikt ab.
foreach ($candidate in @(
(Join-Path $repoRoot 'Directory.Build.props'),
$csproj
)) {
if (-not (Test-Path $candidate)) { continue }
$match = [regex]::Match((Get-Content $candidate -Raw), '<Version>\s*([^<]+?)\s*</Version>')
if ($match.Success) {
return $match.Groups[1].Value.Trim()
}
}
return $null
}
if (-not $Version) {
$Version = Get-ProjectVersion
if (-not $Version) {
throw "Keine <Version> in Directory.Build.props oder $csprojRel gefunden. Bitte -Version angeben."
}
Write-Host "Version aus dem Projekt gelesen: $Version" -ForegroundColor DarkGray
}
if (-not $Changelog) {
$Changelog = "Release v$Version"
}
# ------------------------------------------------------------------- Werkzeug
function Get-PackAndDeploy {
$exe = Join-Path $toolDir 'pack-and-deploy.exe'
if (Test-Path $exe) { return $exe }
Write-Host "pack-and-deploy wird geholt ..." -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $toolDir | Out-Null
$name = 'pack-and-deploy-win-x64.exe'
$temp = Join-Path $toolDir 'download.tmp'
Invoke-WebRequest -Uri "$baseUrl/installer/$name" -OutFile $temp -UseBasicParsing
$expectedRaw = (Invoke-WebRequest -Uri "$baseUrl/installer/$name.sha256" -UseBasicParsing).Content
$expected = if ($expectedRaw -is [byte[]]) {
[System.Text.Encoding]::ASCII.GetString($expectedRaw)
} else { [string]$expectedRaw }
$expected = $expected.Trim().ToLower()
$actual = (Get-FileHash $temp -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $expected) {
Remove-Item $temp -Force
throw "Pruefsumme von $name stimmt nicht.`n erwartet: $expected`n erhalten: $actual"
}
Move-Item $temp $exe -Force
try { Unblock-File $exe -ErrorAction SilentlyContinue } catch { }
Write-Host " Pruefsumme in Ordnung." -ForegroundColor DarkGray
return $exe
}
$packAndDeploy = Get-PackAndDeploy
# --------------------------------------------------------------------- Ablauf
Write-Host ''
Write-Host "Projekt : $($config.project)" -ForegroundColor White
Write-Host "Version : $Version"
Write-Host "Kanal : $Channel"
Write-Host "Plattform : $($config.runtimes -join ', ')"
Write-Host ''
$results = @()
foreach ($rid in $config.runtimes) {
Write-Host "=== $rid ===" -ForegroundColor Cyan
$publishDir = Join-Path $repoRoot "artifacts/publish/$rid"
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'dotnet publish')) {
# Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden
# sonst mit ins Paket wandern.
if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force }
$publishArgs = @(
'publish', $csproj,
'-c', 'Release',
'-r', $rid,
'-o', $publishDir,
'--nologo'
)
if ($config.selfContained) { $publishArgs += '--self-contained', 'true' }
else { $publishArgs += '--self-contained', 'false' }
if ($config.publishSingleFile) { $publishArgs += '-p:PublishSingleFile=true' }
& dotnet @publishArgs
if ($LASTEXITCODE -ne 0) { throw "dotnet publish fuer $rid ist fehlgeschlagen." }
}
# setup.json mitliefern, damit der Installer weiss, was einzurichten ist.
# Bewusst kein Ternary-Operator: den gibt es erst ab PowerShell 7, und
# diese Vorlage soll auch mit dem mitgelieferten 5.1 laufen.
$setupRel = if ($config.setupJson) { $config.setupJson } else { 'setup.json' }
$setupJson = Join-Path $repoRoot $setupRel
if (Test-Path $setupJson) {
Copy-Item $setupJson (Join-Path $publishDir 'setup.json') -Force
Write-Host " setup.json mitgenommen" -ForegroundColor DarkGray
}
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'pack-and-deploy')) {
$packArgs = @(
'--project', $config.project,
'--version', $Version,
'--channel', $Channel,
'--platform', $rid,
'--publish-dir', $publishDir,
'--changelog', $Changelog
)
if ($Critical) { $packArgs += '--critical' }
if ($config.mainAssembly) { $packArgs += '--main-assembly', $config.mainAssembly }
& $packAndDeploy @packArgs
$code = $LASTEXITCODE
$results += [pscustomobject]@{ Runtime = $rid; ExitCode = $code }
# 1 = Konfigurationsfehler oder Versionskonflikt: dann stimmt etwas
# Grundsaetzliches, und die weiteren Plattformen wuerden genauso
# scheitern.
if ($code -eq 1) { throw "pack-and-deploy meldet einen Konfigurations- oder Versionsfehler." }
}
Write-Host ''
}
# ------------------------------------------------------------------ Ergebnis
Write-Host '=== Ergebnis ===' -ForegroundColor White
foreach ($r in $results) {
$text = switch ($r.ExitCode) {
0 { 'vollstaendig veroeffentlicht' }
2 { 'TEILWEISE - Upload oder Registrierung fehlgeschlagen' }
default { "unerwarteter Rueckgabewert $($r.ExitCode)" }
}
$color = if ($r.ExitCode -eq 0) { 'Green' } else { 'Yellow' }
Write-Host (" {0,-16} {1}" -f $r.Runtime, $text) -ForegroundColor $color
}
$failed = @($results | Where-Object { $_.ExitCode -ne 0 })
if ($failed.Count -gt 0) {
Write-Host ''
Write-Host 'Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen,' -ForegroundColor Yellow
Write-Host 'ob die bereits hochgeladenen Dateien konsistent sind.' -ForegroundColor Yellow
exit 2
}
Write-Host ''
Write-Host "Fertig. $($config.project) $Version ist im Kanal $Channel verfuegbar." -ForegroundColor Green
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env bash
#
# Veroeffentlicht dieses Projekt im Deploymentcenter.
#
# Vorlage zum Kopieren nach scripts/release.sh des eigenen Projekts.
# Anzupassen ist nur scripts/release.config.json - dieses Skript selbst
# bleibt unveraendert.
#
# Der Ablauf je Zielplattform:
# dotnet publish -r <rid> -> pack-and-deploy --platform <rid>
#
# pack-and-deploy uebernimmt Pruefsummen, Dateimanifest, das Fortschreiben der
# latest.json und die Anmeldung beim Deploymentcenter. Das hier nachzubauen
# waere ein zweiter Ort fuer dieselben Fehler; das Skript orchestriert nur.
#
# ./scripts/release.sh --version 1.4.3 --changelog "Behebt den Login-Fehler."
# ./scripts/release.sh --version 1.5.0 --channel beta --dry-run
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="${DC_RELEASE_CONFIG:-$SCRIPT_DIR/release.config.json}"
VERSION=""
CHANNEL="prod"
CHANGELOG=""
CRITICAL=0
DRY_RUN=0
usage() {
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
exit 0
}
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift 2 ;;
--channel) CHANNEL="$2"; shift 2 ;;
--changelog) CHANGELOG="$2"; shift 2 ;;
--critical) CRITICAL=1; shift ;;
--dry-run|-n) DRY_RUN=1; shift ;;
--help|-h) usage ;;
*) echo "Unbekannte Option: $1" >&2; exit 1 ;;
esac
done
# ------------------------------------------------------------- Voraussetzungen
command -v dotnet >/dev/null 2>&1 || {
echo "FEHLER: dotnet wird gebraucht, ist aber nicht installiert." >&2
exit 1
}
[ -f "$CONFIG_FILE" ] || {
echo "FEHLER: Konfiguration fehlt: $CONFIG_FILE" >&2
echo " Vorlage kopieren: release.config.example.json -> release.config.json" >&2
exit 1
}
# JSON lesen - mit jq, sonst mit Python.
#
# Bewusst nicht nur jq: Es ist auf den wenigsten Systemen vorinstalliert, und
# an einer fehlenden Abhaengigkeit soll die Vorlage nicht scheitern. Python
# liegt auf den meisten Entwickler- und CI-Systemen ohnehin bereit.
JSON_READER=""
if command -v jq >/dev/null 2>&1; then
JSON_READER="jq"
else
# Nicht nur pruefen, ob der Befehl existiert, sondern ob er laeuft:
# Windows legt unter WindowsApps einen python3-Platzhalter ab, der
# gefunden wird, beim Aufruf aber nur auf den Store verweist.
for candidate in python3 python; do
if command -v "$candidate" >/dev/null 2>&1 \
&& "$candidate" -c "import json" >/dev/null 2>&1; then
JSON_READER="$candidate"
break
fi
done
fi
if [ -z "$JSON_READER" ]; then
echo "FEHLER: Zum Lesen von $CONFIG_FILE wird jq oder ein lauffaehiges Python gebraucht." >&2
exit 1
fi
# Liest einen Skalar. Pfad in jq-Schreibweise, z. B. .project
cfg() {
if [ "$JSON_READER" = "jq" ]; then
jq -r "$1 // empty" "$CONFIG_FILE"
else
"$JSON_READER" -c "
import json,sys
d=json.load(open(sys.argv[1], encoding='utf-8'))
for part in sys.argv[2].lstrip('.').split('.'):
if not isinstance(d, dict): d=None; break
d=d.get(part)
if d is None: print('')
elif isinstance(d, bool): print('true' if d else 'false')
else: print(d)
" "$CONFIG_FILE" "$1"
fi
}
# Liest ein Feld mit Zeichenketten, eine je Zeile.
cfg_list() {
if [ "$JSON_READER" = "jq" ]; then
jq -r "$1[]?" "$CONFIG_FILE"
else
"$JSON_READER" -c "
import json,sys
d=json.load(open(sys.argv[1], encoding='utf-8'))
for part in sys.argv[2].lstrip('.').split('.'):
d = d.get(part) if isinstance(d, dict) else None
for item in (d or []): print(item)
" "$CONFIG_FILE" "$1"
fi
}
PROJECT="$(cfg .project)"
CSPROJ_REL="$(cfg .csproj)"
BASE_URL="$(cfg .baseUrl)"
BASE_URL="${BASE_URL:-https://dc.mhdf.de}"
BASE_URL="${BASE_URL%/}"
SETUP_REL="$(cfg .setupJson)"
SETUP_REL="${SETUP_REL:-setup.json}"
MAIN_ASSEMBLY="$(cfg .mainAssembly)"
[ -n "$PROJECT" ] || { echo "FEHLER: 'project' fehlt in $CONFIG_FILE" >&2; exit 1; }
[ -n "$CSPROJ_REL" ] || { echo "FEHLER: 'csproj' fehlt in $CONFIG_FILE" >&2; exit 1; }
CSPROJ="$REPO_ROOT/$CSPROJ_REL"
[ -f "$CSPROJ" ] || { echo "FEHLER: Projektdatei nicht gefunden: $CSPROJ" >&2; exit 1; }
mapfile -t RUNTIMES < <(cfg_list .runtimes)
[ "${#RUNTIMES[@]}" -gt 0 ] || { echo "FEHLER: 'runtimes' ist leer." >&2; exit 1; }
SELF_CONTAINED="$(cfg .selfContained)"
SINGLE_FILE="$(cfg .publishSingleFile)"
# -------------------------------------------------------------------- Version
if [ -z "$VERSION" ]; then
# Directory.Build.props zuerst: Steht <Version> nur in einem von mehreren
# Projekten, laufen die Angaben auseinander - und pack-and-deploy bricht
# dann zu Recht mit einem Versionskonflikt ab.
for candidate in "$REPO_ROOT/Directory.Build.props" "$CSPROJ"; do
[ -f "$candidate" ] || continue
VERSION="$(sed -n 's:.*<Version>\s*\([^<]*\)\s*</Version>.*:\1:p' "$candidate" | head -1 | tr -d '[:space:]')"
[ -n "$VERSION" ] && break
done
[ -n "$VERSION" ] || {
echo "FEHLER: Keine <Version> gefunden. Bitte --version angeben." >&2
exit 1
}
echo "Version aus dem Projekt gelesen: $VERSION"
fi
CHANGELOG="${CHANGELOG:-Release v$VERSION}"
# ------------------------------------------------------------------- Werkzeug
TOOL_DIR="$REPO_ROOT/.dc-tools"
PACK="$TOOL_DIR/pack-and-deploy"
fetch_tool() {
[ -x "$PACK" ] && return 0
case "$(uname -m)" in
x86_64|amd64) rid="linux-x64" ;;
aarch64|arm64) rid="linux-arm64" ;;
*) echo "FEHLER: Nicht unterstuetzte Architektur $(uname -m)" >&2; exit 1 ;;
esac
echo "pack-and-deploy wird geholt ($rid) ..."
mkdir -p "$TOOL_DIR"
tmp="$(mktemp)"
curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid" -o "$tmp"
expected="$(curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid.sha256" | tr -d ' \t\r\n')"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$tmp" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$tmp" | cut -d' ' -f1)"
fi
if [ "$actual" != "$expected" ]; then
rm -f "$tmp"
echo "FEHLER: Pruefsumme stimmt nicht." >&2
echo " erwartet: $expected" >&2
echo " erhalten: $actual" >&2
exit 1
fi
chmod +x "$tmp"
mv "$tmp" "$PACK"
echo " Pruefsumme in Ordnung."
}
fetch_tool
# --------------------------------------------------------------------- Ablauf
echo
echo "Projekt : $PROJECT"
echo "Version : $VERSION"
echo "Kanal : $CHANNEL"
echo "Plattform : ${RUNTIMES[*]}"
[ "$DRY_RUN" -eq 1 ] && echo "(Probelauf - es wird nichts hochgeladen)"
echo
FAILED=0
for rid in "${RUNTIMES[@]}"; do
echo "=== $rid ==="
PUBLISH_DIR="$REPO_ROOT/artifacts/publish/$rid"
if [ "$DRY_RUN" -eq 0 ]; then
# Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden
# sonst mit ins Paket wandern.
rm -rf "$PUBLISH_DIR"
publish_args=(publish "$CSPROJ" -c Release -r "$rid" -o "$PUBLISH_DIR" --nologo)
if [ "$SELF_CONTAINED" = "true" ]; then
publish_args+=(--self-contained true)
else
publish_args+=(--self-contained false)
fi
[ "$SINGLE_FILE" = "true" ] && publish_args+=(-p:PublishSingleFile=true)
dotnet "${publish_args[@]}"
# setup.json mitliefern, damit der Installer weiss, was einzurichten ist.
if [ -f "$REPO_ROOT/$SETUP_REL" ]; then
cp "$REPO_ROOT/$SETUP_REL" "$PUBLISH_DIR/setup.json"
echo " setup.json mitgenommen"
fi
pack_args=(
--project "$PROJECT"
--version "$VERSION"
--channel "$CHANNEL"
--platform "$rid"
--publish-dir "$PUBLISH_DIR"
--changelog "$CHANGELOG"
)
[ "$CRITICAL" -eq 1 ] && pack_args+=(--critical)
[ -n "$MAIN_ASSEMBLY" ] && pack_args+=(--main-assembly "$MAIN_ASSEMBLY")
set +e
"$PACK" "${pack_args[@]}"
code=$?
set -e
case "$code" in
0) echo " -> vollstaendig veroeffentlicht" ;;
1)
# Konfigurationsfehler oder Versionskonflikt: die weiteren
# Plattformen wuerden genauso scheitern.
echo "FEHLER: Konfigurations- oder Versionsfehler - Abbruch." >&2
exit 1
;;
2) echo " -> TEILWEISE: Upload oder Registrierung fehlgeschlagen"; FAILED=1 ;;
*) echo " -> unerwarteter Rueckgabewert $code"; FAILED=1 ;;
esac
fi
echo
done
if [ "$FAILED" -ne 0 ]; then
echo "Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen," >&2
echo "ob die bereits hochgeladenen Dateien konsistent sind." >&2
exit 2
fi
echo "Fertig. $PROJECT $VERSION ist im Kanal $CHANNEL verfuegbar."
+11
View File
@@ -30,5 +30,16 @@
"Enabled": true, "Enabled": true,
"IntervalMinutes": 30 "IntervalMinutes": 30
} }
},
"Deploymentcenter": {
"ApiToken": "",
"HeartbeatEnabled": true,
"MonitorSource": "",
"MonitorInstance": "default",
"HeartbeatIntervalSeconds": 60,
"ReportErrors": true,
"CheckUpdatesOnStart": true,
"UpdateChannel": "prod",
"LicenseKey": ""
} }
} }
+27
View File
@@ -0,0 +1,27 @@
{
"schema": 1,
"displayName": "IBKRTrader",
"description": "Modulares Trading-Framework fuer Interactive Brokers. Fuer den Betrieb werden eine MySQL/MariaDB-Datenbank und ein IBKR-Gateway (TWS/IB Gateway) benoetigt.",
"targets": [
{ "id": "app", "file": "settings.json", "location": "install", "format": "json" }
],
"fields": [
{ "key": "Database:Host", "label": "MySQL-Host", "type": "string", "required": true },
{ "key": "Database:Port", "label": "MySQL-Port", "type": "int", "default": "3306", "required": false },
{ "key": "Database:Database", "label": "Datenbankname", "type": "string", "required": true },
{ "key": "Database:User", "label": "MySQL-Benutzer", "type": "string", "required": true },
{ "key": "Database:Password", "label": "MySQL-Passwort", "type": "secret", "required": true },
{ "key": "IBKR:Host", "label": "TWS/Gateway-Adresse", "type": "string", "default": "127.0.0.1", "required": false },
{ "key": "IBKR:Port", "label": "TWS/Gateway-Port (Paper 4002 / Live 4001)", "type": "int", "default": "4002", "required": false },
{ "key": "Trading:ApplicationTimeZoneId", "label": "Betriebszeitzone (IANA, z. B. Europe/Berlin)", "type": "string", "default": "Europe/Berlin", "required": false },
{ "key": "Deploymentcenter:BaseUrl", "label": "Deploymentcenter", "type": "url", "source": "detect:baseurl" },
{ "key": "Deploymentcenter:MonitorSource", "label": "Name im Watchdog-Dashboard", "source": "detect:hostname" },
{ "key": "Deploymentcenter:ApiToken", "label": "Token dieser Installation",
"source": "provision", "scopes": ["watchdog:ping", "bugtracker:report"] }
]
}
+74
View File
@@ -0,0 +1,74 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="IBKRTrader.App.App"
RequestedThemeVariant="Light">
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
<!-- Projektweite Grundgestaltung. Bewusst hier zentral statt je Fenster wiederholt, damit
alle Fenster gleich aussehen und die neuen Module direkt darauf aufsetzen können. -->
<Style Selector="DataGrid">
<Setter Property="GridLinesVisibility" Value="Horizontal" />
<Setter Property="HeadersVisibility" Value="Column" />
<Setter Property="IsReadOnly" Value="True" />
<Setter Property="CanUserResizeColumns" Value="True" />
<Setter Property="CanUserSortColumns" Value="True" />
<Setter Property="RowHeight" Value="24" />
<Setter Property="FontSize" Value="12" />
</Style>
<!-- KPI-Kachel: Ersatz für die zweizeiligen Labels der WinForms-Oberfläche. -->
<Style Selector="Border.kpi">
<Setter Property="Background" Value="#F5F5F5" />
<Setter Property="BorderBrush" Value="#DDDDDD" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="Margin" Value="0,0,8,8" />
<Setter Property="MinWidth" Value="150" />
</Style>
<Style Selector="Border.kpi TextBlock.caption">
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="#666666" />
</Style>
<Style Selector="Border.kpi TextBlock.value">
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- Werkzeugleiste oben in jedem Fenster (Ersatz für ToolStrip). -->
<Style Selector="Border.toolbar">
<Setter Property="Background" Value="#FAFAFA" />
<Setter Property="BorderBrush" Value="#DDDDDD" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Padding" Value="6,4" />
</Style>
<Style Selector="Border.toolbar Button">
<Setter Property="Margin" Value="0,0,6,0" />
<Setter Property="Padding" Value="10,4" />
</Style>
<!-- Statusleiste unten (Ersatz für StatusStrip). -->
<Style Selector="Border.statusbar">
<Setter Property="Background" Value="#FAFAFA" />
<Setter Property="BorderBrush" Value="#DDDDDD" />
<Setter Property="BorderThickness" Value="0,1,0,0" />
<Setter Property="Padding" Value="8,4" />
</Style>
<Style Selector="Border.statusbar TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="#666666" />
</Style>
<!-- Abschnittsüberschrift innerhalb eines Fensters. -->
<Style Selector="TextBlock.section">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,8,0,4" />
</Style>
</Application.Styles>
</Application>
+50
View File
@@ -0,0 +1,50 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using IBKRTrader.App.Shell;
using IBKRTrader.App.Views;
using IBKRTrader.Core.Modularity;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.App;
/// <summary>
/// Avalonia-Anwendungsobjekt. Verbindet den bereits laufenden Generic Host (Trading-Dienste,
/// Module, Persistenz) mit der Oberfläche: beim Start wird der Launcher erzeugt und die Views von
/// Core und Modulen werden bei der Shell registriert.
/// </summary>
public partial class App : Application
{
/// <summary>
/// Wird von <see cref="Program"/> vor <c>StartWithClassicDesktopLifetime</c> gesetzt.
/// Bewusst statisch: Avalonia erzeugt die Application-Instanz selbst, ein Konstruktorparameter
/// ist deshalb nicht möglich.
/// </summary>
public static IServiceProvider? Services { get; set; }
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && Services != null)
{
var uiHost = Services.GetRequiredService<AvaloniaUiHost>();
CoreViews.Register(uiHost, Services);
foreach (var module in Services.GetServices<IModule>())
module.RegisterUi(uiHost, Services);
ModuleViews.Register(uiHost, Services);
ViewIcons.AssignDefaults(uiHost);
var launcher = new LauncherWindow(uiHost, Services);
uiHost.SetMainWindow(launcher);
desktop.MainWindow = launcher;
// Das Schließen des Launchers läuft über die Sicherheitsabfrage (wie bisher das X).
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
}
base.OnFrameworkInitializationCompleted();
}
}
+59
View File
@@ -0,0 +1,59 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Plattformneutrale Oberfläche: dieselbe Anwendung läuft unter Windows und Linux.
WinExe unterdrückt nur das Konsolenfenster unter Windows mit WinForms hat das nichts
zu tun. Der letzte Stand vor der Portierung liegt im Git-Tag winforms-final. -->
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>IBKRTrader.App</AssemblyName>
<RootNamespace>IBKRTrader.App</RootNamespace>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<!-- Layout wird deklarativ in .axaml gebaut, nicht zur Laufzeit im Code. Kompilierte Bindings
verlangen ein x:DataType je Datenkontext dafür fallen Tippfehler in Bindings beim
Kompilieren auf statt erst zur Laufzeit. -->
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- AVLN3001: Die Fenster haben bewusst KEINEN parameterlosen Konstruktor sie bekommen ihre
Dienste per DI. Damit sind sie für den XAML-Previewer nicht ladbar, was wir hinnehmen:
ein parameterloser Konstruktor würde ein Fenster ohne seine Dienste konstruierbar machen
und genau den Fehler verdecken, den die Konstruktionsprüfung finden soll. -->
<NoWarn>$(NoWarn);AVLN3001</NoWarn>
<!-- Wie beim Daemon: ICU ist Pflicht. Ohne sie kippt die feste de-DE-Formatierung des
PDF-Exports auf invariant und Windows-Zeitzonen-IDs lassen sich nicht mehr auflösen. -->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!-- BEWUSST die 11er-Linie, nicht 12.x. LiveCharts2 (2.0.5, aktuellste Fassung) ist gegen
Avalonia 11 gebaut und bricht unter 12: Avalonia.Input.Gestures.PinchEvent gibt es dort
nicht mehr. Diagramme kommen mit den neuen Modulen erst wenn LiveCharts2 Avalonia 12
unterstützt, darf hier angehoben werden. Erfahrung aus PolytraderSharp. -->
<PackageReference Include="Avalonia" Version="11.3.19" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.19" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.19" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.19" />
<!-- DataGrid folgt einer eigenen Versionsreihe und endet in der 11er-Linie bei 11.3.13. -->
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IBKRTrader.Hosting\IBKRTrader.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<!-- 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)" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+81
View File
@@ -0,0 +1,81 @@
using Avalonia;
using IBKRTrader.App.Shell;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace IBKRTrader.App;
/// <summary>
/// Einstiegspunkt der plattformneutralen Oberfläche.
///
/// <para>Bewusst zweigeteilt: <see cref="AppHostBuilder"/> stellt den Host mit Persistenz,
/// Diensten und Modulen zusammen ohne jeden Bezug zur Oberfläche. Erst danach wird Avalonia
/// daran gehängt. Derselbe Host trägt den kopflosen Linux-Dienst
/// (<c>IBKRTrader.Daemon</c>).</para>
/// </summary>
internal static class Program
{
[STAThread]
public static int Main(string[] args)
{
// Konstruktionsprüfung aller Fenster ohne Message-Loop und ohne laufende Dienste.
if (HasFlag(args, "--smoke-ui")) return SmokeUi.Run();
var modules = AppHostBuilder.CreateModules();
using var host = AppHostBuilder.Build(modules, ShellServices.Register);
AppHostBuilder.RunStartupChecks(host.Services);
var logger = host.Services.GetRequiredService<LoggingService>();
logger.Info("Core", "=== IBKRTrader startet ===");
logger.Info("Core", $"Version: {IBKRTrader.Hosting.BuildInfo.Summary} | .NET {Environment.Version}");
host.Start();
StartModules(modules, logger);
try
{
App.Services = host.Services;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return 0;
}
finally
{
// Geordnetes Herunterfahren erst nachdem die Oberfläche beendet ist.
StopModules(modules, logger);
host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
}
}
/// <summary>Von Avalonia erwartete Fabrikmethode (auch vom XAML-Previewer genutzt).</summary>
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
private static void StartModules(IReadOnlyList<IModule> modules, LoggingService logger)
{
foreach (var module in modules)
{
try { module.StartAsync(default).GetAwaiter().GetResult(); }
catch (Exception ex) { logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
}
logger.Info("Core", "IBKRTrader bereit.");
}
private static void StopModules(IReadOnlyList<IModule> modules, LoggingService logger)
{
foreach (var module in modules)
{
try { module.StopAsync(default).GetAwaiter().GetResult(); }
catch (Exception ex) { logger.Warn(module.Name, $"{module.Name}: Stopp fehlgeschlagen: {ex.Message}"); }
}
}
private static bool HasFlag(string[] args, string flag) =>
args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
}
+120
View File
@@ -0,0 +1,120 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using IBKRTrader.App.Views;
using IBKRTrader.Core.Modularity;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Avalonia-Umsetzung von <see cref="IModuleUiHost"/>. Verhält sich wie der frühere
/// WinForms-<c>ShellUiHost</c>: je View höchstens ein Fenster, ein bereits offenes wird nach vorn
/// geholt, der Offen-Status wird gemeldet (für die Markierung im Launcher).
///
/// <para>Der Core-Contract ist toolkit-neutral <see cref="ModuleView.CreateView"/> liefert
/// <see cref="object"/>. Hier wird auf <see cref="Window"/> gecastet: ein anderer Typ ist ein
/// Programmierfehler und soll laut scheitern, nicht still ein leeres Fenster ergeben.</para>
/// </summary>
public sealed class AvaloniaUiHost : IModuleUiHost
{
private readonly List<ModuleView> _views = [];
private readonly Dictionary<string, Window> _open = new(StringComparer.OrdinalIgnoreCase);
private Window? _mainWindow;
private bool _shutdownDialogOpen;
/// <summary>True, sobald das Herunterfahren über die Sicherheitsabfrage bestätigt wurde.</summary>
public bool ShutdownConfirmed { get; private set; }
public event Action? OpenStateChanged;
public IReadOnlyList<ModuleView> Views => _views;
public void RegisterView(ModuleView view) => _views.Add(view);
/// <summary>Setzt das Hauptfenster (Launcher) Ziel für <see cref="ActivateMain"/>.</summary>
public void SetMainWindow(Window main) => _mainWindow = main;
public bool IsOpen(string viewId) => _open.ContainsKey(viewId);
public void ActivateMain()
{
if (_mainWindow is null) return;
if (_mainWindow.WindowState == WindowState.Minimized)
_mainWindow.WindowState = WindowState.Normal;
_mainWindow.Activate();
}
public void OpenView(string viewId)
{
var view = _views.FirstOrDefault(v => v.Id == viewId);
if (view is not null) OpenView(view);
}
private void OpenView(ModuleView view)
{
if (_open.TryGetValue(view.Id, out var existing))
{
if (existing.WindowState == WindowState.Minimized)
existing.WindowState = WindowState.Normal;
existing.Activate();
return;
}
var window = (Window)view.CreateView();
if (string.IsNullOrEmpty(window.Title)) window.Title = view.Title;
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
_open[view.Id] = window;
window.Closed += (_, _) =>
{
_open.Remove(view.Id);
OpenStateChanged?.Invoke();
};
window.Show();
OpenStateChanged?.Invoke();
}
/// <summary>
/// Zeigt die Sicherheitsabfrage und fährt bei Bestätigung herunter. Aus jedem Fenster
/// aufrufbar auch aus Modul-Fenstern, die nur den Core-Contract kennen.
///
/// <para><c>async void</c> ist hier korrekt: die Methode ist ein Ereignis-Handler hinter dem
/// synchronen Contract <see cref="IModuleUiHost.RequestShutdown"/>, und der Dialog muss
/// erwartet werden. Ausnahmen können nicht entweichen der Dialog wirft nicht, und der
/// <c>finally</c>-Block gibt die Sperre in jedem Fall frei.</para>
/// </summary>
public async void RequestShutdown()
{
if (ShutdownConfirmed || _shutdownDialogOpen) return;
_shutdownDialogOpen = true;
try
{
var owner = _mainWindow;
if (owner is null) return;
var confirmed = await new ShutdownConfirmWindow().ShowDialog<bool>(owner);
if (!confirmed) return;
ShutdownConfirmed = true;
CloseAllViews();
if (Application.Current?.ApplicationLifetime
is IClassicDesktopStyleApplicationLifetime desktop)
desktop.Shutdown();
}
finally
{
_shutdownDialogOpen = false;
}
}
/// <summary>Schließt alle offenen View-Fenster (beim Herunterfahren).</summary>
public void CloseAllViews()
{
foreach (var window in _open.Values.ToList())
window.Close();
_open.Clear();
}
}
+47
View File
@@ -0,0 +1,47 @@
using IBKRTrader.App.Views;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.App.Shell;
/// <summary>Registriert die Core-Ansichten (Dashboard, Workers, Logs, Settings) bei der Shell.</summary>
public static class CoreViews
{
public static void Register(IModuleUiHost host, IServiceProvider sp)
{
host.RegisterView(new ModuleView
{
Id = "core.dashboard", Title = "Dashboard", Group = "Core", Order = 5,
CreateView = () => new DashboardWindow(
host,
sp.GetRequiredService<DashboardService>(),
sp.GetRequiredService<SettingsService>(),
sp.GetServices<IModule>(),
sp.GetRequiredService<IConfiguration>(),
sp.GetServices<IWorker>())
});
host.RegisterView(new ModuleView
{
Id = "core.workers", Title = "Workers / Services", Group = "Core", Order = 10,
CreateView = () => new WorkersWindow(host, sp.GetRequiredService<WorkerEngine>())
});
host.RegisterView(new ModuleView
{
Id = "core.logs", Title = "Logs", Group = "Core", Order = 20,
CreateView = () => new LogsWindow(host, sp.GetRequiredService<LoggingService>())
});
host.RegisterView(new ModuleView
{
Id = "core.settings", Title = "Settings", Group = "Core", Order = 30,
CreateView = () => new SettingsWindow(host, sp.GetRequiredService<SettingsService>())
});
}
}
+72
View File
@@ -0,0 +1,72 @@
using IBKRTrader.App.Views.Modules;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Registriert die Fenster der Module bei der Shell.
///
/// <para><b>Warum hier und nicht im Modul?</b> Ein Modul, das sein eigenes Fenster erzeugt, müsste
/// Avalonia referenzieren und wäre damit nicht mehr kopflos auf Linux lauffähig. Die
/// Modulprojekte bleiben deshalb frei von UI-Code; ihr <c>RegisterUi</c> ist leer, und die Shell
/// verdrahtet die Fenster zentral. Die Modul-Dienste kommen unverändert aus dem DI-Container.</para>
///
/// <para>Registriert wird nur, was auch geladen ist: fehlt ein Modul in dieser Sitzung, entfällt
/// sein Fenster, und der Launcher zeigt es gar nicht erst an.</para>
/// </summary>
public static class ModuleViews
{
public static void Register(IModuleUiHost host, IServiceProvider sp)
{
RegisterIfLoaded(sp, "CongressTrading", () => host.RegisterView(new ModuleView
{
Id = "congresstrading.main", Title = "Congress Trading", Group = "CongressTrading", Order = 100,
CreateView = () => new CongressTradingWindow(
host,
sp.GetRequiredService<CongressRepository>(),
sp.GetRequiredService<WorkerEngine>(),
sp.GetRequiredService<IPortfolioService>(),
sp.GetRequiredService<LoggingService>())
}));
RegisterIfLoaded(sp, "Supervisor", () => host.RegisterView(new ModuleView
{
Id = "supervisor.main", Title = "Supervisor", Group = "Supervisor", Order = 300,
CreateView = () => new SupervisorWindow(
host,
sp.GetRequiredService<SupervisorAgent>(),
sp.GetRequiredService<DossierService>(),
sp.GetRequiredService<ISupervisorReportRepository>(),
sp.GetRequiredService<LoggingService>())
}));
RegisterIfLoaded(sp, "Accounting", () => host.RegisterView(new ModuleView
{
Id = "accounting.main", Title = "Accounting", Group = "Accounting", Order = 400,
CreateView = () => new AccountingWindow(
host,
sp.GetRequiredService<ILedgerRepository>(),
sp.GetRequiredService<IIngestRunRepository>(),
sp.GetRequiredService<AccountingReportService>(),
sp.GetRequiredService<AccountingIngestService>(),
sp.GetRequiredService<LoggingService>())
}));
}
/// <summary>Registriert die Ansicht nur, wenn das Modul in dieser Sitzung geladen ist.</summary>
private static void RegisterIfLoaded(IServiceProvider sp, string moduleName, Action register)
{
if (sp.GetServices<IModule>().Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase)))
register();
}
}
+18
View File
@@ -0,0 +1,18 @@
using IBKRTrader.Core.Modularity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Die Dienste, die es nur mit Oberfläche gibt. Alles andere kommt aus
/// <c>AppHostBuilder</c> und ist mit dem kopflosen Daemon geteilt.
/// </summary>
public static class ShellServices
{
public static void Register(IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<AvaloniaUiHost>();
services.AddSingleton<IModuleUiHost>(sp => sp.GetRequiredService<AvaloniaUiHost>());
}
}
+95
View File
@@ -0,0 +1,95 @@
using IBKRTrader.App.ViewModels;
using IBKRTrader.App.Views;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings;
using IBKRTrader.Hosting;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Konstruktionsprüfung aller Fenster ohne Message-Loop und ohne laufende Dienste.
///
/// <para>Nachfolger des <c>--smoke-ui</c>-Laufs der WinForms-Shell, der Konstruktionsfehler
/// zuverlässig gefangen hat. Der Host wird bewusst <b>nicht</b> gestartet: sonst liefen Worker,
/// Broker-Verbindungen und Marktdaten-Abrufe gegen die echten Endpunkte an für eine reine
/// Konstruktionsprüfung unerwünscht, auf einem Build-Server schlicht falsch.</para>
///
/// <para>Im Gegensatz zu WinForms braucht Avalonia dafür kein Anzeigegerät:
/// <c>SetupWithoutStarting</c> initialisiert das Framework, ohne ein Fenster zu zeigen. Damit ist
/// diese Prüfung erstmals CI-tauglich.</para>
/// </summary>
public static class SmokeUi
{
public static int Run()
{
Program.BuildAvaloniaApp().SetupWithoutStarting();
var modules = AppHostBuilder.CreateModules();
using var host = AppHostBuilder.Build(modules, ShellServices.Register);
App.Services = host.Services;
var uiHost = host.Services.GetRequiredService<AvaloniaUiHost>();
CoreViews.Register(uiHost, host.Services);
foreach (var module in modules)
module.RegisterUi(uiHost, host.Services);
ModuleViews.Register(uiHost, host.Services);
ViewIcons.AssignDefaults(uiHost);
var failures = 0;
Console.WriteLine("=== Smoke-UI: Fenster-Konstruktion (Avalonia) ===");
foreach (var view in uiHost.Views)
failures += Check(view.Id, view.Title, () => view.CreateView());
failures += Check("shell.launcher", "Launcher", () => new LauncherWindow(uiHost, host.Services));
failures += Check("shell.shutdown", "Beenden-Abfrage", () => new ShutdownConfirmWindow());
// Die Einstellungsmaske entsteht aus den Attributen von AppSettings. Ein Fenster kann
// fehlerfrei konstruieren und trotzdem leer sein, wenn die Attribute verlorengehen
// deshalb hier gegen die tatsächliche Feldzahl prüfen.
failures += CheckSettingsForm(host.Services.GetRequiredService<SettingsService>());
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
return failures == 0 ? 0 : 1;
}
private static int Check(string id, string title, Func<object> construct)
{
try
{
_ = construct();
Console.WriteLine($"[OK] {id} ({title})");
return 0;
}
catch (Exception ex)
{
Console.WriteLine($"[FEHLER] {id}: {ex.GetType().Name}: {ex.Message}");
return 1;
}
}
private static int CheckSettingsForm(SettingsService settings)
{
try
{
var sections = SettingsModelBuilder.Build(settings.Settings);
var fieldCount = sections.Sum(s => s.Fields.Count);
if (sections.Count == 0 || fieldCount == 0)
{
Console.WriteLine("[FEHLER] Einstellungsmaske: keine Felder aus AppSettings ermittelt " +
"(Category-/DisplayName-Attribute verloren?).");
return 1;
}
Console.WriteLine($"[OK] Einstellungsmaske: {sections.Count} Abschnitte, {fieldCount} Felder");
return 0;
}
catch (Exception ex)
{
Console.WriteLine($"[FEHLER] Einstellungsmaske: {ex.GetType().Name}: {ex.Message}");
return 1;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
using System.Collections.Concurrent;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using IBKRTrader.Core.Modularity;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Löst die toolkit-neutralen <see cref="ModuleView.IconKey"/>-Schlüssel gegen die
/// Avalonia-Bildressourcen auf. Gegenstück zum gleichnamigen Helfer der WinForms-Shell
/// <b>dieselben Schlüssel, dieselben PNG-Dateien</b>, sodass Core und Module unverändert bleiben.
/// </summary>
public static class ViewIcons
{
/// <summary>Symbol-Schlüssel → Dateiname unter <c>Resources/</c> (als Avalonia-Asset eingebettet).</summary>
private static readonly Dictionary<string, string> FileByKey = new(StringComparer.OrdinalIgnoreCase)
{
["dashboard"] = "dashboard.png",
["workers"] = "system_time.png",
["logs"] = "error_log.png",
["settings"] = "setting_tools.png",
["congresstrading"] = "cross_reference.png",
["accounting"] = "coins_in_hand.png",
["supervisor"] = "token_quantifier.png",
};
/// <summary>Standard-Symbolschlüssel je View-ID identisch zur WinForms-Shell.</summary>
private static readonly Dictionary<string, string> DefaultKeyByViewId = new(StringComparer.OrdinalIgnoreCase)
{
["core.dashboard"] = "dashboard",
["core.workers"] = "workers",
["core.logs"] = "logs",
["core.settings"] = "settings",
["congresstrading.main"] = "congresstrading",
["accounting.main"] = "accounting",
["supervisor.main"] = "supervisor",
};
private static readonly ConcurrentDictionary<string, Bitmap?> Cache = new();
/// <summary>Bild zum Schlüssel, oder <c>null</c> (kein Symbol / Datei fehlt).</summary>
public static Bitmap? Resolve(string? iconKey)
{
if (iconKey is null || !FileByKey.TryGetValue(iconKey, out var file)) return null;
return Cache.GetOrAdd(iconKey, _ =>
{
try
{
using var stream = AssetLoader.Open(
new Uri($"avares://IBKRTrader.App/Assets/{file}"));
return new Bitmap(stream);
}
catch
{
// Ein fehlendes Symbol darf die Oberfläche nie aufhalten dann eben nur Text.
return null;
}
});
}
/// <summary>Setzt bei allen registrierten Views den Standard-Schlüssel, falls noch keiner gesetzt ist.</summary>
public static void AssignDefaults(IModuleUiHost host)
{
foreach (var view in host.Views)
if (view.IconKey is null && DefaultKeyByViewId.TryGetValue(view.Id, out var key))
view.IconKey = key;
}
}
+92
View File
@@ -0,0 +1,92 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using IBKRTrader.Core.Modularity;
namespace IBKRTrader.App.Shell;
/// <summary>
/// Baut das gemeinsame Fenster-Menü, das auf JEDEM Fenster erscheint und das Wechseln zwischen
/// allen Fenstern (Launcher + Core + Module) erlaubt. Es nutzt nur den Core-Contract
/// <see cref="IModuleUiHost"/> und funktioniert deshalb auch aus Modul-Fenstern.
///
/// <para>Gegenstück zum gleichnamigen WinForms-Helfer gleiches Verhalten, anderes Toolkit.</para>
/// </summary>
public static class WindowMenu
{
/// <summary>
/// Verdrahtet ein <see cref="Menu"/> mit der Fensterliste: füllt es sofort und baut es bei
/// jeder Änderung des Offen-Status neu auf. Die Registrierung wird beim Entladen gelöst.
/// </summary>
/// <param name="currentViewId">ID der eigenen View, oder <c>null</c> im Launcher.</param>
public static void Wire(Menu menu, IModuleUiHost host, string? currentViewId)
{
void Refresh()
{
// Der Offen-Status kann aus einem beliebigen Fenster gemeldet werden der Aufbau
// der Menüleiste gehört aber auf den UI-Thread.
if (Dispatcher.UIThread.CheckAccess()) Populate(menu, host, currentViewId);
else Dispatcher.UIThread.Post(() => Populate(menu, host, currentViewId));
}
Populate(menu, host, currentViewId);
host.OpenStateChanged += Refresh;
menu.DetachedFromVisualTree += (_, _) => host.OpenStateChanged -= Refresh;
}
/// <summary>Baut die Menüleiste komplett neu auf.</summary>
public static void Populate(Menu menu, IModuleUiHost host, string? currentViewId)
{
var items = new List<Control>
{
BuildItem("Launcher", null, isCurrent: currentViewId is null,
isOpen: false, onClick: host.ActivateMain)
};
foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
var id = view.Id;
items.Add(BuildItem(view.Title, view.IconKey,
isCurrent: id == currentViewId,
isOpen: host.IsOpen(id),
onClick: () => host.OpenView(id)));
}
// Kontextabhängige rechte Aktion: nur der Launcher darf die Anwendung beenden; jedes
// andere Fenster bietet nur „Fenster schließen" (Module laufen weiter).
if (currentViewId is null)
{
items.Add(BuildItem("Beenden", null, false, false, host.RequestShutdown, alignRight: true));
}
else
{
items.Add(BuildItem("Fenster schließen", null, false, false,
() => (menu.GetVisualRoot() as Window)?.Close(), alignRight: true));
}
menu.ItemsSource = items;
}
private static MenuItem BuildItem(string title, string? iconKey, bool isCurrent, bool isOpen,
Action onClick, bool alignRight = false)
{
var item = new MenuItem
{
Header = title,
FontWeight = isCurrent ? FontWeight.Bold : FontWeight.Normal,
// Offene Fenster werden hervorgehoben Ersatz für das Häkchen der WinForms-Leiste.
Foreground = isOpen && !isCurrent ? Brushes.SteelBlue : null,
};
if (alignRight) item.HorizontalAlignment = HorizontalAlignment.Right;
var icon = ViewIcons.Resolve(iconKey);
if (icon is not null)
item.Icon = new Image { Source = icon, Width = 16, Height = 16 };
item.Click += (_, _) => onClick();
return item;
}
}
+47
View File
@@ -0,0 +1,47 @@
using Avalonia.Media;
namespace IBKRTrader.App.ViewModels;
/// <summary>
/// Zeilentypen für die DataGrids der Core-Ansichten.
///
/// <para>Bewusst benannte Records statt der anonymen Typen, die die WinForms-Fassung an
/// <c>DataGridView.DataSource</c> gehängt hat: Avalonias <c>DataGrid</c> bindet über
/// kompilierte Bindings gegen einen bekannten Typ. Anonyme Typen sind <c>internal</c> und
/// funktionieren dort nur über Reflexion mit benannten Records bleibt die Spaltendefinition
/// im XAML prüfbar.</para>
/// </summary>
public sealed record ModuleRow(string Name, string Prefix, string Status);
/// <summary>
/// Eine Zeile im Live-Log. Die Farbe hängt am Eintrag statt an einer Selektion WinForms färbte
/// über <c>SelectionColor</c> der RichTextBox ein, in Avalonia wird je Element gebunden.
/// </summary>
public sealed record LogRow(string Text, IBrush Color);
/// <summary>Eine offene Position im Modul-Fenster.</summary>
public sealed record PositionRow(string Symbol, int Quantity, decimal AvgPrice, decimal Notional);
/// <summary>Eine Zeile der Supervisor-Berichtsliste. Tokens als fertiger Text (Prompt / Completion).</summary>
public sealed record SupervisorReportRow(
DateTime CreatedAt, string Profile, string Model, string Question, int ToolCallCount, string Tokens);
// ─── Accounting ──────────────────────────────────────────────────────────────
// Beträge als bereits formatierter Text: die Umrechnung in die Anzeigewährung passiert beim
// Laden, und das Format ist damit an einer Stelle festgelegt statt in jeder Spaltendefinition.
/// <summary>Eine Monatszeile des Monatsvergleichs (BWA).</summary>
public sealed record MonthlyRow(
string Month, string Opening, string Deposits, string Withdrawals,
string Volume, string Fees, string Result, string Closing);
/// <summary>Eine Buchung im neutralen Ledger.</summary>
public sealed record LedgerRow(
DateTime Time, string AccountId, string EventType, string Side, string Symbol,
string Currency, decimal Quantity, decimal Price,
decimal Gross, decimal Fee, decimal Net, string TransactionId);
/// <summary>Ein Ingest-Lauf des Flex-Query-Abrufs.</summary>
public sealed record IngestRunRow(
string AccountId, DateTime Started, DateTime? Finished, bool Backfill,
int NewEntries, int DuplicateEntries, bool Success, string Delta, string Message);
@@ -0,0 +1,87 @@
using System.ComponentModel;
using System.Reflection;
namespace IBKRTrader.App.ViewModels;
/// <summary>Ein bearbeitbares Einzelfeld der Einstellungen.</summary>
public sealed class SettingsField
{
public required string DisplayName { get; init; }
public required string Description { get; init; }
public required Type ValueType { get; init; }
public required bool IsPassword { get; init; }
public required Func<object?> Get { get; init; }
public required Action<object?> Set { get; init; }
}
/// <summary>Ein Abschnitt (entspricht einem aufklappbaren Knoten des früheren PropertyGrid).</summary>
public sealed record SettingsSection(string Title, IReadOnlyList<SettingsField> Fields);
/// <summary>
/// Baut die Eingabemaske der Einstellungen aus den Attributen von <c>AppSettings</c>.
///
/// <para><b>Warum aus Attributen:</b> Die WinForms-Fassung zeigte <c>AppSettings</c> in einem
/// <c>PropertyGrid</c>. Avalonia hat dafür kein Gegenstück. Die Klassen tragen bereits
/// <see cref="CategoryAttribute"/>, <see cref="DisplayNameAttribute"/> und
/// <see cref="DescriptionAttribute"/> daraus lässt sich die Maske erzeugen, statt sie von Hand
/// zu pflegen. Eine neue Einstellung erscheint damit automatisch, ohne dass jemand die Oberfläche
/// anfasst; genau das war der Vorteil des PropertyGrid, und er bleibt erhalten.</para>
/// </summary>
public static class SettingsModelBuilder
{
/// <summary>Typen, die als Eingabefeld dargestellt werden. Alles andere gilt als Unterabschnitt.</summary>
private static bool IsLeaf(Type t) =>
t == typeof(string) || t.IsEnum ||
t == typeof(int) || t == typeof(long) || t == typeof(double) ||
t == typeof(decimal) || t == typeof(bool);
/// <summary>Zerlegt das Einstellungsobjekt in Abschnitte mit Feldern.</summary>
public static IReadOnlyList<SettingsSection> Build(object root)
{
var sections = new List<SettingsSection>();
Walk(root, prefix: null, sections);
return sections;
}
private static void Walk(object owner, string? prefix, List<SettingsSection> sections)
{
var fields = new List<SettingsField>();
foreach (var prop in owner.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (!prop.CanRead || prop.GetIndexParameters().Length > 0) continue;
var title = prop.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? prop.Name;
if (IsLeaf(prop.PropertyType))
{
if (!prop.CanWrite) continue; // z. B. berechnete Eigenschaften
var target = owner; // für den Abschluss festhalten
fields.Add(new SettingsField
{
DisplayName = title,
Description = prop.GetCustomAttribute<DescriptionAttribute>()?.Description ?? "",
ValueType = prop.PropertyType,
IsPassword = prop.GetCustomAttribute<PasswordPropertyTextAttribute>()?.Password == true,
Get = () => prop.GetValue(target),
Set = v => prop.SetValue(target, v)
});
continue;
}
// Verschachteltes Einstellungsobjekt → eigener Abschnitt. Nur eigene Typen verfolgen,
// damit die Rekursion nicht in Framework-Typen abbiegt.
if (prop.PropertyType.IsClass && prop.PropertyType.Namespace?.StartsWith("IBKRTrader") == true)
{
var child = prop.GetValue(owner);
if (child is not null)
Walk(child, prefix is null ? title : $"{prefix} · {title}", sections);
}
}
if (fields.Count > 0)
sections.Add(new SettingsSection(prefix ?? "Allgemein", fields));
}
}
@@ -0,0 +1,44 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dg="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.DataGrid"
xmlns:vm="clr-namespace:IBKRTrader.App.ViewModels"
x:Class="IBKRTrader.App.Views.DashboardWindow"
Title="Dashboard"
Width="960" Height="600"
MinWidth="640" MinHeight="420"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal">
<Button x:Name="RefreshButton" Content="Aktualisieren" />
</StackPanel>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock x:Name="StatusText" />
</Border>
<Grid Margin="12" RowDefinitions="Auto,Auto,Auto,*">
<TextBlock Grid.Row="0" x:Name="ModeText"
FontSize="16" FontWeight="SemiBold" Margin="0,0,0,10" />
<!-- Kennzahlen als Kacheln statt einer langen Label-Zeile. -->
<WrapPanel Grid.Row="1" x:Name="KpiPanel" />
<TextBlock Grid.Row="2" Classes="section" Text="Geladene Module" />
<dg:DataGrid Grid.Row="3" x:Name="ModulesGrid" AutoGenerateColumns="False"
x:DataType="vm:ModuleRow">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Modul" Binding="{Binding Name}" Width="200" />
<dg:DataGridTextColumn Header="Präfix" Binding="{Binding Prefix}" Width="100" />
<dg:DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="*" />
</dg:DataGrid.Columns>
</dg:DataGrid>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,97 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using IBKRTrader.App.Shell;
using IBKRTrader.App.ViewModels;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Time;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using Microsoft.Extensions.Configuration;
namespace IBKRTrader.App.Views;
/// <summary>
/// Core-Ansicht: Gesamtüberblick (Handelsmodus, aggregierte Kennzahlen, geladene Module).
/// DB-Zugriffe laufen NUR auf Anzeige und Nutzerinteraktion nie im Konstruktor, damit die
/// Konstruktionsprüfung (<c>--smoke-ui</c>) auch ohne Datenbank fehlerfrei durchläuft.
/// </summary>
public partial class DashboardWindow : Window
{
private readonly DashboardService _dashboard;
private readonly SettingsService _settings;
private readonly IReadOnlyList<IModule> _modules;
private readonly IConfiguration _config;
private readonly int _workerCount;
public DashboardWindow(IModuleUiHost uiHost,
DashboardService dashboard,
SettingsService settings,
IEnumerable<IModule> modules,
IConfiguration config,
IEnumerable<IWorker> workers)
{
_dashboard = dashboard;
_settings = settings;
_modules = modules.ToList();
_config = config;
_workerCount = workers.Count();
InitializeComponent();
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.dashboard");
this.FindControl<Button>("RefreshButton")!.Click += async (_, _) => await RefreshAsync();
Opened += async (_, _) => await RefreshAsync();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
private async Task RefreshAsync()
{
var t = _settings.Settings.Trading;
var modeText = this.FindControl<TextBlock>("ModeText")!;
modeText.Text = $"Trading: {t.Mode} {(t.TradingEnabled ? "AKTIV" : "inaktiv")}";
modeText.Foreground = t.TradingEnabled ? Brushes.SeaGreen : Brushes.Gray;
this.FindControl<DataGrid>("ModulesGrid")!.ItemsSource = _modules
.Select(m => new ModuleRow(m.Name, m.DbPrefix, m.GetActivationBlocker(_config) ?? "aktivierbar"))
.ToList();
var status = this.FindControl<TextBlock>("StatusText")!;
try
{
var snap = await _dashboard.GetSnapshotAsync();
ShowKpis(
("Offene Positionen", snap.OpenPositions.ToString()),
("Exposure", snap.TotalExposure.ToString("N2")),
("Trades gesamt", snap.TotalTrades.ToString()),
("Worker / Services", _workerCount.ToString()));
status.Text = $"Aktualisiert: {AppTimeZone.Now:HH:mm:ss}";
}
catch (Exception ex)
{
ShowKpis(
("Offene Positionen", "n/v"),
("Exposure", "n/v"),
("Trades gesamt", "n/v"),
("Worker / Services", _workerCount.ToString()));
status.Text = $"DB nicht erreichbar: {ex.Message}";
}
}
/// <summary>Baut die Kennzahlen-Kacheln neu. Gestaltung kommt aus der Klasse "kpi" in App.axaml.</summary>
private void ShowKpis(params (string Caption, string Value)[] kpis)
{
var panel = this.FindControl<WrapPanel>("KpiPanel")!;
panel.Children.Clear();
foreach (var (caption, value) in kpis)
{
var stack = new StackPanel();
stack.Children.Add(new TextBlock { Text = caption, Classes = { "caption" } });
stack.Children.Add(new TextBlock { Text = value, Classes = { "value" } });
panel.Children.Add(new Border { Classes = { "kpi" }, Child = stack });
}
}
}
@@ -0,0 +1,44 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="IBKRTrader.App.Views.LauncherWindow"
Title="IBKRTrader — Launcher"
Width="820" Height="560"
MinWidth="560" MinHeight="360"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
<!-- Fensterleiste: je registrierter View eine Schaltfläche, Symbol über Text. -->
<Border Classes="toolbar" DockPanel.Dock="Top">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl x:Name="ViewButtons">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ScrollViewer>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock x:Name="StatusText" Text="Start..." />
</Border>
<Border Background="White">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="6">
<TextBlock Text="IBKRTrader"
FontSize="22" FontWeight="SemiBold"
HorizontalAlignment="Center" />
<TextBlock Text="Fenster über die Leiste oben öffnen."
Foreground="#666666"
HorizontalAlignment="Center" />
<TextBlock x:Name="EnvironmentText"
Foreground="#999999" FontSize="11"
Margin="0,12,0,0"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
</DockPanel>
</Window>
@@ -0,0 +1,108 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;
using IBKRTrader.App.Shell;
using IBKRTrader.Core.Time;
namespace IBKRTrader.App.Views;
/// <summary>
/// Launcher das Basisfenster der Shell. Zeigt je registrierter View eine Schaltfläche und trägt
/// das gemeinsame Fenster-Menü. Die inhaltlichen Ansichten sind eigenständige Fenster.
///
/// <para>Die Dienste laufen bereits, wenn dieses Fenster erscheint: der Host wird in
/// <c>Program.Main</c> vor Avalonia gestartet. Der Launcher startet nichts, er zeigt nur an.</para>
/// </summary>
public partial class LauncherWindow : Window
{
private readonly AvaloniaUiHost _uiHost;
private readonly Dictionary<string, Button> _viewButtons = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Parameterloser Konstruktor nur für den XAML-Previewer.</summary>
public LauncherWindow() : this(new AvaloniaUiHost(), null) { }
public LauncherWindow(AvaloniaUiHost uiHost, IServiceProvider? services)
{
_uiHost = uiHost;
InitializeComponent();
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, _uiHost, currentViewId: null);
BuildViewButtons();
this.FindControl<TextBlock>("EnvironmentText")!.Text =
$"{Environment.OSVersion.Platform} · .NET {Environment.Version} · Zeitzone {AppTimeZone.CurrentId}";
_uiHost.OpenStateChanged += UpdateButtonStates;
Closed += (_, _) => _uiHost.OpenStateChanged -= UpdateButtonStates;
SetStatus(services is null ? "Vorschau" : "Bereit");
UpdateButtonStates();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
protected override void OnClosing(WindowClosingEventArgs e)
{
// Auch das Schließen-X läuft über die Sicherheitsabfrage.
if (!_uiHost.ShutdownConfirmed)
{
e.Cancel = true;
_uiHost.RequestShutdown();
return;
}
base.OnClosing(e);
}
private void BuildViewButtons()
{
var buttons = new List<Control>();
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
var id = view.Id;
var content = new StackPanel { Spacing = 2, HorizontalAlignment = HorizontalAlignment.Center };
var icon = ViewIcons.Resolve(view.IconKey);
if (icon is not null)
content.Children.Add(new Image
{
Source = icon, Width = 32, Height = 32,
HorizontalAlignment = HorizontalAlignment.Center
});
content.Children.Add(new TextBlock
{
Text = view.Title, FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center
});
var button = new Button { Content = content, Padding = new Thickness(10, 6) };
button.Click += (_, _) => _uiHost.OpenView(id);
_viewButtons[id] = button;
buttons.Add(button);
}
this.FindControl<ItemsControl>("ViewButtons")!.ItemsSource = buttons;
}
/// <summary>Hebt die Schaltflächen der bereits offenen Fenster hervor.</summary>
private void UpdateButtonStates()
{
if (!Dispatcher.UIThread.CheckAccess())
{
Dispatcher.UIThread.Post(UpdateButtonStates);
return;
}
foreach (var (id, button) in _viewButtons)
button.BorderBrush = _uiHost.IsOpen(id) ? Brushes.SteelBlue : null;
}
private void SetStatus(string text) =>
this.FindControl<TextBlock>("StatusText")!.Text =
$"Status: {text} | {AppTimeZone.Now:HH:mm:ss}";
}
+45
View File
@@ -0,0 +1,45 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:IBKRTrader.App.ViewModels"
x:Class="IBKRTrader.App.Views.LogsWindow"
Title="Logs"
Width="1000" Height="650"
MinWidth="640" MinHeight="360"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="ClearButton" Content="Leeren" />
<Button x:Name="CopyButton" Content="Alles kopieren" />
<CheckBox x:Name="AutoScrollCheck" Content="Automatisch scrollen"
IsChecked="True" Margin="12,0,0,0" VerticalAlignment="Center" />
<TextBlock Text="Filter:" Margin="16,0,6,0" VerticalAlignment="Center" />
<TextBox x:Name="FilterBox" Width="220" Watermark="Text oder Modul" />
</StackPanel>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock x:Name="StatusText" />
</Border>
<!-- Dunkles Terminal-Erscheinungsbild wie in der bisherigen RichTextBox. Statt der
Selection-Einfärbung von WinForms wird hier je Eintrag ein eingefärbtes Element
erzeugt das ist der Weg, den Avalonia dafür vorsieht. -->
<ScrollViewer x:Name="LogScroller" Background="#14141E">
<ItemsControl x:Name="LogList" Margin="8">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LogRow">
<TextBlock Text="{Binding Text}"
Foreground="{Binding Color}"
FontFamily="Consolas,Menlo,DejaVu Sans Mono,monospace"
FontSize="12"
TextWrapping="NoWrap" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,106 @@
using System.Collections.ObjectModel;
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;
using IBKRTrader.App.Shell;
using IBKRTrader.App.ViewModels;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Time;
namespace IBKRTrader.App.Views;
/// <summary>
/// Core-Ansicht: Live-Log, an das <see cref="LoggingService.EntryWritten"/>-Ereignis gebunden.
///
/// <para>Einfärbung und der Wechsel auf den UI-Thread liegen hier, nicht mehr im Logging-Dienst
/// der Core trägt seit der Portierung keine UI-Abhängigkeit. Der Dienst schreibt aus beliebigen
/// Worker-Threads, deshalb geht jeder Eintrag über den Dispatcher.</para>
/// </summary>
public partial class LogsWindow : Window
{
/// <summary>
/// Obergrenze der angezeigten Zeilen. Ohne sie wüchse die Liste im Dauerbetrieb unbegrenzt
/// die vollständige Historie steht ohnehin in den Logdateien.
/// </summary>
private const int MaxLines = 5000;
private static readonly IBrush ColorInfo = new SolidColorBrush(Color.FromRgb(150, 210, 150));
private static readonly IBrush ColorWarn = new SolidColorBrush(Color.FromRgb(255, 190, 60));
private static readonly IBrush ColorError = new SolidColorBrush(Color.FromRgb(255, 80, 80));
private readonly LoggingService _logger;
private readonly ObservableCollection<LogRow> _rows = [];
private string _filter = "";
public LogsWindow(IModuleUiHost uiHost, LoggingService logger)
{
_logger = logger;
InitializeComponent();
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.logs");
this.FindControl<ItemsControl>("LogList")!.ItemsSource = _rows;
this.FindControl<Button>("ClearButton")!.Click += (_, _) => { _rows.Clear(); SetStatus("Geleert."); };
this.FindControl<Button>("CopyButton")!.Click += async (_, _) => await CopyAllAsync();
this.FindControl<TextBox>("FilterBox")!.TextChanged += (s, _) =>
_filter = ((TextBox)s!).Text ?? "";
_logger.EntryWritten += OnEntryWritten;
Closed += (_, _) => _logger.EntryWritten -= OnEntryWritten;
SetStatus("Bereit.");
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
/// <summary>
/// Wird aus beliebigen Worker-Threads gerufen. Ein Fehler hier darf den schreibenden Worker
/// niemals mitreißen deshalb der umschließende Schutz.
/// </summary>
private void OnEntryWritten(LogEntry e)
{
try
{
if (Dispatcher.UIThread.CheckAccess()) Append(e);
else Dispatcher.UIThread.Post(() => Append(e));
}
catch { /* Fenster wird gerade geschlossen */ }
}
private void Append(LogEntry e)
{
var text = LoggingService.Format(e);
if (_filter.Length > 0 && !text.Contains(_filter, StringComparison.OrdinalIgnoreCase))
return;
var color = e.Level switch
{
AppLogLevel.Warn => ColorWarn,
AppLogLevel.Error => ColorError,
_ => ColorInfo
};
_rows.Add(new LogRow(text, color));
while (_rows.Count > MaxLines) _rows.RemoveAt(0);
if (this.FindControl<CheckBox>("AutoScrollCheck")!.IsChecked == true)
this.FindControl<ScrollViewer>("LogScroller")!.ScrollToEnd();
}
private async Task CopyAllAsync()
{
var clipboard = GetTopLevel(this)?.Clipboard;
if (clipboard is null) { SetStatus("Zwischenablage nicht verfügbar."); return; }
await clipboard.SetTextAsync(string.Join(Environment.NewLine, _rows.Select(r => r.Text)));
SetStatus($"{_rows.Count} Zeilen kopiert.");
}
private void SetStatus(string text) =>
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
}
@@ -0,0 +1,128 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dg="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.DataGrid"
xmlns:vm="clr-namespace:IBKRTrader.App.ViewModels"
x:Class="IBKRTrader.App.Views.Modules.AccountingWindow"
Title="Accounting"
Width="1100" Height="720"
MinWidth="820" MinHeight="520"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
<!-- Gemeinsame Filterleiste über allen Registerkarten. -->
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Von" VerticalAlignment="Center" Margin="0,0,4,0" />
<DatePicker x:Name="FromDate" />
<TextBlock Text="Bis" VerticalAlignment="Center" Margin="10,0,4,0" />
<DatePicker x:Name="ToDate" />
<TextBlock Text="Konto" VerticalAlignment="Center" Margin="10,0,4,0" />
<ComboBox x:Name="AccountBox" MinWidth="150" />
<TextBlock Text="Währung" VerticalAlignment="Center" Margin="10,0,4,0" />
<ComboBox x:Name="CurrencyBox" MinWidth="90" />
<Button x:Name="RefreshButton" Content="Aktualisieren" Margin="14,0,0,0" />
</StackPanel>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock x:Name="StatusText" />
</Border>
<TabControl>
<!-- ── Übersicht / BWA ─────────────────────────────────────── -->
<TabItem Header="Übersicht / BWA">
<DockPanel Margin="10">
<WrapPanel x:Name="KpiPanel" DockPanel.Dock="Top" />
<TextBlock x:Name="CurrencyNote" DockPanel.Dock="Top"
Foreground="#666666" FontSize="11" Margin="0,0,0,8" TextWrapping="Wrap" />
<TextBlock Classes="section" DockPanel.Dock="Top" Text="Monatsvergleich" />
<dg:DataGrid x:Name="MonthlyGrid" AutoGenerateColumns="False" x:DataType="vm:MonthlyRow">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Monat" Binding="{Binding Month}" Width="90" />
<dg:DataGridTextColumn Header="Anfang" Binding="{Binding Opening}" Width="*" />
<dg:DataGridTextColumn Header="Einzahlungen" Binding="{Binding Deposits}" Width="*" />
<dg:DataGridTextColumn Header="Auszahlungen" Binding="{Binding Withdrawals}" Width="*" />
<dg:DataGridTextColumn Header="Volumen" Binding="{Binding Volume}" Width="*" />
<dg:DataGridTextColumn Header="Fees" Binding="{Binding Fees}" Width="*" />
<dg:DataGridTextColumn Header="Ergebnis" Binding="{Binding Result}" Width="*" />
<dg:DataGridTextColumn Header="Endsaldo" Binding="{Binding Closing}" Width="*" />
</dg:DataGrid.Columns>
</dg:DataGrid>
</DockPanel>
</TabItem>
<!-- ── Ledger ──────────────────────────────────────────────── -->
<TabItem Header="Ledger">
<dg:DataGrid x:Name="LedgerGrid" AutoGenerateColumns="False" x:DataType="vm:LedgerRow">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Zeit (UTC)"
Binding="{Binding Time, StringFormat='{}{0:yyyy-MM-dd HH:mm}'}" Width="140" />
<dg:DataGridTextColumn Header="Konto" Binding="{Binding AccountId}" Width="110" />
<dg:DataGridTextColumn Header="Typ" Binding="{Binding EventType}" Width="110" />
<dg:DataGridTextColumn Header="Side" Binding="{Binding Side}" Width="70" />
<dg:DataGridTextColumn Header="Symbol" Binding="{Binding Symbol}" Width="100" />
<dg:DataGridTextColumn Header="Währung" Binding="{Binding Currency}" Width="80" />
<dg:DataGridTextColumn Header="Menge" Binding="{Binding Quantity}" Width="90" />
<dg:DataGridTextColumn Header="Preis" Binding="{Binding Price}" Width="90" />
<dg:DataGridTextColumn Header="Brutto" Binding="{Binding Gross}" Width="100" />
<dg:DataGridTextColumn Header="Fee" Binding="{Binding Fee}" Width="90" />
<dg:DataGridTextColumn Header="Netto" Binding="{Binding Net}" Width="100" />
<dg:DataGridTextColumn Header="Transaktion" Binding="{Binding TransactionId}" Width="*" />
</dg:DataGrid.Columns>
</dg:DataGrid>
</TabItem>
<!-- ── Steuer (Platzhalter) ────────────────────────────────── -->
<TabItem Header="Steuer">
<ScrollViewer>
<SelectableTextBlock Margin="16" TextWrapping="Wrap"
Text="Die steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt).&#10;&#10;Der neutrale Ledger und die Periodenabrechnung sind davon unabhängig gültig. Eine konkrete Steuerschicht (z. B. DE-Kapitalertragsteuer oder US Form 8949 / Schedule D) wird hier später als klar dokumentierte, prüfbare Rechenschicht ergänzt.&#10;&#10;Hinweis: Dies ist keine Steuerberatung." />
</ScrollViewer>
</TabItem>
<!-- ── Abrechnung / Export ─────────────────────────────────── -->
<TabItem Header="Abrechnung / Export">
<StackPanel Margin="16" Spacing="8" HorizontalAlignment="Left">
<TextBlock Text="Exportiert die aktuelle Auswahl (Zeitraum / Konto / Währung):"
Margin="0,0,0,4" />
<Button x:Name="ExportLedgerCsvButton" Content="Ledger als CSV …" MinWidth="200" />
<Button x:Name="ExportStatementCsvButton" Content="Abrechnung als CSV …" MinWidth="200" />
<Button x:Name="ExportPdfButton" Content="Abrechnung als PDF …" MinWidth="200" />
</StackPanel>
</TabItem>
<!-- ── Abruf / Status ──────────────────────────────────────── -->
<TabItem Header="Abruf / Status">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="8" Spacing="8">
<Button x:Name="IngestIncrementalButton" Content="Inkrementell abrufen" />
<Button x:Name="IngestBackfillButton" Content="Backfill (voll)" />
</StackPanel>
<TextBlock x:Name="IngestStatus" DockPanel.Dock="Top" Margin="8,0,8,8"
Foreground="#666666" TextWrapping="Wrap" />
<dg:DataGrid x:Name="RunsGrid" AutoGenerateColumns="False" x:DataType="vm:IngestRunRow">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Konto" Binding="{Binding AccountId}" Width="110" />
<dg:DataGridTextColumn Header="Start"
Binding="{Binding Started, StringFormat='{}{0:dd.MM. HH:mm}'}" Width="110" />
<dg:DataGridTextColumn Header="Ende"
Binding="{Binding Finished, StringFormat='{}{0:dd.MM. HH:mm}', TargetNullValue=''}" Width="110" />
<dg:DataGridCheckBoxColumn Header="Backfill" Binding="{Binding Backfill}" Width="80" />
<dg:DataGridTextColumn Header="Neu" Binding="{Binding NewEntries}" Width="70" />
<dg:DataGridTextColumn Header="Duplikate" Binding="{Binding DuplicateEntries}" Width="90" />
<dg:DataGridCheckBoxColumn Header="OK" Binding="{Binding Success}" Width="60" />
<dg:DataGridTextColumn Header="Delta" Binding="{Binding Delta}" Width="100" />
<dg:DataGridTextColumn Header="Meldung" Binding="{Binding Message}" Width="*" />
</dg:DataGrid.Columns>
</dg:DataGrid>
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -0,0 +1,291 @@
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage;
using IBKRTrader.App.Shell;
using IBKRTrader.App.ViewModels;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Time;
using IBKRTrader.Modules.Accounting.Logic;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
namespace IBKRTrader.App.Views.Modules;
/// <summary>
/// Fenster des Accounting-Moduls: Übersicht/BWA, Ledger, Steuer (Platzhalter),
/// Abrechnung/Export und Abruf/Status.
///
/// <para>Alle DB-Zugriffe laufen NUR auf Nutzerinteraktion nie im Konstruktor, damit die
/// Konstruktionsprüfung das Fenster auch ohne Datenbank fehlerfrei baut.</para>
///
/// <para>Beträge werden beim Laden gegen <see cref="CultureInfo.InvariantCulture"/> formatiert:
/// die Anzeige soll nicht davon abhängen, auf welchem Host die Instanz läuft. Für den PDF-Export
/// gilt dieselbe Festlegung an einer eigenen Stelle (fest de-DE).</para>
/// </summary>
public partial class AccountingWindow : Window
{
private const string AllAccounts = "(alle)";
private readonly ILedgerRepository _ledger;
private readonly IIngestRunRepository _runs;
private readonly AccountingReportService _report;
private readonly AccountingIngestService _ingest;
private readonly LoggingService _logger;
public AccountingWindow(IModuleUiHost uiHost,
ILedgerRepository ledger,
IIngestRunRepository runs,
AccountingReportService report,
AccountingIngestService ingest,
LoggingService logger)
{
_ledger = ledger;
_runs = runs;
_report = report;
_ingest = ingest;
_logger = logger;
InitializeComponent();
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "accounting.main");
this.FindControl<DatePicker>("FromDate")!.SelectedDate = DateTimeOffset.Now.AddMonths(-1).Date;
this.FindControl<DatePicker>("ToDate")!.SelectedDate = DateTimeOffset.Now.Date;
var currency = this.FindControl<ComboBox>("CurrencyBox")!;
currency.ItemsSource = new[] { "USD", "EUR" };
currency.SelectedIndex = 0;
var account = this.FindControl<ComboBox>("AccountBox")!;
account.ItemsSource = new[] { AllAccounts };
account.SelectedIndex = 0;
this.FindControl<Button>("RefreshButton")!.Click += (_, _) => RefreshAll();
this.FindControl<Button>("ExportLedgerCsvButton")!.Click += async (_, _) => await ExportLedgerCsvAsync();
this.FindControl<Button>("ExportStatementCsvButton")!.Click += async (_, _) => await ExportStatementCsvAsync();
this.FindControl<Button>("ExportPdfButton")!.Click += async (_, _) => await ExportPdfAsync();
this.FindControl<Button>("IngestIncrementalButton")!.Click += async (_, _) => await RunIngestAsync(backfill: false);
this.FindControl<Button>("IngestBackfillButton")!.Click += async (_, _) => await RunIngestAsync(backfill: true);
this.FindControl<TextBlock>("IngestStatus")!.Text =
"Offline-Standard: keine Live-Quelle registriert → der Ingest bucht nichts (korrekt).";
SetStatus("Bereit Aktualisieren lädt die Daten.");
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
// ── Auswahl ──────────────────────────────────────────────────────────────
private string? SelectedAccount() =>
this.FindControl<ComboBox>("AccountBox")!.SelectedItem as string is { } a && a != AllAccounts ? a : null;
private string SelectedCurrency() =>
(string?)this.FindControl<ComboBox>("CurrencyBox")!.SelectedItem ?? "USD";
private (DateTime From, DateTime To) SelectedRange()
{
var from = this.FindControl<DatePicker>("FromDate")!.SelectedDate?.Date ?? DateTime.Today.AddMonths(-1);
var to = this.FindControl<DatePicker>("ToDate")!.SelectedDate?.Date ?? DateTime.Today;
return (from, to.AddDays(1).AddTicks(-1));
}
// ── Laden ────────────────────────────────────────────────────────────────
private void RefreshAll()
{
try
{
LoadAccounts();
LoadOverview();
LoadLedger();
LoadRuns();
SetStatus($"Aktualisiert: {AppTimeZone.Now:HH:mm:ss}");
}
catch (Exception ex)
{
_logger.Error("Accounting", $"Aktualisieren fehlgeschlagen: {ex.Message}", ex);
SetStatus($"Fehler: {ex.Message}");
}
}
private void LoadAccounts()
{
var box = this.FindControl<ComboBox>("AccountBox")!;
var current = box.SelectedItem as string;
var items = new List<string> { AllAccounts };
items.AddRange(_ledger.DistinctAccounts());
box.ItemsSource = items;
box.SelectedItem = current is not null && items.Contains(current) ? current : AllAccounts;
}
private void LoadOverview()
{
var (from, to) = SelectedRange();
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
var view = _report.GetCurrencyView(SelectedCurrency(), to);
string M(decimal v) => (Math.Round(v * view.Factor, 2)).ToString("N2", CultureInfo.InvariantCulture);
ShowKpis(
("Netto-Handelsergebnis", $"{M(stmt.NetTradingResult)} {view.Code}"),
("Handelsvolumen", M(stmt.TradeVolume)),
("Dividenden", M(stmt.Dividends)),
("Fees", M(stmt.Fees)),
("Endsaldo", M(stmt.ClosingBalance)),
("Trades", stmt.TradeCount.ToString(CultureInfo.InvariantCulture)),
("Buchungen", stmt.EntryCount.ToString(CultureInfo.InvariantCulture)));
this.FindControl<TextBlock>("CurrencyNote")!.Text = view.Note;
this.FindControl<DataGrid>("MonthlyGrid")!.ItemsSource = _report
.BuildMonthly(SelectedAccount(), from, to)
.Select(m => new MonthlyRow(
m.From.ToString("yyyy-MM", CultureInfo.InvariantCulture),
M(m.OpeningBalance), M(m.Deposits), M(m.Withdrawals),
M(m.TradeVolume), M(m.Fees), M(m.NetTradingResult), M(m.ClosingBalance)))
.ToList();
}
private void LoadLedger()
{
var (from, to) = SelectedRange();
this.FindControl<DataGrid>("LedgerGrid")!.ItemsSource = _ledger
.Query(SelectedAccount(), from, to, 2000)
.Select(e => new LedgerRow(
e.Timestamp, e.AccountId, e.EventType.ToString(), e.Side, e.Symbol,
e.Currency, e.Quantity, e.PriceNative, e.GrossBase, e.FeeBase, e.NetBase, e.TransactionId))
.ToList();
}
private void LoadRuns()
{
this.FindControl<DataGrid>("RunsGrid")!.ItemsSource = _runs
.GetRecent(SelectedAccount(), 100)
.Select(r => new IngestRunRow(
r.AccountId, r.StartedAt, r.FinishedAt, r.Backfill,
r.NewEntries, r.DuplicateEntries, r.Success,
// Nullable: solange kein Saldo-Anker vorliegt, gibt es kein Delta.
r.BalanceDeltaBase?.ToString("N2", CultureInfo.InvariantCulture) ?? "", r.Message))
.ToList();
}
// ── Export ───────────────────────────────────────────────────────────────
private async Task ExportLedgerCsvAsync()
{
var (from, to) = SelectedRange();
var entries = _ledger.Query(SelectedAccount(), from, to, 100_000);
await SaveTextAsync("ledger.csv", "CSV", "csv", CsvExporter.Ledger(entries));
}
private async Task ExportStatementCsvAsync()
{
var (from, to) = SelectedRange();
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
await SaveTextAsync("abrechnung.csv", "CSV", "csv", CsvExporter.Statement(stmt));
}
private async Task ExportPdfAsync()
{
try
{
var (from, to) = SelectedRange();
var account = SelectedAccount();
var stmt = _report.BuildStatement(account, from, to);
var monthly = _report.BuildMonthly(account, from, to);
var entries = _ledger.Query(account, from, to, 100_000).OrderBy(e => e.Timestamp).ToList();
var view = _report.GetCurrencyView(SelectedCurrency(), to);
var pdf = PdfExporter.Render(stmt, monthly, entries, view.Code, view.Factor, view.Note);
var file = await PickSaveFileAsync("abrechnung.pdf", "PDF", "pdf");
if (file is null) return;
await using var stream = await file.OpenWriteAsync();
await stream.WriteAsync(pdf);
_logger.Info("Accounting", $"PDF-Abrechnung geschrieben: {file.Name}");
SetStatus($"PDF geschrieben: {file.Name}");
}
catch (Exception ex)
{
_logger.Error("Accounting", $"PDF-Export fehlgeschlagen: {ex.Message}", ex);
SetStatus($"PDF-Export fehlgeschlagen: {ex.Message}");
}
}
private async Task SaveTextAsync(string suggested, string typeName, string extension, string content)
{
try
{
var file = await PickSaveFileAsync(suggested, typeName, extension);
if (file is null) return;
await using var stream = await file.OpenWriteAsync();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync(content);
_logger.Info("Accounting", $"Export geschrieben: {file.Name}");
SetStatus($"Export geschrieben: {file.Name}");
}
catch (Exception ex)
{
_logger.Error("Accounting", $"Export fehlgeschlagen: {ex.Message}", ex);
SetStatus($"Export fehlgeschlagen: {ex.Message}");
}
}
/// <summary>
/// Speicherdialog über den Speicheranbieter der Plattform der Nachfolger von
/// <c>SaveFileDialog</c>. Liefert <c>null</c>, wenn der Nutzer abbricht.
/// </summary>
private async Task<IStorageFile?> PickSaveFileAsync(string suggested, string typeName, string extension) =>
await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
SuggestedFileName = suggested,
DefaultExtension = extension,
FileTypeChoices = [new FilePickerFileType(typeName) { Patterns = [$"*.{extension}"] }]
});
// ── Abruf ────────────────────────────────────────────────────────────────
private async Task RunIngestAsync(bool backfill)
{
var status = this.FindControl<TextBlock>("IngestStatus")!;
try
{
status.Text = backfill ? "Backfill läuft …" : "Inkrementeller Abruf läuft …";
await _ingest.IngestAllAsync(backfill, CancellationToken.None);
status.Text = $"Abruf abgeschlossen ({AppTimeZone.Now:HH:mm:ss}).";
LoadRuns();
}
catch (Exception ex)
{
status.Text = $"Fehler: {ex.Message}";
_logger.Error("Accounting", $"Manueller Ingest fehlgeschlagen: {ex.Message}", ex);
}
}
// ── Hilfsmittel ──────────────────────────────────────────────────────────
private void ShowKpis(params (string Caption, string Value)[] kpis)
{
var panel = this.FindControl<WrapPanel>("KpiPanel")!;
panel.Children.Clear();
foreach (var (caption, value) in kpis)
{
var stack = new StackPanel();
stack.Children.Add(new TextBlock { Text = caption, Classes = { "caption" } });
stack.Children.Add(new TextBlock { Text = value, Classes = { "value" } });
panel.Children.Add(new Border { Classes = { "kpi" }, Child = stack });
}
}
private void SetStatus(string text) =>
this.FindControl<TextBlock>("StatusText")!.Text = text;
}
@@ -0,0 +1,45 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dg="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.DataGrid"
xmlns:vm="clr-namespace:IBKRTrader.App.ViewModels"
x:Class="IBKRTrader.App.Views.Modules.CongressTradingWindow"
Title="Congress Trading"
Width="920" Height="620"
MinWidth="640" MinHeight="420"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal">
<Button x:Name="RefreshButton" Content="Aktualisieren" />
<Button x:Name="ScrapeButton" Content="Scrape jetzt"
ToolTip.Tip="Löst den CT-Scrape-Worker sofort aus." />
</StackPanel>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock x:Name="StatusText" />
</Border>
<Grid Margin="12" RowDefinitions="Auto,Auto,Auto,*">
<TextBlock Grid.Row="0" Text="Congress Trading"
FontSize="18" FontWeight="SemiBold" Margin="0,0,0,10" />
<WrapPanel Grid.Row="1" x:Name="KpiPanel" />
<TextBlock Grid.Row="2" Classes="section" Text="Offene Positionen (Modul CT)" />
<dg:DataGrid Grid.Row="3" x:Name="PositionsGrid" AutoGenerateColumns="False"
x:DataType="vm:PositionRow">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Symbol" Binding="{Binding Symbol}" Width="140" />
<dg:DataGridTextColumn Header="Stück" Binding="{Binding Quantity}" Width="100" />
<dg:DataGridTextColumn Header="Ø-Kurs" Binding="{Binding AvgPrice}" Width="140" />
<dg:DataGridTextColumn Header="Wert" Binding="{Binding Notional}" Width="*" />
</dg:DataGrid.Columns>
</dg:DataGrid>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,109 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using IBKRTrader.App.Shell;
using IBKRTrader.App.ViewModels;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Time;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.CongressTrading.Database;
namespace IBKRTrader.App.Views.Modules;
/// <summary>
/// Fenster des CongressTrading-Moduls: DB-Kennzahlen, manueller Scrape-Auslöser und die offenen
/// Positionen des Moduls (aus dem Core-Portfolio).
///
/// <para>DB-Zugriffe laufen NUR beim Anzeigen und auf Nutzerinteraktion nie im Konstruktor,
/// damit die Konstruktionsprüfung auch ohne Datenbank durchläuft.</para>
/// </summary>
public partial class CongressTradingWindow : Window
{
private const string ScrapeWorkerName = "CT-ScrapeWorker";
private readonly CongressRepository _repo;
private readonly WorkerEngine _engine;
private readonly IPortfolioService _portfolio;
private readonly LoggingService _logger;
public CongressTradingWindow(IModuleUiHost uiHost,
CongressRepository repo,
WorkerEngine engine,
IPortfolioService portfolio,
LoggingService logger)
{
_repo = repo;
_engine = engine;
_portfolio = portfolio;
_logger = logger;
InitializeComponent();
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "congresstrading.main");
this.FindControl<Button>("RefreshButton")!.Click += async (_, _) => await RefreshAsync();
this.FindControl<Button>("ScrapeButton")!.Click += async (_, _) => await TriggerScrapeAsync();
Opened += async (_, _) => await RefreshAsync();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
private async Task RefreshAsync()
{
try
{
var trades = await _repo.GetTradeCountAsync();
var members = await _repo.GetMemberCountAsync();
ShowKpis(("Trades in DB", trades.ToString("N0")),
("Mitglieder in DB", members.ToString("N0")));
var positions = await _portfolio.GetPositionsAsync(CongressTradingModule.LogTag);
this.FindControl<DataGrid>("PositionsGrid")!.ItemsSource = positions
.Select(p => new PositionRow(p.Symbol, p.Quantity, p.AvgPrice, p.Notional))
.ToList();
SetStatus($"Aktualisiert: {AppTimeZone.Now:HH:mm:ss}");
}
catch (Exception ex)
{
ShowKpis(("Trades in DB", "n/v"), ("Mitglieder in DB", "n/v"));
SetStatus($"DB nicht erreichbar: {ex.Message}");
_logger.Warn(CongressTradingModule.LogTag, $"Kennzahlen konnten nicht geladen werden: {ex.Message}");
}
}
private async Task TriggerScrapeAsync()
{
try
{
SetStatus("Scrape angestoßen …");
await _engine.TriggerWorkerAsync(ScrapeWorkerName);
_logger.Info(CongressTradingModule.LogTag, "Scrape-Worker manuell ausgelöst (aus Modul-Fenster).");
SetStatus("Scrape ausgelöst.");
}
catch (Exception ex)
{
SetStatus($"Scrape fehlgeschlagen: {ex.Message}");
_logger.Error(CongressTradingModule.LogTag, "Manueller Scrape-Trigger fehlgeschlagen.", ex);
}
}
private void ShowKpis(params (string Caption, string Value)[] kpis)
{
var panel = this.FindControl<WrapPanel>("KpiPanel")!;
panel.Children.Clear();
foreach (var (caption, value) in kpis)
{
var stack = new StackPanel();
stack.Children.Add(new TextBlock { Text = caption, Classes = { "caption" } });
stack.Children.Add(new TextBlock { Text = value, Classes = { "value" } });
panel.Children.Add(new Border { Classes = { "kpi" }, Child = stack });
}
}
private void SetStatus(string text) =>
this.FindControl<TextBlock>("StatusText")!.Text = text;
}
@@ -0,0 +1,110 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dg="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.DataGrid"
xmlns:vm="clr-namespace:IBKRTrader.App.ViewModels"
xmlns:sup="clr-namespace:IBKRTrader.Modules.Supervisor.Services;assembly=IBKRTrader.Modules.Supervisor"
x:Class="IBKRTrader.App.Views.Modules.SupervisorWindow"
Title="Supervisor"
Width="1120" Height="760"
MinWidth="820" MinHeight="540"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock x:Name="StatusText" />
</Border>
<TabControl>
<!-- ── Analyse ─────────────────────────────────────────────── -->
<TabItem Header="Analyse">
<DockPanel Margin="8">
<Border Classes="toolbar" DockPanel.Dock="Top" Margin="-8,-8,-8,8">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Profil" VerticalAlignment="Center" Margin="0,0,6,0" />
<ComboBox x:Name="ProfileBox" MinWidth="170" />
<Button x:Name="AskButton" Content="Fragen" Margin="12,0,0,0" />
</StackPanel>
</Border>
<TextBox x:Name="QuestionBox" DockPanel.Dock="Top"
Height="72" AcceptsReturn="True" TextWrapping="Wrap"
Watermark="Frage an den Supervisor …" Margin="0,0,0,8" />
<Border Background="#14141E">
<ScrollViewer x:Name="AnswerScroller">
<SelectableTextBlock x:Name="AnswerText" Margin="8"
Foreground="#D2D2D2"
FontFamily="Consolas,Menlo,DejaVu Sans Mono,monospace"
FontSize="12" TextWrapping="Wrap" />
</ScrollViewer>
</Border>
</DockPanel>
</TabItem>
<!-- ── Dossier-Browser ─────────────────────────────────────── -->
<TabItem Header="Dossier-Browser">
<Grid ColumnDefinitions="420,4,*">
<DockPanel Grid.Column="0">
<Button x:Name="LoadSignalsButton" Content="Signale laden"
DockPanel.Dock="Top" Margin="6" HorizontalAlignment="Stretch" />
<dg:DataGrid x:Name="SignalsGrid" AutoGenerateColumns="False"
SelectionMode="Single" x:DataType="sup:SignalSummary">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Zeit"
Binding="{Binding FirstSeen, StringFormat='{}{0:dd.MM. HH:mm}'}"
Width="100" />
<dg:DataGridTextColumn Header="Symbol" Binding="{Binding Symbol}" Width="90" />
<dg:DataGridTextColumn Header="Modul" Binding="{Binding Module}" Width="110" />
<dg:DataGridTextColumn Header="Entscheid." Binding="{Binding LastDecision}" Width="*" />
</dg:DataGrid.Columns>
</dg:DataGrid>
</DockPanel>
<GridSplitter Grid.Column="1" Background="#DDDDDD" />
<Border Grid.Column="2" Background="#14141E">
<ScrollViewer>
<SelectableTextBlock x:Name="DossierText" Margin="8"
Foreground="#D2D2D2"
FontFamily="Consolas,Menlo,DejaVu Sans Mono,monospace"
FontSize="12" TextWrapping="Wrap"
Text="Signal links auswählen." />
</ScrollViewer>
</Border>
</Grid>
</TabItem>
<!-- ── Berichte ────────────────────────────────────────────── -->
<TabItem Header="Berichte">
<DockPanel>
<Button x:Name="LoadReportsButton" Content="Berichte laden"
DockPanel.Dock="Top" Margin="6" HorizontalAlignment="Left" />
<dg:DataGrid x:Name="ReportsGrid" AutoGenerateColumns="False"
x:DataType="vm:SupervisorReportRow">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Erstellt"
Binding="{Binding CreatedAt, StringFormat='{}{0:dd.MM.yyyy HH:mm}'}"
Width="140" />
<dg:DataGridTextColumn Header="Profil" Binding="{Binding Profile}" Width="110" />
<dg:DataGridTextColumn Header="Modell" Binding="{Binding Model}" Width="180" />
<dg:DataGridTextColumn Header="Frage" Binding="{Binding Question}" Width="*" />
<dg:DataGridTextColumn Header="Tools" Binding="{Binding ToolCallCount}" Width="70" />
<dg:DataGridTextColumn Header="Tokens" Binding="{Binding Tokens}" Width="110" />
</dg:DataGrid.Columns>
</dg:DataGrid>
</DockPanel>
</TabItem>
<!-- ── Hinweise ────────────────────────────────────────────── -->
<TabItem Header="Hinweise">
<ScrollViewer>
<SelectableTextBlock x:Name="InfoText" Margin="16" TextWrapping="Wrap" />
</ScrollViewer>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -0,0 +1,197 @@
using System.Text.Json;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using IBKRTrader.App.Shell;
using IBKRTrader.App.ViewModels;
using IBKRTrader.Core.Analytics;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Time;
using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
namespace IBKRTrader.App.Views.Modules;
/// <summary>
/// Fenster des Supervisor-Moduls: Analyse (Chat mit dem Agenten, Tool-Aufrufe sichtbar),
/// Dossier-Browser, Berichte und Hinweise. Strikt read-only kein Tool kann handeln oder schreiben.
///
/// <para>DB- und Agent-Zugriffe laufen NUR auf Nutzerinteraktion, nie im Konstruktor.</para>
/// </summary>
public partial class SupervisorWindow : Window
{
private readonly SupervisorAgent _agent;
private readonly DossierService _dossiers;
private readonly ISupervisorReportRepository _reports;
private readonly LoggingService _logger;
public SupervisorWindow(IModuleUiHost uiHost,
SupervisorAgent agent,
DossierService dossiers,
ISupervisorReportRepository reports,
LoggingService logger)
{
_agent = agent;
_dossiers = dossiers;
_reports = reports;
_logger = logger;
InitializeComponent();
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "supervisor.main");
var profiles = this.FindControl<ComboBox>("ProfileBox")!;
profiles.ItemsSource = SupervisorProfiles.All.Select(p => p.Name).ToList();
profiles.SelectedIndex = 0;
this.FindControl<Button>("AskButton")!.Click += async (_, _) => await AskAsync();
this.FindControl<Button>("LoadSignalsButton")!.Click += (_, _) => LoadSignals();
this.FindControl<Button>("LoadReportsButton")!.Click += (_, _) => LoadReports();
this.FindControl<DataGrid>("SignalsGrid")!.SelectionChanged += (_, _) => ShowSelectedDossier();
this.FindControl<SelectableTextBlock>("InfoText")!.Text = BuildInfoText();
SetStatus("Bereit.");
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
// ── Analyse ──────────────────────────────────────────────────────────────
private async Task AskAsync()
{
var questionBox = this.FindControl<TextBox>("QuestionBox")!;
var question = (questionBox.Text ?? "").Trim();
if (question.Length == 0) return;
var askButton = this.FindControl<Button>("AskButton")!;
var answer = this.FindControl<SelectableTextBlock>("AnswerText")!;
askButton.IsEnabled = false;
answer.Text = "";
var profile = SupervisorProfiles.ByName((string?)this.FindControl<ComboBox>("ProfileBox")!.SelectedItem ?? "");
// Der Agent meldet Tool-Aufrufe im Verlauf die sollen live sichtbar sein, nicht erst
// am Ende. Progress<T> meldet auf dem erfassten Kontext; der Dispatcher-Wechsel bleibt
// trotzdem stehen, weil der Agent aus einem Worker-Thread berichten kann.
var progress = new Progress<string>(AppendLine);
try
{
SetStatus("Analyse läuft …");
var result = await _agent.AskAsync(question, profile: profile, progress: progress);
AppendLine("");
AppendLine("─── Antwort ───");
AppendLine(result.Answer);
_reports.Insert(new SupervisorReport
{
Profile = profile.Name,
Model = SupervisorAgent.DefaultModel,
Question = question,
Answer = result.Answer,
ToolCallsJson = JsonSerializer.Serialize(result.ToolInvocations.Select(i => new { i.Tool, i.Arguments })),
ToolCallCount = result.ToolInvocations.Count,
PromptTokens = result.PromptTokens,
CompletionTokens = result.CompletionTokens
});
SetStatus($"Analyse abgeschlossen ({result.ToolInvocations.Count} Tool-Aufrufe).");
}
catch (Exception ex)
{
AppendLine("");
AppendLine($"FEHLER: {ex.Message}");
_logger.Warn("Supervisor", $"Analyse fehlgeschlagen: {ex.Message}");
SetStatus($"Analyse fehlgeschlagen: {ex.Message}");
}
finally
{
askButton.IsEnabled = true;
}
}
private void AppendLine(string text)
{
if (!Dispatcher.UIThread.CheckAccess())
{
Dispatcher.UIThread.Post(() => AppendLine(text));
return;
}
var block = this.FindControl<SelectableTextBlock>("AnswerText")!;
block.Text += text + Environment.NewLine;
this.FindControl<ScrollViewer>("AnswerScroller")!.ScrollToEnd();
}
// ── Dossier ──────────────────────────────────────────────────────────────
private void LoadSignals()
{
try
{
var signals = _dossiers.RecentSignals(200);
this.FindControl<DataGrid>("SignalsGrid")!.ItemsSource = signals;
SetStatus($"{signals.Count} Signale geladen.");
}
catch (Exception ex)
{
_logger.Warn("Supervisor", $"Signale laden fehlgeschlagen: {ex.Message}");
SetStatus($"Signale laden fehlgeschlagen: {ex.Message}");
}
}
private void ShowSelectedDossier()
{
if (this.FindControl<DataGrid>("SignalsGrid")!.SelectedItem is not SignalSummary s) return;
var target = this.FindControl<SelectableTextBlock>("DossierText")!;
try { target.Text = DossierBuilder.ToMarkdown(_dossiers.BuildForSignal(s.SignalId)); }
catch (Exception ex) { target.Text = $"FEHLER: {ex.Message}"; }
}
// ── Berichte ─────────────────────────────────────────────────────────────
private void LoadReports()
{
try
{
var rows = _reports.GetRecent(100)
.Select(r => new SupervisorReportRow(
r.CreatedAt, r.Profile, r.Model, r.Question, r.ToolCallCount,
$"{r.PromptTokens} / {r.CompletionTokens}"))
.ToList();
this.FindControl<DataGrid>("ReportsGrid")!.ItemsSource = rows;
SetStatus($"{rows.Count} Berichte geladen.");
}
catch (Exception ex)
{
_logger.Warn("Supervisor", $"Berichte laden fehlgeschlagen: {ex.Message}");
SetStatus($"Berichte laden fehlgeschlagen: {ex.Message}");
}
}
// ── Hinweise ─────────────────────────────────────────────────────────────
private static string BuildInfoText()
{
var keySet = !string.IsNullOrEmpty(OpenRouterClient.DefaultApiKeyProvider());
return
"Supervisor read-only Analyse und Forensik über alle Module." + Environment.NewLine + Environment.NewLine +
"OpenRouter-Key: env IBKRTRADER_OPENROUTER_KEY oder Datei 'openrouter.key' (gitignored)." + Environment.NewLine +
$" Status: {(keySet ? "gesetzt" : "NICHT gesetzt Analyse nicht verfügbar")}" + Environment.NewLine + Environment.NewLine +
"Tagesbericht (opt-in): env IBKRTRADER_SUPERVISOR_DAILY = Stunde 023." + Environment.NewLine +
$" Die Stunde gilt in der Betriebszeitzone dieser Instanz ({AppTimeZone.CurrentId})." + Environment.NewLine +
"MCP-Light (opt-in): env IBKRTRADER_MCP_PORT = Port (bindet nur 127.0.0.1)." + Environment.NewLine + Environment.NewLine +
"Sicherheit: OpenRouter ist ein bewusst freigegebener externer Datenempfänger. Gesendet werden " +
"nur Analyse-Daten der Tools, niemals Secrets. Kein Tool kann handeln oder schreiben.";
}
private void SetStatus(string text) =>
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
}
@@ -0,0 +1,28 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="IBKRTrader.App.Views.SettingsWindow"
Title="Settings"
Width="860" Height="740"
MinWidth="620" MinHeight="420"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal">
<Button x:Name="SaveButton" Content="Speichern" />
<Button x:Name="ReloadButton" Content="Verwerfen"
ToolTip.Tip="Lädt die gespeicherten Werte neu und verwirft ungespeicherte Änderungen." />
</StackPanel>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock x:Name="StatusText" />
</Border>
<ScrollViewer>
<StackPanel x:Name="SectionPanel" Margin="14" Spacing="4" />
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,160 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using IBKRTrader.App.Shell;
using IBKRTrader.App.ViewModels;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Time;
namespace IBKRTrader.App.Views;
/// <summary>
/// Core-Ansicht: Einstellungen. Ersetzt das <c>PropertyGrid</c> der WinForms-Fassung durch eine
/// aus den Attributen erzeugte Maske (siehe <see cref="SettingsModelBuilder"/>).
///
/// <para>Geändert wird direkt auf dem <c>AppSettings</c>-Objekt; „Speichern" schreibt es nach
/// <c>settings.json</c>, „Verwerfen" lädt die Datei neu. Zahlen werden ausdrücklich gegen
/// <see cref="CultureInfo.InvariantCulture"/> gelesen die Datei ist maschinenlesbar und darf
/// nicht von der Kultur des Rechners abhängen.</para>
/// </summary>
public partial class SettingsWindow : Window
{
private readonly SettingsService _settings;
public SettingsWindow(IModuleUiHost uiHost, SettingsService settings)
{
_settings = settings;
InitializeComponent();
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.settings");
this.FindControl<Button>("SaveButton")!.Click += (_, _) =>
{
try
{
_settings.Save();
SetStatus("Gespeichert. Zeitzonen-Änderungen greifen erst nach einem Neustart.");
}
catch (Exception ex) { SetStatus($"Speichern fehlgeschlagen: {ex.Message}"); }
};
this.FindControl<Button>("ReloadButton")!.Click += (_, _) =>
{
_settings.Load();
BuildForm();
SetStatus("Gespeicherte Werte neu geladen.");
};
BuildForm();
SetStatus("Bereit.");
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
private void BuildForm()
{
var panel = this.FindControl<StackPanel>("SectionPanel")!;
panel.Children.Clear();
foreach (var section in SettingsModelBuilder.Build(_settings.Settings))
{
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("240,*"),
Margin = new Thickness(4, 4, 4, 12)
};
for (var i = 0; i < section.Fields.Count; i++)
{
var field = section.Fields[i];
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
var label = new TextBlock
{
Text = field.DisplayName,
Margin = new Thickness(0, 6, 10, 6),
VerticalAlignment = VerticalAlignment.Center
};
if (!string.IsNullOrWhiteSpace(field.Description))
ToolTip.SetTip(label, field.Description);
var editor = CreateEditor(field);
editor.Margin = new Thickness(0, 4, 0, 4);
if (!string.IsNullOrWhiteSpace(field.Description))
ToolTip.SetTip(editor, field.Description);
Grid.SetRow(label, i); Grid.SetColumn(label, 0);
Grid.SetRow(editor, i); Grid.SetColumn(editor, 1);
grid.Children.Add(label);
grid.Children.Add(editor);
}
panel.Children.Add(new Expander
{
Header = section.Title,
IsExpanded = true,
Content = grid,
Margin = new Thickness(0, 0, 0, 6),
HorizontalContentAlignment = HorizontalAlignment.Stretch
});
}
}
/// <summary>Wählt das Eingabeelement passend zum Typ des Feldes.</summary>
private static Control CreateEditor(SettingsField field)
{
if (field.ValueType == typeof(bool))
{
var check = new CheckBox { IsChecked = (bool?)field.Get() };
check.IsCheckedChanged += (_, _) => field.Set(check.IsChecked == true);
return check;
}
if (field.ValueType.IsEnum)
{
var combo = new ComboBox
{
ItemsSource = Enum.GetValues(field.ValueType),
SelectedItem = field.Get(),
HorizontalAlignment = HorizontalAlignment.Left,
MinWidth = 200
};
combo.SelectionChanged += (_, _) => { if (combo.SelectedItem is not null) field.Set(combo.SelectedItem); };
return combo;
}
if (field.ValueType == typeof(int) || field.ValueType == typeof(long) ||
field.ValueType == typeof(double) || field.ValueType == typeof(decimal))
{
var numeric = new NumericUpDown
{
Value = ToDecimal(field.Get()),
Increment = field.ValueType == typeof(double) || field.ValueType == typeof(decimal) ? 0.5m : 1m,
FormatString = field.ValueType == typeof(double) || field.ValueType == typeof(decimal) ? "0.###" : "0",
HorizontalAlignment = HorizontalAlignment.Left,
MinWidth = 200
};
numeric.ValueChanged += (_, _) =>
{
if (numeric.Value is not { } v) return;
field.Set(Convert.ChangeType(v, field.ValueType, CultureInfo.InvariantCulture));
};
return numeric;
}
var box = new TextBox { Text = field.Get()?.ToString() ?? "" };
// Kennwortfelder (DB-Passwort, Flex-Token) nicht im Klartext anzeigen.
if (field.IsPassword) box.PasswordChar = '•';
box.TextChanged += (_, _) => field.Set(box.Text ?? "");
return box;
}
private static decimal ToDecimal(object? value) =>
value is null ? 0m : Convert.ToDecimal(value, CultureInfo.InvariantCulture);
private void SetStatus(string text) =>
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
}
@@ -0,0 +1,22 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="IBKRTrader.App.Views.ShutdownConfirmWindow"
Title="Beenden"
Width="440" SizeToContent="Height"
CanResize="False"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner">
<StackPanel Margin="20" Spacing="14">
<TextBlock Text="IBKRTrader wirklich beenden?"
FontSize="15" FontWeight="SemiBold" />
<TextBlock TextWrapping="Wrap" Foreground="#555555"
Text="Laufende Worker und Dienste werden gestoppt. Offene Broker-Anfragen werden abgebrochen; bereits platzierte Orders bleiben beim Broker bestehen und werden NICHT storniert." />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button x:Name="CancelButton" Content="Abbrechen" IsCancel="True" MinWidth="100" />
<Button x:Name="ConfirmButton" Content="Beenden" IsDefault="True" MinWidth="100" />
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,31 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace IBKRTrader.App.Views;
/// <summary>
/// Sicherheitsabfrage vor dem Beenden. Ersetzt <c>MessageBox.Show</c> Avalonia bringt keinen
/// eingebauten Meldungsdialog mit.
///
/// <para>Liefert <c>true</c> bei Bestätigung, sonst <c>false</c>; auch das Schließen über das X
/// zählt als Abbruch, damit ein versehentlicher Klick nie den Handelsbetrieb stoppt.</para>
/// </summary>
public partial class ShutdownConfirmWindow : Window
{
public ShutdownConfirmWindow()
{
InitializeComponent();
this.FindControl<Button>("ConfirmButton")!.Click += (_, _) => Close(true);
this.FindControl<Button>("CancelButton")!.Click += (_, _) => Close(false);
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
protected override void OnClosing(WindowClosingEventArgs e)
{
// Wird das Fenster über das X geschlossen, ist kein Ergebnis gesetzt ShowDialog<bool>
// liefert dann default(bool) = false. Genau das ist gewollt.
base.OnClosing(e);
}
}
@@ -0,0 +1,45 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dg="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.DataGrid"
xmlns:w="clr-namespace:IBKRTrader.Core.Workers;assembly=IBKRTrader.Core"
x:Class="IBKRTrader.App.Views.WorkersWindow"
Title="Workers / Services"
Width="1200" Height="700"
MinWidth="760" MinHeight="420"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal">
<Button x:Name="TriggerButton" Content="Jetzt ausführen"
ToolTip.Tip="Löst den ausgewählten Worker sofort aus." />
</StackPanel>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock x:Name="StatusText" />
</Border>
<!-- Die Zeilen sind WorkerInfo-Objekte aus der WorkerEngine. Sie melden Änderungen über
INotifyPropertyChanged, deshalb aktualisiert sich das Raster von selbst. -->
<dg:DataGrid x:Name="WorkersGrid" AutoGenerateColumns="False" SelectionMode="Single"
x:DataType="w:WorkerInfo">
<dg:DataGrid.Columns>
<dg:DataGridCheckBoxColumn Header="Aktiv" Binding="{Binding Active}" Width="60" />
<dg:DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="80" />
<dg:DataGridTextColumn Header="Modul" Binding="{Binding Module}" Width="100" />
<dg:DataGridTextColumn Header="Worker" Binding="{Binding WorkerName}" Width="200" />
<dg:DataGridTextColumn Header="Letzter Lauf"
Binding="{Binding LastRuntime, StringFormat='{}{0:dd.MM.yyyy HH:mm:ss}', TargetNullValue=''}"
Width="150" />
<dg:DataGridTextColumn Header="Nächster Lauf"
Binding="{Binding NextRuntime, StringFormat='{}{0:dd.MM.yyyy HH:mm:ss}', TargetNullValue=''}"
Width="150" />
<dg:DataGridTextColumn Header="Intervall" Binding="{Binding RunEvery}" Width="90" />
<dg:DataGridTextColumn Header="Info" Binding="{Binding Info}" Width="*" />
</dg:DataGrid.Columns>
</dg:DataGrid>
</DockPanel>
</Window>
@@ -0,0 +1,61 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using IBKRTrader.App.Shell;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Time;
using IBKRTrader.Core.Workers;
namespace IBKRTrader.App.Views;
/// <summary>
/// Core-Ansicht: Worker- und Service-Übersicht, live an die <see cref="WorkerEngine"/> gebunden.
///
/// <para>Die Liste selbst ändert sich zur Laufzeit nicht die Worker werden einmal im
/// Konstruktor der Engine registriert. Was sich ändert, sind die Eigenschaften je Zeile, und
/// die meldet <c>WorkerInfo</c> über <c>INotifyPropertyChanged</c>. Deshalb genügt hier die
/// direkte Bindung an die Liste der Engine, ohne eine gespiegelte Sammlung.</para>
/// </summary>
public partial class WorkersWindow : Window
{
private readonly WorkerEngine _engine;
public WorkersWindow(IModuleUiHost uiHost, WorkerEngine engine)
{
_engine = engine;
InitializeComponent();
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.workers");
var grid = this.FindControl<DataGrid>("WorkersGrid")!;
grid.ItemsSource = _engine.WorkerInfos;
this.FindControl<Button>("TriggerButton")!.Click += async (_, _) => await TriggerSelectedAsync();
SetStatus($"{_engine.WorkerInfos.Count} Worker/Services registriert.");
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
private async Task TriggerSelectedAsync()
{
if (this.FindControl<DataGrid>("WorkersGrid")!.SelectedItem is not WorkerInfo selected)
{
SetStatus("Kein Worker ausgewählt.");
return;
}
try
{
SetStatus($"{selected.WorkerName} wird ausgelöst …");
await _engine.TriggerWorkerAsync(selected.WorkerName);
SetStatus($"{selected.WorkerName} ausgelöst.");
}
catch (Exception ex)
{
SetStatus($"{selected.WorkerName} fehlgeschlagen: {ex.Message}");
}
}
private void SetStatus(string text) =>
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
}
+11
View File
@@ -0,0 +1,11 @@
{
"Database": {
"MySqlConnectionString": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Warning"
}
}
}
-24
View File
@@ -1,24 +0,0 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.Core.AI;
/// <summary>
/// Platzhalter für KI-Modell-Integration.
/// Wird in einer späteren Phase mit echten ML-Modellen ersetzt.
/// </summary>
public class AIModelService
{
private readonly LoggingService _logger;
public AIModelService(LoggingService logger)
{
_logger = logger;
}
/// <summary>Platzhalter: Gibt immer 0.5 zurück (keine echte Vorhersage).</summary>
public Task<double> PredictAsync(string symbol, object features)
{
_logger.Warn("AI", $"AIModelService ist ein Platzhalter keine echte Vorhersage für {symbol}.");
return Task.FromResult(0.5);
}
}
@@ -19,13 +19,6 @@ public class BudgetService
_logger = logger; _logger = logger;
} }
public async Task<decimal> GetAvailableBudgetAsync(string module)
{
await using var db = await _dbf.CreateDbContextAsync();
var b = await db.Budgets.FindAsync(module);
return b is null ? 0m : b.TotalBudget - b.UsedBudget;
}
public async Task ReserveBudgetAsync(string module, decimal amount) public async Task ReserveBudgetAsync(string module, decimal amount)
{ {
await using var db = await _dbf.CreateDbContextAsync(); await using var db = await _dbf.CreateDbContextAsync();
@@ -0,0 +1,115 @@
namespace IBKRTrader.Core.Configuration;
/// <summary>
/// Die Verzeichnisse, in die die Anwendung schreibt.
///
/// <para><b>Warum das nötig wurde:</b> Bisher lag alles neben der Binärdatei <c>Logs/</c>,
/// <c>Backups/</c>, <c>settings.json</c>, <c>master.key</c>. Unter Windows ist das üblich. Auf
/// Linux liegt eine Anwendung typischerweise unter <c>/opt</c> oder <c>/usr/local</c>, und dort
/// hat der Dienstbenutzer <b>keinen Schreibzugriff</b>. Der Dienst wäre beim ersten Logeintrag
/// gescheitert.</para>
///
/// <para><b>Auflösung, in dieser Reihenfolge:</b></para>
/// <list type="number">
/// <item>Umgebungsvariable (<c>IBKRTRADER_CONFIG_DIR</c>, <c>_DATA_DIR</c>, <c>_LOG_DIR</c>)
/// 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
/// bisherige Verhalten unter Windows und beim Entwickeln unter Linux unverändert.</item>
/// <item>Sonst plattformabhängig: unter Linux/macOS die FHS-Konvention
/// (<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>
///
/// <para>Die Prüfung läuft einmal beim ersten Zugriff; die Verzeichnisse werden dabei angelegt.</para>
/// </summary>
public static class AppPaths
{
private const string AppFolder = "ibkrtrader";
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}", windowsSubDir: "Data"));
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>
public static string Config => _config.Value;
/// <summary>Veränderliche Daten: <c>Backups/</c>.</summary>
public static string Data => _data.Value;
/// <summary>Logdateien (Textlog je Modul und JSONL).</summary>
public static string Logs => _logs.Value;
/// <summary>Vollständiger Pfad einer Konfigurationsdatei.</summary>
public static string ConfigFile(string fileName) => Path.Combine(Config, fileName);
/// <summary>Vollständiger Pfad unterhalb des Datenverzeichnisses.</summary>
public static string DataPath(string relative) => Path.Combine(Data, relative);
/// <summary>Kurzfassung für den Startlog damit im Betrieb sichtbar ist, wohin geschrieben wird.</summary>
public static string Describe() => $"config={Config}, data={Data}, logs={Logs}";
/// <param name="envVar">Umgebungsvariable, die alles überstimmt.</param>
/// <param name="fhsFallback">FHS-Pfad unter Linux/macOS, wenn neben der Binärdatei nicht geschrieben werden darf.</param>
/// <param name="localSubDir">
/// 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
/// weiterlaufen.
/// </param>
/// <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);
if (!string.IsNullOrWhiteSpace(fromEnv))
return Ensure(fromEnv.Trim());
var local = localSubDir is null
? AppContext.BaseDirectory
: Path.Combine(AppContext.BaseDirectory, localSubDir);
if (IsWritable(AppContext.BaseDirectory))
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);
}
/// <summary>
/// Prüft den Schreibzugriff, indem tatsächlich geschrieben wird. Eine Rechteprüfung über
/// Attribute trägt nicht: unter Linux entscheiden Besitzer, Gruppe und Modus, unter Windows
/// die ACL ein Schreibversuch ist die einzige verlässliche Antwort.
/// </summary>
private static bool IsWritable(string dir)
{
try
{
var probe = Path.Combine(dir, $".write-probe-{Guid.NewGuid():N}");
using (File.Create(probe, 1, FileOptions.DeleteOnClose)) { }
return true;
}
catch (UnauthorizedAccessException) { return false; }
catch (IOException) { return false; }
}
private static string Ensure(string dir)
{
Directory.CreateDirectory(dir);
return dir;
}
}
+1 -56
View File
@@ -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>
@@ -244,7 +197,7 @@ public class IBKRGatewayService
var url = $"iserver/marketdata/history?conid={conid}" + var url = $"iserver/marketdata/history?conid={conid}" +
$"&period={Uri.EscapeDataString(period)}" + $"&period={Uri.EscapeDataString(period)}" +
$"&bar={Uri.EscapeDataString(bar)}" + $"&bar={Uri.EscapeDataString(bar)}" +
$"&outsideRth={outsideRth.ToString().ToLower()}"; $"&outsideRth={(outsideRth ? "true" : "false")}";
var response = await _http.GetAsync(url, ct); var response = await _http.GetAsync(url, ct);
@@ -263,12 +216,4 @@ public class IBKRGatewayService
return null; return null;
} }
} }
// ─── Disconnect ──────────────────────────────────────────────────────────
public Task DisconnectAsync()
{
IsConnected = false;
return Task.CompletedTask;
}
} }
+5 -4
View File
@@ -1,12 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework> <!-- Plattformneutral: derselbe Core läuft unter Windows und Linux. Der UI-Contract
(ModuleView/IModuleUiHost) ist toolkit-neutral (Func&lt;object&gt; statt eines Fenstertyps,
IconKey statt System.Drawing.Image) hier hängt kein UI-Toolkit und kein
System.Drawing.Common, letzteres ist seit .NET 7 Windows-only und wirft auf Linux. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- Der Core stellt den UI-Contract (ModuleFormBase/WindowManager, später IModuleUiHost/ModuleView)
bereit, damit Module designbare Forms beitragen können. -->
<UseWindowsForms>true</UseWindowsForms>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
+1 -1
View File
@@ -1,6 +1,6 @@
namespace IBKRTrader.Core.Logging; namespace IBKRTrader.Core.Logging;
/// <summary>Immutable log entry wird in Datei und RichTextBox geschrieben.</summary> /// <summary>Unveränderlicher Logeintrag geht in die Logdateien und an alle Senken.</summary>
public sealed record LogEntry( public sealed record LogEntry(
DateTime Timestamp, DateTime Timestamp,
AppLogLevel Level, AppLogLevel Level,
+52 -49
View File
@@ -1,25 +1,37 @@
using IBKRTrader.Core.Configuration;
using IBKRTrader.Core.Time;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
namespace IBKRTrader.Core.Logging; namespace IBKRTrader.Core.Logging;
/// <summary> /// <summary>
/// Thread-sicherer Logging-Service. /// Thread-sicherer Logging-Service.
/// Schreibt farbig in die RichTextBox (UI-Thread-safe via BeginInvoke) /// Schreibt in Logs\{Module}\{Level}-dd-MM-yy.txt sowie strukturiert nach Logs\{Datum}.jsonl
/// Schreibt in Logs\{Module}\{Level}-dd-MM-yy.txt /// Meldet jeden Eintrag über <see cref="EntryWritten"/> an interessierte Senken (z. B. die
/// Live-Log-Ansicht der Oberfläche)
///
/// <para><b>Bewusst ohne UI-Bezug:</b> Früher hielt dieser Dienst direkt eine
/// <c>RichTextBox</c> samt <c>System.Drawing.Color</c> und marshallte selbst auf den UI-Thread.
/// Damit hing der Core an WinForms. Jetzt kennt er nur noch das Ereignis; Einfärbung und
/// Thread-Wechsel sind Sache der jeweiligen Oberfläche.</para>
/// </summary> /// </summary>
public class LoggingService public class LoggingService
{ {
private RichTextBox? _rtb;
private AppLogLevel _minLevel = AppLogLevel.Info; private AppLogLevel _minLevel = AppLogLevel.Info;
private readonly object _fileLock = new(); private readonly object _fileLock = new();
private readonly object _jsonlLock = new(); private readonly object _jsonlLock = new();
private static readonly string LogBaseDir = private static readonly string LogBaseDir =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); AppPaths.Logs;
// ─── Konfiguration ──────────────────────────────────────────────────────── // ─── Konfiguration ────────────────────────────────────────────────────────
public void AttachRichTextBox(RichTextBox rtb) => _rtb = rtb; /// <summary>
/// Feuert für jeden geschriebenen Eintrag (nach der Mindest-Level-Prüfung). Die Oberfläche
/// hängt sich hier ein; das Marshalling auf den UI-Thread übernimmt sie selbst, weil dieser
/// Dienst aus beliebigen Worker-Threads schreibt.
/// </summary>
public event Action<LogEntry>? EntryWritten;
public void SetMinLevel(AppLogLevel level) => _minLevel = level; public void SetMinLevel(AppLogLevel level) => _minLevel = level;
@@ -48,10 +60,27 @@ public class LoggingService
public void Write(AppLogLevel level, string module, string message, Exception? ex = null, string? cid = null) public void Write(AppLogLevel level, string module, string message, Exception? ex = null, string? cid = null)
{ {
if (level < _minLevel) return; if (level < _minLevel) return;
var entry = new LogEntry(DateTime.Now, level, module, message, ex);
// Zwei Zeitformen, bewusst getrennt:
// • entry.Timestamp = Betriebszeitzone → Anzeige und Dateinamen (…-dd-MM-yy.txt,
// {yyyy-MM-dd}.jsonl). Die Tagesgrenzen sollen an der Instanz hängen, nicht am Host
// ein UTC-Container hätte sonst andere Grenzen als der Windows-Desktop, und der
// Supervisor liest die JSONL-Dateien über genau diese Namen.
// • utc = maschinenlesbares Feld im JSONL. Muss separat mitgeführt werden, weil die
// umgerechnete Ortszeit Kind=Unspecified trägt: ein ToUniversalTime() darauf würde sie
// als Zeit des HOSTS deuten und bei abweichender Rechnerzeitzone falsch verschieben.
var utc = DateTime.UtcNow;
var entry = new LogEntry(AppTimeZone.ToDisplay(utc), level, module, message, ex);
WriteToFile(entry); WriteToFile(entry);
WriteToJsonl(entry, cid); WriteToJsonl(entry, utc, cid);
WriteToRtb(entry); NotifySinks(entry);
}
private void NotifySinks(LogEntry e)
{
// Eine hängende Senke darf den schreibenden Worker nicht mitreißen.
try { EntryWritten?.Invoke(e); } catch { /* Logging darf niemals abstürzen */ }
} }
// ─── Datei ──────────────────────────────────────────────────────────────── // ─── Datei ────────────────────────────────────────────────────────────────
@@ -66,10 +95,10 @@ public class LoggingService
var file = Path.Combine(dir, $"{e.Level}-{e.Timestamp:dd-MM-yy}.txt"); var file = Path.Combine(dir, $"{e.Level}-{e.Timestamp:dd-MM-yy}.txt");
var line = $"[{e.Timestamp:HH:mm:ss}] {e.Message}"; var line = $"[{e.Timestamp:HH:mm:ss}] {e.Message}";
if (e.Exception != null) if (e.Exception != null)
line += $"\r\n {e.Exception}"; line += $"{Environment.NewLine} {e.Exception}";
lock (_fileLock) lock (_fileLock)
File.AppendAllText(file, line + "\r\n"); File.AppendAllText(file, line + Environment.NewLine);
} }
catch { /* Logging darf niemals abstürzen */ } catch { /* Logging darf niemals abstürzen */ }
} }
@@ -79,15 +108,16 @@ public class LoggingService
/// <summary> /// <summary>
/// Schreibt zusätzlich eine JSON-Zeile nach Logs\{yyyy-MM-dd}.jsonl (Dual-Sink). Zeilenweise /// Schreibt zusätzlich eine JSON-Zeile nach Logs\{yyyy-MM-dd}.jsonl (Dual-Sink). Zeilenweise
/// filter-/parsebar (Datum/Level/Quelle/Text/CorrelationId) Grundlage für Log Viewer + Supervisor. /// filter-/parsebar (Datum/Level/Quelle/Text/CorrelationId) Grundlage für Log Viewer + Supervisor.
/// Dateiname nach Ortszeit (Tagesgrenze), <c>ts</c>-Feld in UTC (maschinenlesbar).
/// </summary> /// </summary>
private void WriteToJsonl(LogEntry e, string? cid) private void WriteToJsonl(LogEntry e, DateTime utc, string? cid)
{ {
try try
{ {
Directory.CreateDirectory(LogBaseDir); Directory.CreateDirectory(LogBaseDir);
var file = Path.Combine(LogBaseDir, $"{e.Timestamp:yyyy-MM-dd}.jsonl"); var file = Path.Combine(LogBaseDir, $"{e.Timestamp:yyyy-MM-dd}.jsonl");
var message = e.Exception != null ? $"{e.Message} | {e.Exception.Message}" : e.Message; var message = e.Exception != null ? $"{e.Message} | {e.Exception.Message}" : e.Message;
var json = LogJson.WriteLine(e.Timestamp, e.Level, e.Module, message, cid); var json = LogJson.WriteLine(utc, e.Level, e.Module, message, cid);
lock (_jsonlLock) lock (_jsonlLock)
File.AppendAllText(file, json + "\n"); File.AppendAllText(file, json + "\n");
@@ -95,45 +125,18 @@ public class LoggingService
catch { /* Logging darf niemals abstürzen */ } catch { /* Logging darf niemals abstürzen */ }
} }
// ─── RichTextBox ────────────────────────────────────────────────────────── // ─── Anzeigeformat ────────────────────────────────────────────────────────
private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150); /// <summary>
private static readonly Color ColorWarn = Color.FromArgb(255, 190, 60); /// Einzeilige Darstellung für Log-Ansichten. Liegt hier, damit jede Oberfläche dieselbe Zeile
private static readonly Color ColorError = Color.FromArgb(255, 80, 80); /// zeigt. <c>ToUpperInvariant</c> ist Absicht: <c>ToUpper()</c> würde unter tr-TR aus "info"
/// ein "İNFO" machen.
private void WriteToRtb(LogEntry e) /// </summary>
public static string Format(LogEntry e)
{ {
if (_rtb == null) return; var text = $"[{e.Timestamp:HH:mm:ss}] [{e.Level.ToString().ToUpperInvariant(),-5}] [{e.Module}] {e.Message}";
try
{
var color = e.Level switch
{
AppLogLevel.Warn => ColorWarn,
AppLogLevel.Error => ColorError,
_ => ColorInfo
};
var text = $"[{e.Timestamp:HH:mm:ss}] [{e.Level.ToString().ToUpper(),-5}] [{e.Module}] {e.Message}";
if (e.Exception != null) if (e.Exception != null)
text += $"\r\n {e.Exception.Message}"; text += $"{Environment.NewLine} {e.Exception.Message}";
text += "\r\n"; return text;
if (_rtb.InvokeRequired)
_rtb.BeginInvoke(() => AppendColored(text, color));
else
AppendColored(text, color);
}
catch { }
}
private void AppendColored(string text, Color color)
{
if (_rtb == null) return;
_rtb.SelectionStart = _rtb.TextLength;
_rtb.SelectionLength = 0;
_rtb.SelectionColor = color;
_rtb.AppendText(text);
_rtb.SelectionColor = _rtb.ForeColor;
if (_rtb.TextLength > 0)
_rtb.ScrollToCaret();
} }
} }
+22 -11
View File
@@ -1,18 +1,22 @@
using System.Windows.Forms;
namespace IBKRTrader.Core.Modularity; namespace IBKRTrader.Core.Modularity;
/// <summary> /// <summary>
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die eigentliche UI ist ein /// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die Shell zeigt je View höchstens
/// <see cref="Form"/>, das über <see cref="CreateForm"/> erzeugt wird (mit DI-Abhängigkeiten). /// eine Instanz und holt ein offenes Fenster wieder nach vorne.
/// Die Shell zeigt je View höchstens eine Instanz und holt ein offenes Fenster wieder nach vorne. ///
/// <para><b>Bewusst toolkit-neutral:</b> <see cref="CreateView"/> liefert ein <see cref="object"/>,
/// keinen konkreten Fenstertyp, und <see cref="IconKey"/> ist ein Schlüssel statt eines Bildes.
/// Dadurch trägt der Core keine UI-Abhängigkeit und bleibt plattformneutral Voraussetzung für den
/// kopflosen Linux-Betrieb. Insbesondere hängt hier kein <c>System.Drawing.Image</c> mehr:
/// <c>System.Drawing.Common</c> ist seit .NET 7 Windows-only und wirft auf Linux. Die jeweilige
/// Shell kennt ihr Toolkit und castet die Avalonia-Shell auf <c>Window</c>.</para>
/// </summary> /// </summary>
public sealed class ModuleView public sealed class ModuleView
{ {
/// <summary>Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View).</summary> /// <summary>Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View).</summary>
public string Id { get; init; } = Guid.NewGuid().ToString(); public string Id { get; init; } = Guid.NewGuid().ToString();
/// <summary>Titel (Fallback-Fenstertitel, falls das Form keinen eigenen setzt).</summary> /// <summary>Titel (Fallback-Fenstertitel, falls das Fenster keinen eigenen setzt).</summary>
public string Title { get; init; } = "Fenster"; public string Title { get; init; } = "Fenster";
/// <summary>Optionale Gruppierung (z. B. "Core", "CongressTrading").</summary> /// <summary>Optionale Gruppierung (z. B. "Core", "CongressTrading").</summary>
@@ -21,17 +25,24 @@ public sealed class ModuleView
/// <summary>Optionale Sortierreihenfolge in Menü/Buttons.</summary> /// <summary>Optionale Sortierreihenfolge in Menü/Buttons.</summary>
public int Order { get; init; } = 0; public int Order { get; init; } = 0;
/// <summary>Optionales Icon für Menü/Buttons.</summary> /// <summary>
public System.Drawing.Image? Icon { get; set; } /// Logischer Schlüssel des Symbols für Menü/Buttons (z. B. "dashboard", "logs"). Die Shell löst
/// ihn gegen ihre eigenen Bildressourcen auf. Settable, damit die Shell den von Modulen
/// registrierten Views zentral ein Symbol zuweisen kann Module kennen die Shell-Ressourcen nicht.
/// </summary>
public string? IconKey { get; set; }
/// <summary>Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung).</summary> /// <summary>
public Func<Form> CreateForm { get; init; } = () => new Form(); /// Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung). Rückgabetyp ist
/// <see cref="object"/> siehe Klassen-Doku zur Toolkit-Neutralität.
/// </summary>
public Func<object> CreateView { get; init; } = () => new object();
} }
/// <summary> /// <summary>
/// Wird der Shell beim Start übergeben; Core und Module registrieren hier ihre Ansichten. /// Wird der Shell beim Start übergeben; Core und Module registrieren hier ihre Ansichten.
/// Über die Navigations-Mitglieder kann JEDES Fenster (auch Modul-Fenster, die nur den Core kennen) /// Über die Navigations-Mitglieder kann JEDES Fenster (auch Modul-Fenster, die nur den Core kennen)
/// das gemeinsame „Fenster"-Menü bauen (siehe <see cref="WindowMenu"/>). /// das gemeinsame „Fenster"-Menü bauen.
/// </summary> /// </summary>
public interface IModuleUiHost public interface IModuleUiHost
{ {
@@ -1,79 +0,0 @@
using System.Drawing;
using System.Windows.Forms;
namespace IBKRTrader.Core.Modularity;
/// <summary>
/// Baut das gemeinsame Fenster-Menü, das auf JEDEM Fenster erscheint und das Wechseln zwischen allen
/// Fenstern (Launcher + Core + Module) erlaubt. Da es nur den Core-Contract <see cref="IModuleUiHost"/>
/// nutzt, funktioniert es auch aus Modul-Fenstern (die die App nicht kennen).
/// </summary>
public static class WindowMenu
{
/// <summary>
/// Verdrahtet einen <see cref="MenuStrip"/> mit der Fensterliste: füllt ihn sofort und baut ihn bei
/// jeder Offen-Status-Änderung neu auf. Die Registrierung wird beim Entsorgen sauber gelöst.
/// </summary>
public static void Wire(MenuStrip menu, IModuleUiHost host, string? currentViewId)
{
void Refresh()
{
if (menu.IsDisposed) return;
if (menu.IsHandleCreated && menu.InvokeRequired)
{
try { menu.BeginInvoke((Action)(() => Populate(menu, host, currentViewId))); }
catch { /* Fenster wird gerade geschlossen */ }
return;
}
Populate(menu, host, currentViewId);
}
Populate(menu, host, currentViewId);
host.OpenStateChanged += Refresh;
menu.Disposed += (_, _) => host.OpenStateChanged -= Refresh;
}
/// <summary>Baut die Menüleiste komplett neu auf (Launcher, alle Views nebeneinander, Aktion rechts).</summary>
public static void Populate(MenuStrip menu, IModuleUiHost host, string? currentViewId)
{
menu.Items.Clear();
var launcher = new ToolStripMenuItem("Launcher") { Checked = currentViewId == null };
if (currentViewId == null) launcher.Font = new Font(launcher.Font, FontStyle.Bold);
launcher.Click += (_, _) => host.ActivateMain();
menu.Items.Add(launcher);
foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
bool isCurrent = view.Id == currentViewId;
var item = new ToolStripMenuItem(view.Title)
{
Image = view.Icon,
ImageScaling = ToolStripItemImageScaling.SizeToFit,
DisplayStyle = view.Icon != null
? ToolStripItemDisplayStyle.ImageAndText
: ToolStripItemDisplayStyle.Text,
Checked = isCurrent || host.IsOpen(view.Id)
};
if (isCurrent) item.Font = new Font(item.Font, FontStyle.Bold);
string id = view.Id;
item.Click += (_, _) => host.OpenView(id);
menu.Items.Add(item);
}
// Kontextabhängige rechte Aktion: nur der Launcher darf die App beenden; jedes andere Fenster
// bietet nur „Fenster schließen" (kein App-Shutdown, Module laufen weiter).
if (currentViewId == null)
{
var exit = new ToolStripMenuItem("Beenden") { Alignment = ToolStripItemAlignment.Right };
exit.Click += (_, _) => host.RequestShutdown();
menu.Items.Add(exit);
}
else
{
var close = new ToolStripMenuItem("Fenster schließen") { Alignment = ToolStripItemAlignment.Right };
close.Click += (_, _) => menu.FindForm()?.Close();
menu.Items.Add(close);
}
}
}
@@ -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?
+73 -4
View File
@@ -11,7 +11,7 @@ public class DatabaseSettings
[Category("Database")] [Category("Database")]
[DisplayName("Host")] [DisplayName("Host")]
[Description("MySQL-Serveradresse (Hostname oder IP)")] [Description("MySQL-Serveradresse (Hostname oder IP)")]
public string Host { get; set; } = "larx.your-database.de"; public string Host { get; set; } = "your-db-host";
[Category("Database")] [Category("Database")]
[DisplayName("Port")] [DisplayName("Port")]
@@ -21,18 +21,18 @@ public class DatabaseSettings
[Category("Database")] [Category("Database")]
[DisplayName("Database")] [DisplayName("Database")]
[Description("Name der MySQL-Datenbank")] [Description("Name der MySQL-Datenbank")]
public string Database { get; set; } = "bergisnu_db0"; public string Database { get; set; } = "your-db-name";
[Category("Database")] [Category("Database")]
[DisplayName("User")] [DisplayName("User")]
[Description("MySQL-Benutzername")] [Description("MySQL-Benutzername")]
public string User { get; set; } = "bergisnu_0"; public string User { get; set; } = "your-db-user";
[Category("Database")] [Category("Database")]
[DisplayName("Password")] [DisplayName("Password")]
[Description("MySQL-Passwort")] [Description("MySQL-Passwort")]
[PasswordPropertyText(true)] [PasswordPropertyText(true)]
public string Password { get; set; } = "o5/A49+f}:Da"; public string Password { get; set; } = "CHANGE_ME";
public string BuildConnectionString() => public string BuildConnectionString() =>
$"Server={Host};Port={Port};Database={Database};" + $"Server={Host};Port={Port};Database={Database};" +
@@ -211,6 +211,15 @@ public class TradingSettings
[Description("Handelsmodus: Paper (Test-Account, Port 4002) oder Live (Port 4001)")] [Description("Handelsmodus: Paper (Test-Account, Port 4002) oder Live (Port 4001)")]
public string Mode { get; set; } = "Paper"; public string Mode { get; set; } = "Paper";
[Category("Trading")]
[DisplayName("Betriebszeitzone")]
[Description("Zeitzone dieser Instanz in IANA-Schreibweise, z. B. \"Europe/Berlin\" (EU) oder " +
"\"America/New_York\" (US). Gilt für Anzeige, Logdatei-Tagesgrenzen, Berichtszeiten " +
"und Buchungsperioden; gespeichert wird immer UTC. VOR den ersten Trades festlegen " +
"und danach nicht mehr ändern ein Wechsel verschiebt rückwirkend alle Tagesgrenzen. " +
"Leer = Zeitzone des Systems (nicht empfohlen). Wirkt erst nach einem Neustart.")]
public string ApplicationTimeZoneId { get; set; } = "Europe/Berlin";
[Category("Trading")] [Category("Trading")]
[DisplayName("Trading aktiv")] [DisplayName("Trading aktiv")]
[Description("Globaler Hauptschalter. Nur wenn aktiv werden Orders ausgeführt.")] [Description("Globaler Hauptschalter. Nur wenn aktiv werden Orders ausgeführt.")]
@@ -290,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
@@ -338,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();
} }
@@ -1,3 +1,4 @@
using IBKRTrader.Core.Configuration;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@@ -10,7 +11,7 @@ namespace IBKRTrader.Core.Settings;
public class SettingsService public class SettingsService
{ {
private static readonly string SettingsPath = private static readonly string SettingsPath =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json"); AppPaths.ConfigFile("settings.json");
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
+95
View File
@@ -0,0 +1,95 @@
namespace IBKRTrader.Core.Time;
/// <summary>
/// Betriebszeitzone dieser Instanz. Wird beim Start EINMAL aus den Einstellungen
/// (<c>Trading.ApplicationTimeZoneId</c>) gesetzt und danach überall verwendet, wo aus einem
/// UTC-Zeitstempel eine Ortszeit wird: Anzeige, Logdatei-Tagesgrenzen, Berichtszeitpunkte,
/// Buchungsperioden.
///
/// <para><b>Warum das nötig ist:</b> Wir betreiben Instanzen in <b>zwei</b> Regionen (EU und US).
/// Vorher hing die Ortszeit an der Zeitzone des Rechners (<c>DateTime.Now</c>,
/// <c>DateTimeKind.Local</c>). Derselbe Code hätte auf einem Windows-Desktop mit
/// <c>Europe/Berlin</c> und in einem Linux-Container mit <c>UTC</c> lautlos unterschiedliche
/// Werte geliefert ohne Fehler, nur um Stunden verschoben. Jetzt ist die Zeitzone eine
/// ausdrückliche Einstellung und nicht mehr eine Eigenschaft des Hosts.</para>
///
/// <para><b>Nicht im Betrieb wechseln.</b> Die Zeitzone wird vor den ersten Trades einer Instanz
/// festgelegt und bleibt danach unverändert: ein Wechsel verschiebt rückwirkend Tagesgrenzen von
/// Logs, Berichten und Buchungsperioden. Eine EU-Instanz bleibt EU, eine US-Instanz bleibt US.
/// Änderungen greifen erst nach einem Neustart.</para>
///
/// <para><b>Persistenz bleibt UTC.</b> Diese Klasse ändert nichts daran, dass alle Zeitstempel in
/// der Datenbank UTC sind nur so bleiben die Daten beider Instanzen vergleichbar. Sie rechnet
/// ausschließlich für die Darstellung und für Zeitpläne um.</para>
/// </summary>
public static class AppTimeZone
{
/// <summary>Empfehlung für neue EU-Installationen (IANA-Schreibweise, trägt auf beiden Plattformen).</summary>
public const string DefaultEuId = "Europe/Berlin";
/// <summary>Empfehlung für neue US-Installationen die Zeitzone der US-Börsen.</summary>
public const string DefaultUsId = "America/New_York";
/// <summary>Aktuelle Betriebszeitzone. Vor <see cref="Configure"/> die des Systems.</summary>
public static TimeZoneInfo Current { get; private set; } = TimeZoneInfo.Local;
/// <summary>Die tatsächlich verwendete ID (kann abweichen, wenn ausgewichen werden musste).</summary>
public static string CurrentId => Current.Id;
/// <summary>
/// Setzt die Betriebszeitzone. Leere Angabe = Systemzeitzone. Meldet über
/// <paramref name="warn"/>, wenn auf etwas anderes als das Gewünschte ausgewichen wurde.
/// </summary>
public static void Configure(string? timeZoneId, Action<string>? warn = null)
{
if (string.IsNullOrWhiteSpace(timeZoneId))
{
Current = TimeZoneInfo.Local;
warn?.Invoke($"Keine Betriebszeitzone konfiguriert es gilt die des Systems " +
$"(\"{TimeZoneInfo.Local.Id}\"). Für einen planbaren Betrieb sollte " +
$"Trading.ApplicationTimeZoneId gesetzt sein, z. B. \"{DefaultEuId}\" oder \"{DefaultUsId}\".");
return;
}
var id = timeZoneId.Trim();
if (TryFind(id, out var tz)) { Current = tz!; return; }
// Andere Schreibweise versuchen: dieselbe Konfiguration soll unter Windows und Linux tragen.
if (TimeZoneInfo.TryConvertIanaIdToWindowsId(id, out var windowsId) && TryFind(windowsId!, out tz))
{
Current = tz!;
warn?.Invoke($"Zeitzone \"{id}\" wurde als \"{tz!.Id}\" aufgelöst.");
return;
}
if (TimeZoneInfo.TryConvertWindowsIdToIanaId(id, out var ianaId) && TryFind(ianaId!, out tz))
{
Current = tz!;
warn?.Invoke($"Zeitzone \"{id}\" wurde als \"{tz!.Id}\" aufgelöst.");
return;
}
Current = TimeZoneInfo.Local;
warn?.Invoke($"Zeitzone \"{id}\" ist auf diesem System unbekannt es gilt die Systemzeitzone " +
$"(\"{TimeZoneInfo.Local.Id}\"). Empfohlen ist die IANA-Schreibweise, z. B. \"{DefaultEuId}\".");
}
/// <summary>Nur für Tests: auf die Systemzeitzone zurücksetzen.</summary>
internal static void Reset() => Current = TimeZoneInfo.Local;
/// <summary>Rechnet einen UTC-Zeitstempel in die Betriebszeitzone um (für Anzeige/Tagesgrenzen).</summary>
public static DateTime ToDisplay(DateTime value) =>
value.Kind == DateTimeKind.Utc
? TimeZoneInfo.ConvertTimeFromUtc(value, Current)
: TimeZoneInfo.ConvertTime(value, Current);
/// <summary>Aktuelle Ortszeit in der Betriebszeitzone der Ersatz für <c>DateTime.Now</c>.</summary>
public static DateTime Now => TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Current);
private static bool TryFind(string id, out TimeZoneInfo? tz)
{
try { tz = TimeZoneInfo.FindSystemTimeZoneById(id); return true; }
catch (TimeZoneNotFoundException) { tz = null; return false; }
catch (InvalidTimeZoneException) { tz = null; return false; }
}
}

Some files were not shown because too many files have changed in this diff Show More