Author SHA1 Message Date
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
103 changed files with 5066 additions and 2379 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
-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}";
}
}
+18 -1
View File
@@ -20,8 +20,25 @@
<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" />
-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>
+21 -3
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).
@@ -25,10 +32,21 @@ tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory)
```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).
-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/konzepte/KONZEPT-Linux-Portierung.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
+40 -9
View File
@@ -12,14 +12,25 @@ nur dass statt Polymarket über IBKR gehandelt wird.
## 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: [konzepte/KONZEPT-Linux-Portierung.md](konzepte/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 +43,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 +56,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.
@@ -132,6 +144,25 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin
- [ ] **Offen:** `PlaceOrderAsync` mit echter Ausführung verifizieren (Fill → Buchung); asynchrone Fill-Verfolgung (Orders ohne sofortige Ausführung) - [ ] **Offen:** `PlaceOrderAsync` mit echter Ausführung verifizieren (Fill → Buchung); asynchrone Fill-Verfolgung (Orders ohne sofortige Ausführung)
- [ ] IBKR-Account-Credentials mit `EncryptedStringConverter` speichern - [ ] IBKR-Account-Credentials mit `EncryptedStringConverter` speichern
### L0L5 Linux-Portierung: Avalonia statt WinForms ✅ (2026-08-07)
Analyse und Begründung: [konzepte/KONZEPT-Linux-Portierung.md](konzepte/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:** 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).
--- ---
## Kurskorrektur abgeschlossen (R1R7) ## Kurskorrektur abgeschlossen (R1R7)
+704
View File
@@ -0,0 +1,704 @@
# 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 -1
View File
@@ -56,7 +56,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).
+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();
}
}
+56
View File
@@ -0,0 +1,56 @@
<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>
<!-- Die Symbole der bisherigen Oberfläche unverändert weiterverwenden (siehe Shell/ViewIcons.cs). -->
<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: 1.0.0 | .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"
}
}
}
@@ -0,0 +1,95 @@
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 die FHS-Konvention: <c>/etc/ibkrtrader</c>, <c>/var/lib/ibkrtrader</c>,
/// <c>/var/log/ibkrtrader</c>.</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}"));
private static readonly Lazy<string> _logs = new(() => Resolve("IBKRTRADER_LOG_DIR", $"/var/log/{AppFolder}", "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, 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>
private static string Resolve(string envVar, string fhsFallback, string? localSubDir = 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);
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;
}
}
@@ -244,7 +244,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);
+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,
+53 -50
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 if (e.Exception != null)
{ text += $"{Environment.NewLine} {e.Exception.Message}";
var color = e.Level switch return text;
{
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)
text += $"\r\n {e.Exception.Message}";
text += "\r\n";
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);
}
}
}
@@ -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.")]
@@ -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; }
}
}
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Globalization; using System.Globalization;
using IBApi; using IBApi;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Time;
namespace IBKRTrader.Core.Trading.Ibkr; namespace IBKRTrader.Core.Trading.Ibkr;
@@ -458,7 +459,7 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
slot.Items.Add(new BrokerExecution slot.Items.Add(new BrokerExecution
{ {
ExecId = execution.ExecId, ExecId = execution.ExecId,
Time = IbkrMapping.ParseExecutionTime(execution.Time) ?? DateTime.MinValue, Time = IbkrMapping.ParseExecutionTime(execution.Time, AppTimeZone.Current) ?? DateTime.MinValue,
Symbol = contract.Symbol, Symbol = contract.Symbol,
SecType = contract.SecType, SecType = contract.SecType,
Side = IbkrMapping.ParseSide(execution.Side), Side = IbkrMapping.ParseSide(execution.Side),
@@ -94,21 +94,64 @@ internal static class IbkrMapping
side.Trim().ToUpperInvariant() is "SLD" or "SELL" ? TradeSide.Sell : TradeSide.Buy; side.Trim().ToUpperInvariant() is "SLD" or "SELL" ? TradeSide.Sell : TradeSide.Buy;
/// <summary> /// <summary>
/// Zeitstempel einer Ausführung. TWS liefert je nach Aufruf "yyyyMMdd HH:mm:ss" (mit doppeltem /// Zeitstempel einer Ausführung, <b>immer als UTC</b> (<see cref="DateTimeKind.Utc"/>).
/// Leerzeichen) oder zusätzlich eine Zeitzone ("20260804 17:52:56 Europe/Berlin"). Die Zeitzone ///
/// wird verworfen der Wert bleibt Ortszeit der Börse, wie ihn TWS meldet. /// <para>TWS liefert je nach Aufruf "20260804 17:39:18" (doppeltes Leerzeichen, ohne Zone) oder
/// "20260804 17:52:56 Europe/Berlin" (mit IANA-Zone) beide Formen sind gegen das Paper-Gateway
/// gemessen. Ist eine Zone angegeben, wird gegen sie nach UTC gerechnet; sonst gilt
/// <paramref name="fallbackZone"/>, also die Betriebszeitzone der Instanz.</para>
///
/// <para><b>Warum nicht mehr „Zone verwerfen":</b> wir betreiben Instanzen in EU und US. Würde
/// die gemeldete Zone weggeworfen, bekäme eine Ausführung an der NYSE denselben nackten
/// Zeitwert wie eine an der Eurex und läge in den Büchern um Stunden daneben, ohne dass
/// irgendwo ein Fehler auftaucht.</para>
/// </summary> /// </summary>
public static DateTime? ParseExecutionTime(string? raw) /// <param name="fallbackZone">
/// Zeitzone für Meldungen ohne Zonenangabe die Betriebszeitzone (<c>AppTimeZone.Current</c>).
/// </param>
public static DateTime? ParseExecutionTime(string? raw, TimeZoneInfo fallbackZone)
{ {
if (string.IsNullOrWhiteSpace(raw)) return null; if (string.IsNullOrWhiteSpace(raw)) return null;
var parts = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); // Datum und Uhrzeit trennen TWS je nach Aufruf per Leerzeichen oder Bindestrich
// ("20260804-17:52:56" ist das Format, das auch der Anfragefilter nutzt).
var parts = raw.Replace('-', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2) return null; if (parts.Length < 2) return null;
return DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss", if (!DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) CultureInfo.InvariantCulture, DateTimeStyles.None, out var local))
? parsed return null;
: null;
// Dritter Teil, falls vorhanden, ist die Zeitzone der Börse.
var zone = parts.Length >= 3 ? ResolveZone(parts[2]) ?? fallbackZone : fallbackZone;
local = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
// Bei der Zeitumstellung kann die Ortszeit ungültig (Vorstellen) oder doppelt (Zurückstellen)
// sein. ConvertTimeToUtc würde bei ungültigen Werten werfen eine Ausführung darf daran
// nicht verlorengehen, deshalb der ausdrückliche Versatz.
if (zone.IsInvalidTime(local))
return DateTime.SpecifyKind(local - zone.BaseUtcOffset, DateTimeKind.Utc);
return TimeZoneInfo.ConvertTimeToUtc(local, zone);
}
/// <summary>
/// Löst die von TWS gemeldete Zonenangabe auf. TWS liefert IANA-Schreibweise
/// ("Europe/Berlin", "US/Eastern"); unter Windows braucht es dafür die Umrechnung.
/// Unbekannte Angabe = <c>null</c>, damit der Aufrufer auf die Betriebszeitzone ausweichen kann.
/// </summary>
private static TimeZoneInfo? ResolveZone(string id)
{
try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
catch (TimeZoneNotFoundException) { }
catch (InvalidTimeZoneException) { }
if (TimeZoneInfo.TryConvertIanaIdToWindowsId(id, out var windowsId))
try { return TimeZoneInfo.FindSystemTimeZoneById(windowsId!); } catch { /* unbekannt */ }
return null;
} }
/// <summary> /// <summary>
@@ -111,6 +111,11 @@ public sealed record BrokerExecution
{ {
/// <summary>Eindeutige IBKR-Ausführungs-ID geeignet als Idempotenzschlüssel beim Import.</summary> /// <summary>Eindeutige IBKR-Ausführungs-ID geeignet als Idempotenzschlüssel beim Import.</summary>
public required string ExecId { get; init; } public required string ExecId { get; init; }
/// <summary>
/// Ausführungszeit in <b>UTC</b>. TWS meldet Börsen-Ortszeit (teils mit Zonenangabe); die
/// Umrechnung erfolgt in <c>IbkrMapping.ParseExecutionTime</c>. UTC ist Pflicht, weil wir
/// Instanzen in EU und US betreiben und die Daten vergleichbar bleiben müssen.
/// </summary>
public required DateTime Time { get; init; } public required DateTime Time { get; init; }
public required string Symbol { get; init; } public required string Symbol { get; init; }
public required string SecType { get; init; } public required string SecType { get; init; }
@@ -1,3 +1,6 @@
using IBKRTrader.Core.Configuration;
using System.Globalization;
using IBKRTrader.Core.Time;
using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
@@ -7,9 +10,9 @@ namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary> /// <summary>
/// BackupWorker (Core) läuft alle 30 Minuten (konfigurierbar). /// BackupWorker (Core) läuft alle 30 Minuten (konfigurierbar).
/// 1. Ruft mysqldump.exe auf → SQL-Dump in Backups\DB\ /// 1. Ruft mariadb-dump bzw. mysqldump auf → SQL-Dump nach Backups/DB/
/// 2. Kopiert Logs\ → Backups\Logs\ /// 2. Kopiert Logs/ → Backups/Logs/
/// Falls mysqldump nicht gefunden: Warnung und Überspringen. /// Wird das Werkzeug nicht gefunden: Warnung und Überspringen, nie ein harter Fehler.
/// </summary> /// </summary>
public class BackupWorker : WorkerBase public class BackupWorker : WorkerBase
{ {
@@ -31,8 +34,8 @@ public class BackupWorker : WorkerBase
protected override async Task ExecuteAsync(CancellationToken ct) protected override async Task ExecuteAsync(CancellationToken ct)
{ {
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm"); var timestamp = AppTimeZone.Now.ToString("yyyy-MM-dd_HH-mm");
var backupRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Backups"); var backupRoot = AppPaths.DataPath("Backups");
var dbDir = Path.Combine(backupRoot, "DB"); var dbDir = Path.Combine(backupRoot, "DB");
var logsDir = Path.Combine(backupRoot, "Logs"); var logsDir = Path.Combine(backupRoot, "Logs");
@@ -52,39 +55,50 @@ public class BackupWorker : WorkerBase
private async Task RunMysqlDumpAsync(string dbDir, string timestamp, CancellationToken ct) private async Task RunMysqlDumpAsync(string dbDir, string timestamp, CancellationToken ct)
{ {
var dump = FindMysqldump(); var dump = FindDumpTool();
if (dump == null) if (dump == null)
{ {
Logger.Warn(Module, "mysqldump.exe nicht gefunden DB-Backup übersprungen."); Logger.Warn(Module, $"{string.Join(" / ", ToolNames())} nicht gefunden DB-Backup übersprungen.");
return; return;
} }
var db = _settings.Settings.Database; var db = _settings.Settings.Database;
var dumpFile = Path.Combine(dbDir, $"{db.Database}_{timestamp}.sql"); var dumpFile = Path.Combine(dbDir, $"{db.Database}_{timestamp}.sql");
var args = $"--host={db.Host} --port={db.Port} " +
$"--user={db.User} --password={db.Password} " +
$"--single-transaction --routines --triggers " +
$"{db.Database} --result-file=\"{dumpFile}\"";
var psi = new System.Diagnostics.ProcessStartInfo var psi = new System.Diagnostics.ProcessStartInfo
{ {
FileName = dump, FileName = dump,
Arguments = args,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true CreateNoWindow = true
}; };
// Argumente einzeln statt als eine Zeichenkette: das erspart das Quoting von Pfaden mit
// Leerzeichen und ist auf beiden Plattformen dasselbe.
psi.ArgumentList.Add($"--host={db.Host}");
psi.ArgumentList.Add($"--port={db.Port.ToString(CultureInfo.InvariantCulture)}");
psi.ArgumentList.Add($"--user={db.User}");
psi.ArgumentList.Add("--single-transaction");
psi.ArgumentList.Add("--routines");
psi.ArgumentList.Add("--triggers");
psi.ArgumentList.Add(db.Database);
psi.ArgumentList.Add($"--result-file={dumpFile}");
// Passwort über die Umgebung statt über die Kommandozeile. Auf Linux ist
// /proc/<pid>/cmdline für JEDEN lokalen Nutzer lesbar das Passwort hätte dort für die
// Dauer des Dumps offen im Prozessbaum gestanden. MYSQL_PWD ist der von MySQL/MariaDB
// dafür vorgesehene Weg und wird nur an diesen Kindprozess vererbt.
psi.Environment["MYSQL_PWD"] = db.Password;
using var proc = System.Diagnostics.Process.Start(psi) using var proc = System.Diagnostics.Process.Start(psi)
?? throw new InvalidOperationException("mysqldump konnte nicht gestartet werden."); ?? throw new InvalidOperationException($"{Path.GetFileName(dump)} konnte nicht gestartet werden.");
await proc.WaitForExitAsync(ct); await proc.WaitForExitAsync(ct);
if (proc.ExitCode != 0) if (proc.ExitCode != 0)
{ {
var err = await proc.StandardError.ReadToEndAsync(ct); var err = await proc.StandardError.ReadToEndAsync(ct);
Logger.Warn(Module, $"mysqldump Exitcode {proc.ExitCode}: {err}"); Logger.Warn(Module, $"{Path.GetFileName(dump)} Exitcode {proc.ExitCode}: {err}");
} }
else else
{ {
@@ -92,27 +106,57 @@ public class BackupWorker : WorkerBase
} }
} }
private static string? FindMysqldump() /// <summary>
/// Namen des Dump-Werkzeugs. MariaDB hat <c>mysqldump</c> ab 10.5 in <c>mariadb-dump</c>
/// umbenannt und pflegt den alten Namen nur noch als Alias auf neueren Distributionen ist
/// oft nur der neue vorhanden. Die Dateiendung gibt es nur unter Windows.
/// </summary>
private static string[] ToolNames() =>
OperatingSystem.IsWindows()
? ["mariadb-dump.exe", "mysqldump.exe"]
: ["mariadb-dump", "mysqldump"];
/// <summary>
/// Sucht das Dump-Werkzeug: erst die üblichen Installationspfade der jeweiligen Plattform,
/// dann PATH. Findet es nichts, wird das Backup übersprungen (nie ein harter Fehler).
/// </summary>
private static string? FindDumpTool()
{ {
// Häufige Installationspfade auf Windows-Server var names = ToolNames();
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 c in candidates) // Übliche Installationspfade plattformabhängig.
if (File.Exists(c)) return c; string[] dirs = OperatingSystem.IsWindows()
?
[
@"C:\Program Files\MariaDB\bin",
@"C:\Program Files\MySQL\MySQL Server 8.0\bin",
@"C:\Program Files\MySQL\MySQL Server 8.4\bin",
@"C:\xampp\mysql\bin",
]
:
[
"/usr/bin",
"/usr/local/bin",
"/opt/mariadb/bin",
"/opt/homebrew/bin",
];
// PATH-Suche foreach (var name in names)
foreach (var dir in dirs)
{
var full = Path.Combine(dir, name);
if (File.Exists(full)) return full;
}
// PATH-Suche. Das Trennzeichen ist plattformabhängig: ';' unter Windows, ':' unter Linux
// ein fest verdrahtetes ';' hätte auf Linux den gesamten PATH als einen Eintrag gelesen.
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? ""; var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in pathVar.Split(';')) foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{ foreach (var name in names)
var full = Path.Combine(dir.Trim(), "mysqldump.exe"); {
if (File.Exists(full)) return full; var full = Path.Combine(dir.Trim(), name);
} if (File.Exists(full)) return full;
}
return null; return null;
} }
@@ -123,7 +167,7 @@ public class BackupWorker : WorkerBase
{ {
try try
{ {
var srcLogs = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); var srcLogs = AppPaths.Logs;
if (!Directory.Exists(srcLogs)) return; if (!Directory.Exists(srcLogs)) return;
var destDir = Path.Combine(logsBackupDir, timestamp); var destDir = Path.Combine(logsBackupDir, timestamp);
+1 -1
View File
@@ -16,7 +16,7 @@ public interface IWorker
/// <summary>Worker = periodisch / Service = dauerhaft.</summary> /// <summary>Worker = periodisch / Service = dauerhaft.</summary>
WorkerType Type { get; } WorkerType Type { get; }
/// <summary>Live-Daten für die DataGridView-Zeile.</summary> /// <summary>Live-Daten für die Zeile in der Workers-Ansicht.</summary>
WorkerInfo Info { get; } WorkerInfo Info { get; }
/// <summary>Löst einen sofortigen, manuellen Run aus (unabhängig vom Zeitplan).</summary> /// <summary>Löst einen sofortigen, manuellen Run aus (unabhängig vom Zeitplan).</summary>
+5 -3
View File
@@ -1,3 +1,4 @@
using IBKRTrader.Core.Time;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities; using IBKRTrader.Core.Persistence.Entities;
@@ -111,11 +112,12 @@ public abstract class WorkerBase : IWorker, IHostedService
if (Interval == null) break; // Service: nur einmal if (Interval == null) break; // Service: nur einmal
var next = DateTime.Now.Add(Interval.Value); // Anzeige-/Zeitplanwerte in der Betriebszeitzone, damit die UI dasselbe zeigt wie die Logs.
var next = AppTimeZone.Now.Add(Interval.Value);
Info.NextRuntime = next; Info.NextRuntime = next;
// Warte auf Interval ODER manuellen Trigger // Warte auf Interval ODER manuellen Trigger
var remaining = next - DateTime.Now; var remaining = next - AppTimeZone.Now;
if (remaining > TimeSpan.Zero) if (remaining > TimeSpan.Zero)
{ {
try try
@@ -143,7 +145,7 @@ public abstract class WorkerBase : IWorker, IHostedService
await ExecuteAsync(ct); await ExecuteAsync(ct);
await EndRunLogAsync(logId, true); await EndRunLogAsync(logId, true);
Info.LastRuntime = DateTime.Now; Info.LastRuntime = AppTimeZone.Now;
Info.Status = WorkerStatus.Idle; Info.Status = WorkerStatus.Idle;
Info.Info = $"OK {Info.LastRuntime:HH:mm:ss}"; Info.Info = $"OK {Info.LastRuntime:HH:mm:ss}";
} }
+5 -1
View File
@@ -13,7 +13,11 @@ public class WorkerEngine
{ {
private readonly ConcurrentDictionary<string, IWorker> _registry = new(); private readonly ConcurrentDictionary<string, IWorker> _registry = new();
/// <summary>Live-bindbare Liste für die Workers-Ansicht (DataGridView).</summary> /// <summary>
/// Live-bindbare Liste für die Workers-Ansicht. Die Liste selbst ändert sich zur Laufzeit
/// nicht die Worker werden hier einmal registriert; was sich ändert, meldet
/// <see cref="WorkerInfo"/> über INotifyPropertyChanged.
/// </summary>
public BindingList<WorkerInfo> WorkerInfos { get; } = []; public BindingList<WorkerInfo> WorkerInfos { get; } = [];
public WorkerEngine(IEnumerable<IWorker> workers, LoggingService logger) public WorkerEngine(IEnumerable<IWorker> workers, LoggingService logger)
+3 -4
View File
@@ -4,8 +4,8 @@ using System.Runtime.CompilerServices;
namespace IBKRTrader.Core.Workers; namespace IBKRTrader.Core.Workers;
/// <summary> /// <summary>
/// ViewModel-Objekt für eine Zeile in dgv_workerlist. /// ViewModel-Objekt für eine Zeile der Workers-Ansicht.
/// Implementiert INotifyPropertyChanged für automatisches DataGridView-Binding. /// Implementiert INotifyPropertyChanged, damit die Oberfläche Änderungen ohne Zutun übernimmt.
/// </summary> /// </summary>
public class WorkerInfo : INotifyPropertyChanged public class WorkerInfo : INotifyPropertyChanged
{ {
@@ -32,8 +32,7 @@ public class WorkerInfo : INotifyPropertyChanged
public string RunEvery { get => _runEvery; set => Set(ref _runEvery, value); } public string RunEvery { get => _runEvery; set => Set(ref _runEvery, value); }
public string Info { get => _info; set => Set(ref _info, value); } public string Info { get => _info; set => Set(ref _info, value); }
/// <summary>Interner Status wird nicht direkt als DGV-Spalte verwendet, /// <summary>Interner Status keine eigene Spalte, steuert aber den Info-Text.</summary>
/// aber steuert die Info-Spalte.</summary>
public WorkerStatus Status public WorkerStatus Status
{ {
get => _status; get => _status;
+29
View File
@@ -0,0 +1,29 @@
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Daemon;
/// <summary>
/// Diagnose: öffnet die Datenbank aus <c>settings.json</c> und gibt die Serverversion aus.
/// Gebraucht für das EF-<c>ServerVersion</c>-Pinning; läuft ohne Host und ohne Oberfläche.
/// </summary>
internal static class DbVersion
{
public static int Print()
{
var settings = new SettingsService();
settings.Load();
try
{
using var c = new MySqlConnector.MySqlConnection(settings.Settings.Database.BuildConnectionString());
c.Open();
Console.WriteLine($"ServerVersion: {c.ServerVersion}");
return 0;
}
catch (Exception ex)
{
Console.WriteLine($"FEHLER: {ex.GetType().Name}: {ex.Message}");
return 1;
}
}
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Kopfloser Betrieb: Trading-Kern, Worker, Accounting, Supervisor, REST und MCP ohne
Oberfläche. Das ist der Einstiegspunkt für den Linux-Dienst (systemd). -->
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>IBKRTrader.Daemon</AssemblyName>
<RootNamespace>IBKRTrader.Daemon</RootNamespace>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<!-- BEWUSST NICHT InvariantGlobalization: das Image braucht ICU (libicu / icu-data-full).
Zwei Dinge hängen daran und würden sonst lautlos falsch werden:
1. AppTimeZone löst Windows-Zeitzonen-IDs über TimeZoneInfo.TryConvertWindowsIdToIanaId
auf. Ohne ICU schlägt das fehl eine Instanz mit einer Altkonfiguration
("W. Europe Standard Time") würde auf die Systemzeitzone zurückfallen, also je nach
Container auf UTC. Genau die Verschiebung, die wir in L1b 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" würde
wieder "1,234.56" in einem Dokument, das als prüfbare Aufstellung gilt. -->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IBKRTrader.Hosting\IBKRTrader.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+79
View File
@@ -0,0 +1,79 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace IBKRTrader.Daemon;
/// <summary>
/// Kopfloser Einstiegspunkt: Trading-Kern, Worker, Accounting, Supervisor, REST-API und MCP-Light
/// laufen ohne Oberfläche. Das ist die Betriebsform auf Linux (systemd).
///
/// <para>Der Host kommt unverändert aus <see cref="AppHostBuilder"/> dieselbe Zusammenstellung,
/// die auch die Desktop-Shell startet. Hier fehlt lediglich alles, was ein Fenster braucht.</para>
/// </summary>
internal static class Program
{
private static async Task<int> Main(string[] args)
{
// Diagnose: Serverversion ausgeben (für das EF-ServerVersion-Pinning). Kein Host nötig.
if (HasFlag(args, "--db-version"))
return DbVersion.Print();
// Trockenlauf: Host bauen, Startprüfungen fahren, Dienste NICHT starten. Für Deployment
// und CI prüft Konfiguration, DB-Verbindungszeichenfolge, Master-Key und Ablageorte,
// ohne eine einzige Verbindung zur Börse aufzubauen.
var checkOnly = HasFlag(args, "--check");
var modules = AppHostBuilder.CreateModules();
using var host = AppHostBuilder.Build(modules);
var logger = host.Services.GetRequiredService<LoggingService>();
AppHostBuilder.RunStartupChecks(host.Services);
if (checkOnly)
{
logger.Info("Core", "Prüflauf (--check) erfolgreich: Host baubar, Startprüfungen bestanden.");
Console.WriteLine("=== Prüflauf OK ===");
return 0;
}
logger.Info("Core", "=== IBKRTrader (kopflos) startet ===");
logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version} | {RuntimeDescription()}");
await host.StartAsync().ConfigureAwait(false);
// Module nach dem Host starten so laufen sie nie gegen einen leeren Zustand an.
foreach (var module in modules)
{
try { await module.StartAsync(default).ConfigureAwait(false); }
catch (Exception ex) { logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
}
logger.Info("Core", "IBKRTrader bereit. Beenden mit Strg+C bzw. SIGTERM.");
// WaitForShutdown behandelt SIGTERM und SIGINT genau das, was systemd beim Stoppen sendet.
await host.WaitForShutdownAsync().ConfigureAwait(false);
await StopModulesAsync(modules, logger).ConfigureAwait(false);
logger.Info("Core", "IBKRTrader beendet.");
return 0;
}
private static async Task StopModulesAsync(IReadOnlyList<IModule> modules, LoggingService logger)
{
foreach (var module in modules)
{
try { await module.StopAsync(default).ConfigureAwait(false); }
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));
private static string RuntimeDescription() =>
$"{System.Runtime.InteropServices.RuntimeInformation.OSDescription.Trim()} " +
$"({System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture})";
}
+11
View File
@@ -0,0 +1,11 @@
{
"Database": {
"MySqlConnectionString": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Warning"
}
}
}
+252
View File
@@ -0,0 +1,252 @@
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.Time;
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 Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace IBKRTrader.Hosting;
/// <summary>
/// Baut den Anwendungs-Host: Konfiguration, Persistenz, Core-Dienste, Worker und Module
/// <b>ohne jeden Bezug zur Oberfläche</b>.
///
/// <para>Bewusst ein eigenes Projekt: derselbe Host trägt den kopflosen Linux-Dienst
/// (<c>IBKRTrader.Daemon</c>) und die Desktop-Shell. Läge die Zusammenstellung wie bisher in der
/// WinForms-<c>Program.cs</c>, müsste sie für den Daemon dupliziert werden und beide würden mit
/// der Zeit auseinanderlaufen. Ein Modul, das nur in einer Variante registriert ist, wäre ein
/// Fehler, den man erst im Betrieb bemerkt.</para>
/// </summary>
public static class AppHostBuilder
{
/// <summary>Die in dieser Ausbaustufe aktiven Module.</summary>
public static IReadOnlyList<IModule> CreateModules() =>
[
new CongressTradingModule(),
new AccountingModule(),
new SupervisorModule()
];
/// <summary>
/// Stellt den Host zusammen. <paramref name="configureExtra"/> erlaubt der jeweiligen Shell,
/// ihre eigenen Dienste (Fensterverwaltung, Launcher) zu ergänzen der Daemon übergibt nichts.
/// </summary>
public static IHost Build(IReadOnlyList<IModule> modules,
Action<IServiceCollection, IConfiguration>? configureExtra = null) =>
Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
// Meldet systemd die Betriebsbereitschaft (sd_notify) und behandelt SIGTERM sauber.
// Ohne das würde eine Unit mit Type=notify beim Start bis zum Timeout hängen.
// Läuft der Prozess nicht unter systemd, ist der Aufruf wirkungslos die
// Windows-Shell nutzt denselben Host-Aufbau und merkt davon nichts.
.UseSystemd()
.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);
}
configureExtra?.Invoke(services, context.Configuration);
})
.Build();
/// <summary>
/// Die Startschritte, die vor dem Anlaufen der Dienste passieren müssen in dieser Reihenfolge.
/// Gemeinsam für Daemon und Shell, damit keine Variante einen davon vergisst.
/// </summary>
public static void RunStartupChecks(IServiceProvider services)
{
var logger = services.GetRequiredService<LoggingService>();
// 1. Betriebszeitzone VOR dem ersten Logeintrag sie bestimmt die Tagesgrenzen der Logdateien.
ConfigureAppTimeZone(services, logger);
// 2. Ablageorte melden. Auf Linux können sie je nach Installation vom Binärverzeichnis
// abweichen (FHS) im Betrieb muss sichtbar sein, wohin geschrieben wird.
logger.Info("Core", $"Ablage: {AppPaths.Describe()}");
// 3. Master-Key VOR jeder Entschlüsselung.
ConfigureSecretProtection(logger);
WarnIfKeyFileIsWorldReadable(logger);
// 4. Transportverschlüsselung der DB prüfen.
WarnIfDbTlsNotEnforced(services, logger);
// 5. Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine (vor dem Start).
services.GetRequiredService<WebApiService>()
.SetEngine(services.GetRequiredService<WorkerEngine>());
}
/// <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>.
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>
/// Setzt die Betriebszeitzone dieser Instanz aus den Einstellungen. Sie gilt für Anzeige,
/// Logdatei-Tagesgrenzen, Berichtszeiten und Buchungsperioden; gespeichert wird weiterhin UTC.
/// Wir betreiben Instanzen in EU und US ohne diese Festlegung hinge die Ortszeit an der
/// Zeitzone des Rechners, auf einem UTC-Container also woanders als auf dem Desktop.
/// </summary>
private static void ConfigureAppTimeZone(IServiceProvider services, LoggingService logger)
{
var configured = services.GetRequiredService<SettingsService>().Settings.Trading.ApplicationTimeZoneId;
AppTimeZone.Configure(configured, msg => logger.Warn("Core", msg));
logger.Info("Core", $"Betriebszeitzone: {AppTimeZone.CurrentId} (Persistenz bleibt UTC).");
}
/// <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 Anwendung 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 = AppPaths.ConfigFile("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 Schlüsseldateien für andere Nutzer lesbar sind.
///
/// <para>Nur auf Unix sinnvoll: Windows-ACLs übertragen sich nicht beim Kopieren einer Datei
/// auf einen Linux-Host, und eine <c>master.key</c> mit Standardrechten (644) ist dort für
/// jeden lokalen Nutzer lesbar. Nur eine Warnung die Rechte gehören dem Betreiber.</para>
/// </summary>
private static void WarnIfKeyFileIsWorldReadable(LoggingService logger)
{
if (OperatingSystem.IsWindows()) return;
foreach (var name in new[] { "master.key", "openrouter.key" })
{
var path = AppPaths.ConfigFile(name);
if (!File.Exists(path)) continue;
try
{
var mode = File.GetUnixFileMode(path);
const UnixFileMode others = UnixFileMode.GroupRead | UnixFileMode.GroupWrite
| UnixFileMode.OtherRead | UnixFileMode.OtherWrite;
if ((mode & others) != 0)
logger.Warn("Core", $"⚠️ SICHERHEIT: {name} ist auch für Gruppe/andere zugänglich " +
$"({mode}). Empfohlen: chmod 600 \"{path}\".");
}
catch (Exception ex)
{
logger.Warn("Core", $"Rechte von {name} nicht prüfbar: {ex.Message}");
}
}
}
/// <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 */ }
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Plattformneutral: enthält den kompletten Anwendungs-Host OHNE Oberfläche. Wird sowohl vom
kopflosen Linux-Daemon als auch von den Desktop-Shells verwendet, damit beide dieselbe
Zusammenstellung starten und nicht auseinanderlaufen. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<!-- systemd-Integration: sd_notify (Type=notify) und journald-taugliches Logformat.
Ausserhalb von systemd ein No-Op, stoert die Windows-Shell also nicht. -->
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IBKRTrader.Core\IBKRTrader.Core.csproj" />
<ProjectReference Include="..\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
<ProjectReference Include="..\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
<ProjectReference Include="..\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>IBKRTrader.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -4,7 +4,6 @@ using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Modularity;
using IBKRTrader.Modules.Accounting.Persistence; using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services; using IBKRTrader.Modules.Accounting.Services;
using IBKRTrader.Modules.Accounting.Ui;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -47,22 +46,12 @@ public sealed class AccountingModule : IModule
services.AddHostedService(sp => sp.GetRequiredService<AccountingIngestService>()); services.AddHostedService(sp => sp.GetRequiredService<AccountingIngestService>());
} }
public void RegisterUi(IModuleUiHost host, IServiceProvider services) /// <summary>
{ /// Bewusst leer: das Modulprojekt trägt keinen UI-Code mehr, damit es plattformneutral bleibt
host.RegisterView(new ModuleView /// (kopfloser Linux-Betrieb). Das Accounting-Fenster registriert die Shell zentral in
{ /// <c>UI/ModuleViews.cs</c>; die Dienste dafür kommen aus dem DI-Container.
Id = "accounting.main", /// </summary>
Title = "Accounting", public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
Group = Name,
Order = 400,
CreateForm = () => new AccountingMainForm(
services.GetRequiredService<ILedgerRepository>(),
services.GetRequiredService<IIngestRunRepository>(),
services.GetRequiredService<AccountingReportService>(),
services.GetRequiredService<AccountingIngestService>(),
services.GetRequiredService<LoggingService>())
});
}
// DB-Schema wird extern per `dotnet ef database update` angewendet (keine Laufzeit-Migration). // DB-Schema wird extern per `dotnet ef database update` angewendet (keine Laufzeit-Migration).
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework> <!-- Plattformneutral: das Modul traegt keinen UI-Code mehr (Fenster liegt in der Shell,
siehe UI/ModuleViews.cs) und laeuft damit auch im kopflosen Linux-Betrieb. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- Modul trägt eigene WinForms-UI (Modul-Fenster) bei. -->
<UseWindowsForms>true</UseWindowsForms>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
@@ -0,0 +1,116 @@
using System.Collections.Concurrent;
using PdfSharp.Fonts;
namespace IBKRTrader.Modules.Accounting.Logic;
/// <summary>
/// Schriftauflösung für den PDF-Export.
///
/// <para><b>Warum das nötig ist:</b> Der Export gab MigraDoc bisher „Segoe UI" vor eine
/// Windows-Schrift. PDFsharp 6 löst Schriften auf Nicht-Windows-Plattformen nicht von selbst auf;
/// ohne einen eigenen <see cref="IFontResolver"/> scheitert der Export auf Linux zur Laufzeit.</para>
///
/// <para><b>Vorgehen:</b> Unter Windows übernimmt weiterhin die Plattform (unveränderte Optik)
/// wir geben <c>null</c> zurück und setzen <see cref="GlobalFontSettings.UseWindowsFontsUnderWindows"/>.
/// Auf Linux wird eine der üblichen freien Schriften aus dem System genommen. Bewusst <b>keine</b>
/// Schrift im Repository: das erspart uns eine Lizenzfrage und hält das Paket klein die
/// Abhängigkeit ist dafür ausdrücklich dokumentiert und die Fehlermeldung nennt das Paket.</para>
/// </summary>
public sealed class DocumentFontResolver : IFontResolver
{
/// <summary>Logischer Familienname, den der Export verwendet.</summary>
public const string FamilyName = "IBKRTrader Sans";
/// <summary>Windows-Entsprechung hält die Optik der bisherigen Exporte.</summary>
private const string WindowsFamily = "Segoe UI";
/// <summary>
/// Freie Sans-Serif-Schriften in Vorzugsreihenfolge, je (regular, bold). DejaVu ist auf
/// praktisch jeder Distribution verfügbar, Liberation und Noto sind die üblichen Alternativen.
/// </summary>
private static readonly (string Regular, string Bold)[] Candidates =
[
("DejaVuSans.ttf", "DejaVuSans-Bold.ttf"),
("LiberationSans-Regular.ttf", "LiberationSans-Bold.ttf"),
("NotoSans-Regular.ttf", "NotoSans-Bold.ttf"),
("FreeSans.ttf", "FreeSansBold.ttf"),
];
private static readonly string[] SearchDirs =
[
"/usr/share/fonts",
"/usr/local/share/fonts",
"/run/host/usr/share/fonts", // Flatpak/Toolbox
];
private static readonly ConcurrentDictionary<string, byte[]> Cache = new();
private static readonly Lazy<(string Regular, string Bold)?> Found = new(Locate);
/// <summary>
/// Richtet die Schriftauflösung einmalig ein. Mehrfachaufrufe sind unschädlich
/// PDFsharp lässt den Resolver nur einmal setzen.
/// </summary>
public static void EnsureConfigured()
{
if (GlobalFontSettings.FontResolver is not null) return;
GlobalFontSettings.UseWindowsFontsUnderWindows = true;
GlobalFontSettings.FontResolver = new DocumentFontResolver();
}
public FontResolverInfo? ResolveTypeface(string familyName, bool bold, bool italic)
{
// Unter Windows die Plattform machen lassen: null bedeutet „nicht zuständig".
if (OperatingSystem.IsWindows()) return null;
if (Found.Value is not { } files)
throw new InvalidOperationException(
"Für den PDF-Export wurde keine Schriftart gefunden. Auf dem System muss eine freie " +
"Sans-Serif-Schrift installiert sein unter Debian/Ubuntu z. B. per " +
"\"apt-get install fonts-dejavu-core\", unter Alpine \"apk add font-dejavu\". " +
$"Gesucht wurde in: {string.Join(", ", SearchDirs)}.");
// Kursiv wird von PDFsharp simuliert; wir liefern nur normal und fett.
var path = bold ? files.Bold : files.Regular;
return new FontResolverInfo(path, mustSimulateBold: false, mustSimulateItalic: italic);
}
public byte[]? GetFont(string faceName) =>
Cache.GetOrAdd(faceName, File.ReadAllBytes);
/// <summary>Sucht das erste vollständige Paar (regular + bold) in den üblichen Verzeichnissen.</summary>
private static (string Regular, string Bold)? Locate()
{
var existing = SearchDirs.Where(Directory.Exists).ToArray();
if (existing.Length == 0) return null;
foreach (var (regular, bold) in Candidates)
{
var regularPath = FindFile(existing, regular);
var boldPath = FindFile(existing, bold);
// Ohne Fettschnitt bleibt der Bericht lesbar dann eben beides normal.
if (regularPath is not null) return (regularPath, boldPath ?? regularPath);
}
return null;
}
private static string? FindFile(string[] dirs, string fileName)
{
foreach (var dir in dirs)
{
try
{
var hit = Directory.EnumerateFiles(dir, fileName, SearchOption.AllDirectories).FirstOrDefault();
if (hit is not null) return hit;
}
catch (UnauthorizedAccessException) { /* Verzeichnis nicht lesbar nächstes */ }
catch (DirectoryNotFoundException) { /* zwischenzeitlich weg nächstes */ }
}
return null;
}
/// <summary>Der Familienname, den das Dokument setzen soll.</summary>
public static string DocumentFamily => OperatingSystem.IsWindows() ? WindowsFamily : FamilyName;
}
@@ -1,3 +1,5 @@
using System.Globalization;
using IBKRTrader.Core.Time;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using IBKRTrader.Modules.Accounting.Models; using IBKRTrader.Modules.Accounting.Models;
@@ -15,6 +17,17 @@ namespace IBKRTrader.Modules.Accounting.Logic;
/// </summary> /// </summary>
public static class PdfExporter public static class PdfExporter
{ {
/// <summary>
/// Zahlenformat des Dokuments <b>fest deutsch</b>, nicht die Kultur des Hosts.
///
/// <para>Vorher stand hier <c>ToString("N2")</c> ohne Formatanbieter, also CurrentCulture. Auf
/// dem deutschen Windows-Desktop kam "1.234,56" heraus; in einem Linux-Container mit
/// <c>LANG=C</c> oder <c>InvariantGlobalization</c> wäre daraus "1,234.56" geworden dieselbe
/// Zahl, für einen Leser aber eine andere. Für ein Dokument, das ausdrücklich als prüfbare
/// Aufstellung gedacht ist, darf das Format nicht vom Rechner abhängen, auf dem es entsteht.</para>
/// </summary>
private static readonly CultureInfo DocumentCulture = CultureInfo.GetCultureInfo("de-DE");
public static byte[] Render( public static byte[] Render(
PeriodStatement statement, PeriodStatement statement,
IReadOnlyList<PeriodStatement> monthly, IReadOnlyList<PeriodStatement> monthly,
@@ -22,12 +35,16 @@ public static class PdfExporter
string currencyCode, decimal currencyFactor, string currencyNote) string currencyCode, decimal currencyFactor, string currencyNote)
{ {
decimal V(decimal baseAmount) => Math.Round(baseAmount * currencyFactor, 2, MidpointRounding.AwayFromZero); decimal V(decimal baseAmount) => Math.Round(baseAmount * currencyFactor, 2, MidpointRounding.AwayFromZero);
string M(decimal baseAmount) => V(baseAmount).ToString("N2") + " " + currencyCode; string M(decimal baseAmount) => V(baseAmount).ToString("N2", DocumentCulture) + " " + currencyCode;
// Schriftauflösung: auf Linux gibt es kein "Segoe UI", und PDFsharp löst dort nichts von
// selbst auf ohne das scheitert der Export zur Laufzeit. Siehe DocumentFontResolver.
DocumentFontResolver.EnsureConfigured();
var doc = new Document(); var doc = new Document();
doc.Info.Title = "Buchhalterische Abrechnung"; doc.Info.Title = "Buchhalterische Abrechnung";
var style = doc.Styles["Normal"]!; var style = doc.Styles["Normal"]!;
style.Font.Name = "Segoe UI"; style.Font.Name = DocumentFontResolver.DocumentFamily;
style.Font.Size = 9; style.Font.Size = 9;
var section = doc.AddSection(); var section = doc.AddSection();
@@ -48,7 +65,7 @@ public static class PdfExporter
meta.AddLineBreak(); meta.AddLineBreak();
meta.AddText($"Währung: {currencyCode} ({currencyNote})"); meta.AddText($"Währung: {currencyCode} ({currencyNote})");
meta.AddLineBreak(); meta.AddLineBreak();
meta.AddText($"Erstellt: {DateTime.Now:yyyy-MM-dd HH:mm}"); meta.AddText($"Erstellt: {AppTimeZone.Now:yyyy-MM-dd HH:mm}");
// ---- Aggregat ---- // ---- Aggregat ----
AddSectionTitle(section, "Zusammenfassung"); AddSectionTitle(section, "Zusammenfassung");
@@ -63,8 +80,8 @@ public static class PdfExporter
AddKeyValue(agg, "Quellensteuer", M(statement.TaxWithheld)); AddKeyValue(agg, "Quellensteuer", M(statement.TaxWithheld));
AddKeyValue(agg, "Netto-Handelsergebnis (Cash-Basis)", M(statement.NetTradingResult)); AddKeyValue(agg, "Netto-Handelsergebnis (Cash-Basis)", M(statement.NetTradingResult));
AddKeyValue(agg, "Endsaldo", M(statement.ClosingBalance)); AddKeyValue(agg, "Endsaldo", M(statement.ClosingBalance));
AddKeyValue(agg, "Anzahl Trades", statement.TradeCount.ToString()); AddKeyValue(agg, "Anzahl Trades", statement.TradeCount.ToString(DocumentCulture));
AddKeyValue(agg, "Anzahl Buchungen", statement.EntryCount.ToString()); AddKeyValue(agg, "Anzahl Buchungen", statement.EntryCount.ToString(DocumentCulture));
// ---- Monatsvergleich ---- // ---- Monatsvergleich ----
if (monthly.Count > 1) if (monthly.Count > 1)
@@ -83,7 +100,7 @@ public static class PdfExporter
HeaderRow(lt, "Zeit (UTC)", "Typ", "Side", "Symbol", "Menge", "Preis", "Netto", "Transaktion"); HeaderRow(lt, "Zeit (UTC)", "Typ", "Side", "Symbol", "Menge", "Preis", "Netto", "Transaktion");
foreach (var e in periodEntries.OrderBy(e => e.Timestamp)) foreach (var e in periodEntries.OrderBy(e => e.Timestamp))
DataRow(lt, e.Timestamp.ToString("yyyy-MM-dd HH:mm"), e.EventType.ToString(), e.Side, DataRow(lt, e.Timestamp.ToString("yyyy-MM-dd HH:mm"), e.EventType.ToString(), e.Side,
Trim(e.Symbol, 12), e.Quantity.ToString("0.###"), e.PriceNative.ToString("0.###"), Trim(e.Symbol, 12), e.Quantity.ToString("0.###", DocumentCulture), e.PriceNative.ToString("0.###", DocumentCulture),
M(e.NetBase), Trim(e.TransactionId, 22)); M(e.NetBase), Trim(e.TransactionId, 22));
// ---- Methodik / Nachweis ---- // ---- Methodik / Nachweis ----
@@ -1,302 +0,0 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Modules.Accounting.Logic;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
namespace IBKRTrader.Modules.Accounting.Ui;
/// <summary>
/// Fenster des Accounting-Moduls: Übersicht/BWA, Ledger, Steuer (Platzhalter), Abrechnung/Export,
/// Abruf/Status. Alle DB-Zugriffe laufen NUR auf Nutzer-Interaktion (nicht im Konstruktor) so
/// konstruiert der Smoke-UI-Check das Fenster auch ohne DB fehlerfrei.
/// </summary>
public sealed class AccountingMainForm : Form
{
private readonly ILedgerRepository _ledger;
private readonly IIngestRunRepository _runs;
private readonly AccountingReportService _report;
private readonly AccountingIngestService _ingest;
private readonly LoggingService _logger;
private readonly DateTimePicker _from = new() { Format = DateTimePickerFormat.Short, Width = 110 };
private readonly DateTimePicker _to = new() { Format = DateTimePickerFormat.Short, Width = 110 };
private readonly ComboBox _account = new() { DropDownStyle = ComboBoxStyle.DropDown, Width = 140 };
private readonly ComboBox _currency = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 80 };
private readonly Label _kpis = new() { AutoSize = true, Location = new Point(12, 8) };
private readonly DataGridView _monthly = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
private readonly DataGridView _ledgerGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
private readonly DataGridView _runsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
private readonly Label _status = new() { AutoSize = true, ForeColor = SystemColors.GrayText, Location = new Point(12, 8) };
public AccountingMainForm(
ILedgerRepository ledger, IIngestRunRepository runs, AccountingReportService report,
AccountingIngestService ingest, LoggingService logger)
{
_ledger = ledger;
_runs = runs;
_report = report;
_ingest = ingest;
_logger = logger;
Text = "Accounting";
Width = 1000;
Height = 680;
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(760, 480);
_from.Value = DateTime.Today.AddMonths(-1);
_to.Value = DateTime.Today;
_currency.Items.AddRange(new object[] { "USD", "EUR" });
_currency.SelectedIndex = 0;
BuildLayout();
}
private void BuildLayout()
{
var tabs = new TabControl { Dock = DockStyle.Fill };
// ── gemeinsame Filterleiste ──
var filter = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) };
filter.Controls.Add(new Label { Text = "Von", AutoSize = true, Margin = new Padding(0, 8, 4, 0) });
filter.Controls.Add(_from);
filter.Controls.Add(new Label { Text = "Bis", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
filter.Controls.Add(_to);
filter.Controls.Add(new Label { Text = "Konto", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
filter.Controls.Add(_account);
filter.Controls.Add(new Label { Text = "Währung", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
filter.Controls.Add(_currency);
var btnRefresh = new Button { Text = "Aktualisieren", Width = 120, Margin = new Padding(12, 3, 0, 0) };
btnRefresh.Click += (_, _) => RefreshAll();
filter.Controls.Add(btnRefresh);
// ── Tab: Übersicht/BWA ──
var tabOverview = new TabPage("Übersicht / BWA");
_monthly.Top = 90;
var overviewPanel = new Panel { Dock = DockStyle.Fill };
overviewPanel.Controls.Add(_monthly);
var kpiPanel = new Panel { Dock = DockStyle.Top, Height = 84 };
kpiPanel.Controls.Add(_kpis);
overviewPanel.Controls.Add(kpiPanel);
tabOverview.Controls.Add(overviewPanel);
// ── Tab: Ledger ──
var tabLedger = new TabPage("Ledger");
tabLedger.Controls.Add(_ledgerGrid);
// ── Tab: Steuer (Platzhalter) ──
var tabTax = new TabPage("Steuer");
tabTax.Controls.Add(new Label
{
Dock = DockStyle.Fill, Padding = new Padding(16),
Text = "Steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt).\n\n" +
"Der neutrale Ledger und die Periodenabrechnung sind davon unabhängig gültig.\n" +
"Eine konkrete Steuerschicht (z. B. DE-Kapitalertragsteuer oder US Form 8949 / Schedule D)\n" +
"wird hier später als klar dokumentierte, prüfbare Rechenschicht ergänzt.\n\n" +
"Hinweis: Dies ist keine Steuerberatung."
});
// ── Tab: Abrechnung / Export ──
var tabExport = new TabPage("Abrechnung / Export");
var exportPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), FlowDirection = FlowDirection.TopDown };
exportPanel.Controls.Add(new Label { AutoSize = true, Text = "Exportiert die aktuelle Auswahl (Zeitraum / Konto / Währung):" });
var btnCsvLedger = new Button { Text = "Ledger als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
btnCsvLedger.Click += (_, _) => ExportCsvLedger();
var btnCsvStmt = new Button { Text = "Abrechnung als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
btnCsvStmt.Click += (_, _) => ExportCsvStatement();
var btnPdf = new Button { Text = "Abrechnung als PDF…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
btnPdf.Click += (_, _) => ExportPdf();
exportPanel.Controls.Add(btnCsvLedger);
exportPanel.Controls.Add(btnCsvStmt);
exportPanel.Controls.Add(btnPdf);
tabExport.Controls.Add(exportPanel);
// ── Tab: Abruf / Status ──
var tabIngest = new TabPage("Abruf / Status");
var ingestButtons = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) };
var btnIncr = new Button { Text = "Inkrementell abrufen", Width = 160 };
btnIncr.Click += async (_, _) => await RunIngest(backfill: false);
var btnBackfill = new Button { Text = "Backfill (voll)", Width = 140, Margin = new Padding(8, 0, 0, 0) };
btnBackfill.Click += async (_, _) => await RunIngest(backfill: true);
ingestButtons.Controls.Add(btnIncr);
ingestButtons.Controls.Add(btnBackfill);
var statusPanel = new Panel { Dock = DockStyle.Top, Height = 40 };
statusPanel.Controls.Add(_status);
_status.Text = "Offline-Standard: keine Live-Quelle registriert → der Ingest bucht nichts (korrekt).";
tabIngest.Controls.Add(_runsGrid);
tabIngest.Controls.Add(statusPanel);
tabIngest.Controls.Add(ingestButtons);
tabs.TabPages.AddRange(new[] { tabOverview, tabLedger, tabTax, tabExport, tabIngest });
Controls.Add(tabs);
Controls.Add(filter);
}
// ── Daten laden (nur auf Interaktion) ──
private string? SelectedAccount()
{
var text = _account.Text?.Trim();
return string.IsNullOrWhiteSpace(text) || text == "(alle)" ? null : text;
}
private void RefreshAll()
{
try
{
LoadAccounts();
LoadOverview();
LoadLedger();
LoadRuns();
}
catch (Exception ex)
{
_logger.Error("Accounting", $"UI-Refresh fehlgeschlagen: {ex.Message}", ex);
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void LoadAccounts()
{
var current = _account.Text;
_account.Items.Clear();
_account.Items.Add("(alle)");
foreach (var a in _ledger.DistinctAccounts()) _account.Items.Add(a);
_account.Text = string.IsNullOrEmpty(current) ? "(alle)" : current;
}
private void LoadOverview()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
var view = _report.GetCurrencyView(_currency.Text, to);
decimal C(decimal v) => Math.Round(v * view.Factor, 2);
_kpis.Text =
$"Netto-Handelsergebnis: {C(stmt.NetTradingResult):N2} {view.Code} " +
$"Handelsvolumen: {C(stmt.TradeVolume):N2} Dividenden: {C(stmt.Dividends):N2} Fees: {C(stmt.Fees):N2}\n" +
$"Einzahlungen: {C(stmt.Deposits):N2} Auszahlungen: {C(stmt.Withdrawals):N2} " +
$"Endsaldo: {C(stmt.ClosingBalance):N2} Trades: {stmt.TradeCount} Buchungen: {stmt.EntryCount}\n" +
$"{view.Note}";
var monthly = _report.BuildMonthly(SelectedAccount(), from, to)
.Select(m => new
{
Monat = m.From.ToString("yyyy-MM"),
Anfang = C(m.OpeningBalance),
Einzahlungen = C(m.Deposits),
Auszahlungen = C(m.Withdrawals),
Volumen = C(m.TradeVolume),
Fees = C(m.Fees),
Ergebnis = C(m.NetTradingResult),
Endsaldo = C(m.ClosingBalance)
}).ToList();
_monthly.DataSource = monthly;
}
private void LoadLedger()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var rows = _ledger.Query(SelectedAccount(), from, to, 2000)
.Select(e => new
{
Zeit = e.Timestamp, e.AccountId, Typ = e.EventType.ToString(), e.Side, e.Symbol,
e.Currency, e.Quantity, Preis = e.PriceNative, Brutto = e.GrossBase, Fee = e.FeeBase,
Netto = e.NetBase, e.TransactionId
}).ToList();
_ledgerGrid.DataSource = rows;
}
private void LoadRuns()
{
var rows = _runs.GetRecent(SelectedAccount(), 100)
.Select(r => new
{
r.AccountId, Start = r.StartedAt, Ende = r.FinishedAt, r.Backfill,
Neu = r.NewEntries, Duplikate = r.DuplicateEntries, r.Success,
Anker = r.BalanceAnchorBase, LedgerNetto = r.LedgerNetBase, Delta = r.BalanceDeltaBase, r.Message
}).ToList();
_runsGrid.DataSource = rows;
}
// ── Export ──
private void ExportCsvLedger()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var entries = _ledger.Query(SelectedAccount(), from, to, 100000);
SaveText("ledger.csv", "CSV|*.csv", CsvExporter.Ledger(entries));
}
private void ExportCsvStatement()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
SaveText("abrechnung.csv", "CSV|*.csv", CsvExporter.Statement(stmt));
}
private void ExportPdf()
{
try
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var account = SelectedAccount();
var stmt = _report.BuildStatement(account, from, to);
var monthly = _report.BuildMonthly(account, from, to);
var entries = _ledger.Query(account, from, to, 100000).OrderBy(e => e.Timestamp).ToList();
var view = _report.GetCurrencyView(_currency.Text, to);
byte[] pdf = PdfExporter.Render(stmt, monthly, entries, view.Code, view.Factor, view.Note);
using var dlg = new SaveFileDialog { FileName = "abrechnung.pdf", Filter = "PDF|*.pdf" };
if (dlg.ShowDialog(this) == DialogResult.OK)
{
File.WriteAllBytes(dlg.FileName, pdf);
_logger.Info("Accounting", $"PDF-Abrechnung geschrieben: {dlg.FileName}");
}
}
catch (Exception ex)
{
_logger.Error("Accounting", $"PDF-Export fehlgeschlagen: {ex.Message}", ex);
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void SaveText(string suggested, string filter, string content)
{
try
{
using var dlg = new SaveFileDialog { FileName = suggested, Filter = filter };
if (dlg.ShowDialog(this) == DialogResult.OK)
{
File.WriteAllText(dlg.FileName, content);
_logger.Info("Accounting", $"Export geschrieben: {dlg.FileName}");
}
}
catch (Exception ex)
{
_logger.Error("Accounting", $"Export fehlgeschlagen: {ex.Message}", ex);
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// ── Ingest ──
private async Task RunIngest(bool backfill)
{
try
{
_status.Text = backfill ? "Backfill läuft…" : "Inkrementeller Abruf läuft…";
await _ingest.IngestAllAsync(backfill, CancellationToken.None);
_status.Text = $"Abruf abgeschlossen ({DateTime.Now:HH:mm:ss}).";
LoadRuns();
}
catch (Exception ex)
{
_status.Text = $"Fehler: {ex.Message}";
_logger.Error("Accounting", $"Manueller Ingest fehlgeschlagen: {ex.Message}", ex);
}
}
}
@@ -7,7 +7,6 @@ using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.CongressTrading.Persistence.Ef; using IBKRTrader.Modules.CongressTrading.Persistence.Ef;
using IBKRTrader.Modules.CongressTrading.Scraper; using IBKRTrader.Modules.CongressTrading.Scraper;
using IBKRTrader.Modules.CongressTrading.UI;
using IBKRTrader.Modules.CongressTrading.Workers; using IBKRTrader.Modules.CongressTrading.Workers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@@ -48,21 +47,12 @@ public sealed class CongressTradingModule : IModule
services.AddHostedService(sp => sp.GetRequiredService<CongressScrapeWorker>()); services.AddHostedService(sp => sp.GetRequiredService<CongressScrapeWorker>());
} }
public void RegisterUi(IModuleUiHost host, IServiceProvider services) /// <summary>
{ /// Bewusst leer: das Modulprojekt trägt keinen UI-Code mehr, damit es plattformneutral bleibt
host.RegisterView(new ModuleView /// (kopfloser Linux-Betrieb). Das Modul-Fenster registriert die Shell zentral in
{ /// <c>UI/ModuleViews.cs</c>; die Dienste dafür kommen aus dem DI-Container.
Id = "congresstrading.main", /// </summary>
Title = "Congress Trading", public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
Group = Name,
Order = 100,
CreateForm = () => new CongressTradingForm(
services.GetRequiredService<CongressRepository>(),
services.GetRequiredService<WorkerEngine>(),
services.GetRequiredService<IPortfolioService>(),
services.GetRequiredService<LoggingService>())
});
}
// DB-Schema wird extern per `dotnet ef database update` angewendet (keine Laufzeit-Migration). // DB-Schema wird extern per `dotnet ef database update` angewendet (keine Laufzeit-Migration).
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework> <!-- Plattformneutral: das Modul traegt keinen UI-Code mehr (Fenster liegt in der Shell,
siehe UI/ModuleViews.cs) und laeuft damit auch im kopflosen Linux-Betrieb. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- Modul trägt eigene WinForms-UI (Modul-Fenster) bei. -->
<UseWindowsForms>true</UseWindowsForms>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -121,7 +122,7 @@ public class CapitolTradesScraper
if (match.Success) if (match.Success)
{ {
var total = int.Parse(match.Groups[1].Value); var total = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
_logger.Info("CT", $"GetTotalPagesAsync: {total} Seiten gefunden."); _logger.Info("CT", $"GetTotalPagesAsync: {total} Seiten gefunden.");
return total; return total;
} }
@@ -326,11 +327,18 @@ public class CapitolTradesScraper
return null; return null;
} }
/// <summary>
/// Datum aus dem RSC-Stream ("2026-08-04T…"). Bewusst <c>TryParseExact</c> mit fester
/// Invariant-Kultur: <c>TryParse</c> ohne Formatanbieter hätte die Kultur des Rechners benutzt
/// und damit je nach Host anders geraten. Ändert die Quelle ihr Format, soll das <b>auffallen</b>
/// (null → Satz wird verworfen) statt still zu einem falschen Datum zu werden.
/// </summary>
private static DateOnly? ParseDate(string? s) private static DateOnly? ParseDate(string? s)
{ {
if (string.IsNullOrEmpty(s)) return null; if (string.IsNullOrEmpty(s)) return null;
var datePart = s.Length > 10 ? s[..10] : s; var datePart = s.Length > 10 ? s[..10] : s;
return DateOnly.TryParse(datePart, out var d) ? d : null; return DateOnly.TryParseExact(datePart, "yyyy-MM-dd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var d) ? d : null;
} }
private static string CapFirst(string s) private static string CapFirst(string s)
@@ -1,129 +0,0 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading.Database;
namespace IBKRTrader.Modules.CongressTrading.UI;
/// <summary>
/// Eigenständiges Fenster des CongressTrading-Moduls: DB-Kennzahlen, manueller Scrape-Trigger
/// und die offenen Positionen des Moduls (aus dem Core-Portfolio).
/// </summary>
public sealed class CongressTradingForm : Form
{
private const string ScrapeWorkerName = "CT-ScrapeWorker";
private readonly CongressRepository _repo;
private readonly WorkerEngine _engine;
private readonly IPortfolioService _portfolio;
private readonly LoggingService _logger;
private readonly Label _lblTrades = new() { AutoSize = true, Location = new Point(20, 70) };
private readonly Label _lblMembers = new() { AutoSize = true, Location = new Point(20, 100) };
private readonly Button _btnRefresh = new() { Text = "Aktualisieren", Location = new Point(20, 140), Width = 140 };
private readonly Button _btnScrape = new() { Text = "Scrape jetzt", Location = new Point(170, 140), Width = 140 };
private readonly Label _lblStatus = new() { AutoSize = true, Location = new Point(20, 185), ForeColor = SystemColors.GrayText };
private readonly DataGridView _positions = new()
{
Location = new Point(20, 250),
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
ReadOnly = true,
AllowUserToAddRows = false,
RowHeadersVisible = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill
};
public CongressTradingForm(CongressRepository repo, WorkerEngine engine, IPortfolioService portfolio, LoggingService logger)
{
_repo = repo;
_engine = engine;
_portfolio = portfolio;
_logger = logger;
Text = "Congress Trading [CT]";
Width = 900;
Height = 600;
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(600, 400);
BuildLayout();
}
private void BuildLayout()
{
var title = new Label
{
Text = "Congress Trading",
Font = new Font(Font.FontFamily, 14f, FontStyle.Bold),
Location = new Point(18, 20),
AutoSize = true
};
var posLabel = new Label
{
Text = "Offene Positionen (Modul CT):",
Location = new Point(20, 225),
AutoSize = true
};
_positions.Size = new Size(ClientSize.Width - 40, ClientSize.Height - 270);
_btnRefresh.Click += async (_, _) => await RefreshStatsAsync();
_btnScrape.Click += async (_, _) => await TriggerScrapeAsync();
Controls.Add(title);
Controls.Add(_lblTrades);
Controls.Add(_lblMembers);
Controls.Add(_btnRefresh);
Controls.Add(_btnScrape);
Controls.Add(_lblStatus);
Controls.Add(posLabel);
Controls.Add(_positions);
}
protected override async void OnShown(EventArgs e)
{
base.OnShown(e);
await RefreshStatsAsync();
}
private async Task RefreshStatsAsync()
{
try
{
var trades = await _repo.GetTradeCountAsync();
var members = await _repo.GetMemberCountAsync();
_lblTrades.Text = $"Trades in DB: {trades:N0}";
_lblMembers.Text = $"Mitglieder in DB: {members:N0}";
var positions = await _portfolio.GetPositionsAsync(CongressTradingModule.LogTag);
_positions.DataSource = positions
.Select(p => new { p.Symbol, Stück = p.Quantity, Ø_Kurs = p.AvgPrice, Wert = p.Notional })
.ToList();
_lblStatus.Text = $"Aktualisiert: {DateTime.Now:HH:mm:ss}";
}
catch (Exception ex)
{
_lblTrades.Text = "Trades in DB: n/v";
_lblMembers.Text = "Mitglieder in DB: n/v";
_lblStatus.Text = $"DB nicht erreichbar: {ex.Message}";
_logger.Warn(CongressTradingModule.LogTag, $"Kennzahlen konnten nicht geladen werden: {ex.Message}");
}
}
private async Task TriggerScrapeAsync()
{
try
{
_lblStatus.Text = "Scrape angestoßen...";
await _engine.TriggerWorkerAsync(ScrapeWorkerName);
_logger.Info(CongressTradingModule.LogTag, "Scrape-Worker manuell ausgelöst (aus Modul-Fenster).");
}
catch (Exception ex)
{
_lblStatus.Text = $"Scrape fehlgeschlagen: {ex.Message}";
_logger.Error(CongressTradingModule.LogTag, "Manueller Scrape-Trigger fehlgeschlagen.", ex);
}
}
}
@@ -1,3 +1,4 @@
using IBKRTrader.Core.Time;
using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
@@ -50,7 +51,7 @@ public class CongressScrapeWorker : WorkerBase
if (trades.Count == 0) if (trades.Count == 0)
{ {
Logger.Warn(Module, "Scrape: Keine Trades auf Seite 1 gefunden."); Logger.Warn(Module, "Scrape: Keine Trades auf Seite 1 gefunden.");
Info.Info = $"Letzter Lauf {DateTime.Now:HH:mm}: 0 Trades"; Info.Info = $"Letzter Lauf {AppTimeZone.Now:HH:mm}: 0 Trades";
return; return;
} }
@@ -86,6 +87,6 @@ public class CongressScrapeWorker : WorkerBase
var summary = $"{newTrades} neue Trades, {newMembers} neue Mitglieder"; var summary = $"{newTrades} neue Trades, {newMembers} neue Mitglieder";
Logger.Info(Module, $"Scrape abgeschlossen: {summary}"); Logger.Info(Module, $"Scrape abgeschlossen: {summary}");
Info.Info = $"Letzter Lauf {DateTime.Now:HH:mm}: {summary}"; Info.Info = $"Letzter Lauf {AppTimeZone.Now:HH:mm}: {summary}";
} }
} }
@@ -1,3 +1,4 @@
using IBKRTrader.Core.Configuration;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@@ -36,7 +37,7 @@ public sealed class OpenRouterClient : IChatCompletionClient
{ {
string? key = Environment.GetEnvironmentVariable("IBKRTRADER_OPENROUTER_KEY"); string? key = Environment.GetEnvironmentVariable("IBKRTRADER_OPENROUTER_KEY");
if (!string.IsNullOrWhiteSpace(key)) return key.Trim(); if (!string.IsNullOrWhiteSpace(key)) return key.Trim();
string file = Path.Combine(AppContext.BaseDirectory, "openrouter.key"); string file = AppPaths.ConfigFile("openrouter.key");
return File.Exists(file) ? File.ReadAllText(file).Trim() : null; return File.Exists(file) ? File.ReadAllText(file).Trim() : null;
} }
@@ -1,3 +1,4 @@
using IBKRTrader.Core.Configuration;
using System.Text.Json; using System.Text.Json;
using IBKRTrader.Core.Analytics; using IBKRTrader.Core.Analytics;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
@@ -27,7 +28,7 @@ public static class SupervisorTools
ISupervisorCounterfactualRepository? counterfactuals = null) ISupervisorCounterfactualRepository? counterfactuals = null)
{ {
var reg = new SupervisorToolRegistry(); var reg = new SupervisorToolRegistry();
string logsDir = Path.Combine(AppContext.BaseDirectory, "Logs"); string logsDir = AppPaths.Logs;
reg.Register(new SupervisorTool( reg.Register(new SupervisorTool(
"query_decisions", "query_decisions",
@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework> <!-- Plattformneutral: das Modul traegt keinen UI-Code mehr (Fenster liegt in der Shell,
siehe UI/ModuleViews.cs) und laeuft damit auch im kopflosen Linux-Betrieb. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<!-- Modul trägt eigene WinForms-UI (Modul-Fenster) bei. -->
<UseWindowsForms>true</UseWindowsForms>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Time;
using IBKRTrader.Modules.Supervisor.Agent; using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence; using IBKRTrader.Modules.Supervisor.Persistence;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@@ -38,10 +39,12 @@ public sealed class DailyReportService : BackgroundService
return; return;
} }
_logger.Info("Supervisor", $"Tagesbericht aktiv: täglich um {hour:00}:00 Uhr."); _logger.Info("Supervisor",
$"Tagesbericht aktiv: täglich um {hour:00}:00 Uhr ({AppTimeZone.CurrentId}).");
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
var delay = NextRun(DateTime.Now, hour) - DateTime.Now; var now = DateTime.UtcNow;
var delay = NextRunUtc(now, hour, AppTimeZone.Current) - now;
try { await Task.Delay(delay, stoppingToken); } try { await Task.Delay(delay, stoppingToken); }
catch (OperationCanceledException) { break; } catch (OperationCanceledException) { break; }
@@ -51,10 +54,37 @@ public sealed class DailyReportService : BackgroundService
} }
} }
internal static DateTime NextRun(DateTime now, int hour) /// <summary>
/// Nächster Ausführungszeitpunkt (UTC) für „täglich um <paramref name="hour"/> Uhr Ortszeit".
///
/// <para>Rechnet ausdrücklich gegen <paramref name="zone"/> statt gegen
/// <see cref="DateTimeKind.Local"/>: sonst hinge die Berichtszeit an der Zeitzone des Rechners.
/// Eine US-Instanz auf einem UTC-Container hätte den „18-Uhr-Bericht" um 13 Uhr Ortszeit
/// erzeugt über eine Zeitgrenze hinweg, die im Bericht selbst nicht sichtbar ist.</para>
/// </summary>
internal static DateTime NextRunUtc(DateTime nowUtc, int hour, TimeZoneInfo zone)
{ {
var candidate = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0, DateTimeKind.Local); var localNow = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, zone);
return candidate <= now ? candidate.AddDays(1) : candidate;
// Zwei Tage reichen: der heutige Termin liegt entweder noch vor uns oder der morgige.
for (int addDays = 0; addDays <= 2; addDays++)
{
var candidate = localNow.Date.AddDays(addDays).AddHours(hour);
// Beim Vorstellen der Uhr existiert die Stunde nicht dann auf die nächste gültige
// ausweichen, statt den Bericht des Tages ausfallen zu lassen.
int guard = 0;
while (zone.IsInvalidTime(candidate) && guard++ < 4)
candidate = candidate.AddHours(1);
if (zone.IsInvalidTime(candidate)) continue;
// Beim Zurückstellen ist die Stunde doppelt; ConvertTimeToUtc nimmt die Normalzeit gewollt.
var utc = TimeZoneInfo.ConvertTimeToUtc(
DateTime.SpecifyKind(candidate, DateTimeKind.Unspecified), zone);
if (utc > nowUtc) return utc;
}
return nowUtc.AddDays(1);
} }
private async Task RunOnceAsync(CancellationToken ct) private async Task RunOnceAsync(CancellationToken ct)
@@ -1,3 +1,4 @@
using IBKRTrader.Core.Configuration;
using IBKRTrader.Core.Analytics; using IBKRTrader.Core.Analytics;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence; using IBKRTrader.Core.Persistence;
@@ -26,7 +27,7 @@ public sealed class DossierService
_journal = journal; _journal = journal;
_orderEvents = orderEvents; _orderEvents = orderEvents;
_trades = trades; _trades = trades;
_logsDirectory = Path.Combine(AppContext.BaseDirectory, "Logs"); _logsDirectory = AppPaths.Logs;
} }
/// <summary>Jüngste Signale (gruppiert über das Entscheidungsjournal), neueste zuerst.</summary> /// <summary>Jüngste Signale (gruppiert über das Entscheidungsjournal), neueste zuerst.</summary>
@@ -8,7 +8,6 @@ using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Counterfactual; using IBKRTrader.Modules.Supervisor.Counterfactual;
using IBKRTrader.Modules.Supervisor.Persistence; using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services; using IBKRTrader.Modules.Supervisor.Services;
using IBKRTrader.Modules.Supervisor.Ui;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -58,21 +57,12 @@ public sealed class SupervisorModule : IModule
services.AddHostedService<Mcp.McpLightServer>(); services.AddHostedService<Mcp.McpLightServer>();
} }
public void RegisterUi(IModuleUiHost host, IServiceProvider services) /// <summary>
{ /// Bewusst leer: das Modulprojekt trägt keinen UI-Code mehr, damit es plattformneutral bleibt
host.RegisterView(new ModuleView /// (kopfloser Linux-Betrieb). Das Supervisor-Fenster registriert die Shell zentral in
{ /// <c>UI/ModuleViews.cs</c>; die Dienste dafür kommen aus dem DI-Container.
Id = "supervisor.main", /// </summary>
Title = "Supervisor", public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
Group = Name,
Order = 300,
CreateForm = () => new SupervisorMainForm(
services.GetRequiredService<SupervisorAgent>(),
services.GetRequiredService<DossierService>(),
services.GetRequiredService<ISupervisorReportRepository>(),
services.GetRequiredService<LoggingService>())
});
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -1,188 +0,0 @@
using System.Text;
using System.Text.Json;
using IBKRTrader.Core.Analytics;
using IBKRTrader.Core.Logging;
using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
namespace IBKRTrader.Modules.Supervisor.Ui;
/// <summary>
/// Fenster des Supervisor-Moduls: Analyse (Chat mit dem Agenten, Tool-Aufrufe sichtbar), Dossier-Browser,
/// Berichte, Settings. Read-only. DB-/Agent-Zugriffe laufen NUR auf Nutzer-Interaktion (Smoke-UI-sicher).
/// </summary>
public sealed class SupervisorMainForm : Form
{
private readonly SupervisorAgent _agent;
private readonly DossierService _dossiers;
private readonly ISupervisorReportRepository _reports;
private readonly LoggingService _logger;
private readonly ComboBox _profile = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 160 };
private readonly TextBox _question = new() { Dock = DockStyle.Fill, Multiline = true, Height = 60 };
private readonly RichTextBox _answer = new() { Dock = DockStyle.Fill, ReadOnly = true, Font = new Font("Consolas", 9f) };
private readonly Button _ask = new() { Text = "Fragen", Width = 100 };
private readonly DataGridView _signals = new() { Dock = DockStyle.Left, Width = 360, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
private readonly RichTextBox _dossier = new() { Dock = DockStyle.Fill, ReadOnly = true, Font = new Font("Consolas", 9f) };
private readonly DataGridView _reportsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
public SupervisorMainForm(
SupervisorAgent agent, DossierService dossiers, ISupervisorReportRepository reports, LoggingService logger)
{
_agent = agent;
_dossiers = dossiers;
_reports = reports;
_logger = logger;
Text = "Supervisor";
Width = 1080;
Height = 720;
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(800, 520);
foreach (var p in SupervisorProfiles.All) _profile.Items.Add(p.Name);
_profile.SelectedIndex = 0;
BuildLayout();
}
private void BuildLayout()
{
var tabs = new TabControl { Dock = DockStyle.Fill };
// ── Tab: Analyse ──
var tabChat = new TabPage("Analyse");
var top = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 36, Padding = new Padding(8, 6, 8, 0) };
top.Controls.Add(new Label { Text = "Profil", AutoSize = true, Margin = new Padding(0, 8, 4, 0) });
top.Controls.Add(_profile);
_ask.Click += async (_, _) => await AskAsync();
var qPanel = new Panel { Dock = DockStyle.Top, Height = 70, Padding = new Padding(8, 2, 8, 4) };
qPanel.Controls.Add(_question);
var askPanel = new Panel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(8, 0, 8, 0) };
askPanel.Controls.Add(_ask);
var answerPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8) };
answerPanel.Controls.Add(_answer);
tabChat.Controls.Add(answerPanel);
tabChat.Controls.Add(askPanel);
tabChat.Controls.Add(qPanel);
tabChat.Controls.Add(top);
// ── Tab: Dossier-Browser ──
var tabDossier = new TabPage("Dossier-Browser");
_signals.SelectionChanged += (_, _) => ShowSelectedDossier();
var refreshSignals = new Button { Text = "Signale laden", Dock = DockStyle.Top, Height = 28 };
refreshSignals.Click += (_, _) => LoadSignals();
var left = new Panel { Dock = DockStyle.Left, Width = 360 };
left.Controls.Add(_signals);
left.Controls.Add(refreshSignals);
var dossierPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8) };
dossierPanel.Controls.Add(_dossier);
tabDossier.Controls.Add(dossierPanel);
tabDossier.Controls.Add(left);
// ── Tab: Berichte ──
var tabReports = new TabPage("Berichte");
var refreshReports = new Button { Text = "Berichte laden", Dock = DockStyle.Top, Height = 28 };
refreshReports.Click += (_, _) => LoadReports();
tabReports.Controls.Add(_reportsGrid);
tabReports.Controls.Add(refreshReports);
// ── Tab: Settings (Info) ──
var tabSettings = new TabPage("Settings");
tabSettings.Controls.Add(new Label
{
Dock = DockStyle.Fill, Padding = new Padding(16),
Text =
"Supervisor read-only Analyse/Forensik über alle Module.\n\n" +
"OpenRouter-Key: env IBKRTRADER_OPENROUTER_KEY oder Datei 'openrouter.key' (gitignored).\n" +
$" Status: {(string.IsNullOrEmpty(OpenRouterClient.DefaultApiKeyProvider()) ? "NICHT gesetzt Chat nicht verfügbar" : "gesetzt")}\n\n" +
"Tagesbericht (opt-in): env IBKRTRADER_SUPERVISOR_DAILY = Stunde 023.\n" +
"MCP-Light (opt-in): env IBKRTRADER_MCP_PORT = Port (bindet nur 127.0.0.1).\n\n" +
"Sicherheit: OpenRouter ist ein bewusst freigegebener externer Datenempfänger. Es werden nur\n" +
"Analyse-Daten der Tools gesendet, niemals Secrets. Kein Tool kann handeln oder schreiben."
});
tabs.TabPages.AddRange(new[] { tabChat, tabDossier, tabReports, tabSettings });
Controls.Add(tabs);
}
// ── Analyse ──
private async Task AskAsync()
{
var question = _question.Text.Trim();
if (string.IsNullOrEmpty(question)) return;
_ask.Enabled = false;
_answer.Clear();
var profile = SupervisorProfiles.ByName(_profile.Text);
var progress = new Progress<string>(s => AppendLine(s));
try
{
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
});
}
catch (Exception ex)
{
AppendLine("");
AppendLine($"FEHLER: {ex.Message}");
_logger.Warn("Supervisor", $"Analyse fehlgeschlagen: {ex.Message}");
}
finally
{
_ask.Enabled = true;
}
}
private void AppendLine(string text)
{
if (_answer.InvokeRequired) { _answer.BeginInvoke(() => AppendLine(text)); return; }
_answer.AppendText(text + "\n");
_answer.ScrollToCaret();
}
// ── Dossier ──
private void LoadSignals()
{
try { _signals.DataSource = _dossiers.RecentSignals(200); }
catch (Exception ex) { _logger.Warn("Supervisor", $"Signale laden fehlgeschlagen: {ex.Message}"); }
}
private void ShowSelectedDossier()
{
if (_signals.CurrentRow?.DataBoundItem is not SignalSummary s) return;
try { _dossier.Text = DossierBuilder.ToMarkdown(_dossiers.BuildForSignal(s.SignalId)); }
catch (Exception ex) { _dossier.Text = $"FEHLER: {ex.Message}"; }
}
// ── Berichte ──
private void LoadReports()
{
try
{
_reportsGrid.DataSource = _reports.GetRecent(100)
.Select(r => new { r.CreatedAt, r.Profile, r.Model, r.Question, r.ToolCallCount, r.PromptTokens, r.CompletionTokens })
.ToList();
}
catch (Exception ex) { _logger.Warn("Supervisor", $"Berichte laden fehlgeschlagen: {ex.Message}"); }
}
}
@@ -0,0 +1,62 @@
using FluentAssertions;
using IBKRTrader.Core.Configuration;
namespace IBKRTrader.Tests.Configuration;
/// <summary>
/// Die Ablageorte müssen auf beiden Plattformen bestimmbar und beschreibbar sein. Bisher lag alles
/// neben der Binärdatei unter <c>/opt</c> hat der Dienstbenutzer dort keinen Schreibzugriff, der
/// Dienst wäre beim ersten Logeintrag gescheitert.
/// </summary>
[Trait("cat", "unit")]
public class AppPathsTests
{
[Fact]
public void AlleVerzeichnisse_SindErmittelbarUndExistieren()
{
// Der Zugriff legt sie an ein Dienst darf nicht daran scheitern, dass ein Verzeichnis fehlt.
foreach (var dir in new[] { AppPaths.Config, AppPaths.Data, AppPaths.Logs })
{
dir.Should().NotBeNullOrWhiteSpace();
Directory.Exists(dir).Should().BeTrue($"AppPaths muss \"{dir}\" anlegen");
}
}
[Fact]
public void Verzeichnisse_SindTatsaechlichBeschreibbar()
{
// Die Auflösung prüft den Schreibzugriff durch einen echten Schreibversuch. Dieser Test
// haelt fest, dass das Ergebnis stimmt eine reine Attributpruefung waere hier wertlos,
// weil unter Linux Besitzer/Gruppe/Modus und unter Windows die ACL entscheiden.
foreach (var dir in new[] { AppPaths.Config, AppPaths.Data, AppPaths.Logs })
{
var probe = Path.Combine(dir, $"test-{Guid.NewGuid():N}.tmp");
File.WriteAllText(probe, "x");
File.Delete(probe);
}
}
[Fact]
public void ConfigFile_LiegtImKonfigurationsverzeichnis()
{
var path = AppPaths.ConfigFile("master.key");
Path.GetDirectoryName(path).Should().Be(AppPaths.Config.TrimEnd(Path.DirectorySeparatorChar));
Path.GetFileName(path).Should().Be("master.key");
}
[Fact]
public void DataPath_LiegtImDatenverzeichnis()
{
AppPaths.DataPath("Backups").Should().StartWith(AppPaths.Data);
}
[Fact]
public void Describe_NenntAlleDreiOrte()
{
// Wird beim Start geloggt im Betrieb muss sichtbar sein, wohin geschrieben wird.
var text = AppPaths.Describe();
text.Should().Contain("config=").And.Contain("data=").And.Contain("logs=");
}
}
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<!-- net10.0-windows + WinForms, weil das Testprojekt die WinForms-Hauptassembly referenziert --> <!-- Plattformneutral: die Tests decken Core und Module ab, die beide keinen UI-Code mehr
<TargetFramework>net10.0-windows</TargetFramework> tragen. Die UI-Konstruktionsprüfung liegt jetzt beim Smoke-UI-Lauf der Shell. -->
<UseWindowsForms>true</UseWindowsForms> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
@@ -1,3 +1,4 @@
using System.Globalization;
using FluentAssertions; using FluentAssertions;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Modules.CongressTrading.Scraper; using IBKRTrader.Modules.CongressTrading.Scraper;
@@ -44,6 +45,41 @@ public class CapitolTradesScraperTests
} }
} }
[Theory]
[InlineData("de-DE")] // Tag.Monat.Jahr
[InlineData("th-TH")] // buddhistischer Kalender
[InlineData("fa-IR")] // persischer Kalender
[InlineData("ar-SA")] // Hidschri-Kalender
[InlineData("")] // Invariant so verhält sich ein Container ohne LANG
public void ParseTradesFromHtml_LiefertDieselbenDaten_UnabhaengigVonDerHostKultur(string culture)
{
// Die Datumsfelder kamen ueber DateOnly.TryParse OHNE Formatanbieter herein, also mit der
// Kultur des Rechners. Bei Kulturen mit eigenem Kalender ist das nicht theoretisch:
// aus "2026-08-04" wurde unter th-TH das Jahr 1483 und unter fa-IR das Jahr 2647,
// unter ar-SA schlug das Parsen ganz fehl. de-DE und en-US kommen mit ISO klar - genau
// deshalb faellt so ein Fehler auf dem Entwicklungsrechner nie auf.
var previous = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
var (trades, _) = new CapitolTradesScraper(new LoggingService()).ParseTradesFromHtml(LoadFixture());
trades.Should().NotBeEmpty();
trades.Should().Contain(t => t.TradeDate != null,
"die Fixture enthaelt Handelsdaten im Format yyyy-MM-dd");
// Alle geparsten Daten muessen plausibel sein ein kulturbedingter Tag/Monat-Dreher
// faellt hier auf, weil Tage > 12 sonst gar nicht parsen wuerden.
foreach (var t in trades.Where(t => t.TradeDate != null))
t.TradeDate!.Value.Year.Should().BeInRange(2000, 2100);
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
[Fact] [Fact]
public void ParseTradesFromHtml_EmptyHtml_ReturnsNoTrades() public void ParseTradesFromHtml_EmptyHtml_ReturnsNoTrades()
{ {
@@ -55,17 +55,17 @@ public class CongressTradingModuleTests
} }
[Fact] [Fact]
public void RegisterUi_RegistersMainView() public void RegisterUi_RegistriertNichts_DamitDasModulPlattformneutralBleibt()
{ {
// Absicht, kein Versehen: würde das Modul sein Fenster selbst erzeugen, müsste es das
// UI-Toolkit referenzieren und wäre damit nicht mehr kopflos auf Linux lauffähig.
// Das Fenster registriert die Shell zentral (App: UI/ModuleViews.cs).
var host = new CapturingUiHost(); var host = new CapturingUiHost();
// CreateForm wird hier NICHT aufgerufen daher genügt ein leerer Provider.
var provider = new ServiceCollection().BuildServiceProvider(); var provider = new ServiceCollection().BuildServiceProvider();
new CongressTradingModule().RegisterUi(host, provider); new CongressTradingModule().RegisterUi(host, provider);
host.Views.Should().ContainSingle(); host.Views.Should().BeEmpty();
host.Views[0].Id.Should().Be("congresstrading.main");
host.Views[0].Title.Should().Be("Congress Trading");
} }
[Fact] [Fact]
@@ -0,0 +1,79 @@
using FluentAssertions;
using IBKRTrader.Modules.Supervisor.Services;
namespace IBKRTrader.Tests.Modules.Supervisor;
/// <summary>
/// Der Tagesbericht soll um eine feste ORTSZEIT laufen unabhängig davon, in welcher Zeitzone der
/// Rechner steht. Vorher rechnete er gegen <c>DateTimeKind.Local</c>: eine US-Instanz auf einem
/// UTC-Container hätte den „18-Uhr-Bericht" um 13 Uhr Ortszeit erzeugt, über eine Zeitgrenze
/// hinweg, die im Bericht selbst nicht sichtbar ist.
/// </summary>
[Trait("cat", "unit")]
public class DailyReportScheduleTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
[Fact]
public void NaechsterLauf_LiegtHeute_WennDieStundeNochBevorsteht()
{
// 08:00 UTC = 10:00 Berliner Sommerzeit. 18 Uhr Berlin steht heute noch bevor = 16:00 UTC.
var now = new DateTime(2026, 7, 15, 8, 0, 0, DateTimeKind.Utc);
DailyReportService.NextRunUtc(now, 18, Berlin)
.Should().Be(new DateTime(2026, 7, 15, 16, 0, 0, DateTimeKind.Utc));
}
[Fact]
public void NaechsterLauf_RutschtAufMorgen_WennDieStundeVorbeiIst()
{
// 20:00 UTC = 22:00 Berlin. 18 Uhr ist durch, also morgen 18 Uhr Berlin = 16:00 UTC.
var now = new DateTime(2026, 7, 15, 20, 0, 0, DateTimeKind.Utc);
DailyReportService.NextRunUtc(now, 18, Berlin)
.Should().Be(new DateTime(2026, 7, 16, 16, 0, 0, DateTimeKind.Utc));
}
[Fact]
public void DieselbeStunde_ErgibtJeZoneEinenAnderenUtcZeitpunkt()
{
var now = new DateTime(2026, 7, 15, 6, 0, 0, DateTimeKind.Utc);
// 18 Uhr Ortszeit: Berlin (UTC+2) = 16:00 UTC, New York (UTC-4) = 22:00 UTC.
DailyReportService.NextRunUtc(now, 18, Berlin).Hour.Should().Be(16);
DailyReportService.NextRunUtc(now, 18, NewYork).Hour.Should().Be(22);
}
[Fact]
public void Winterzeit_NutztDenRichtigenVersatz()
{
// Januar: Berlin ist UTC+1, 18 Uhr Ortszeit = 17:00 UTC.
var now = new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc);
DailyReportService.NextRunUtc(now, 18, Berlin)
.Should().Be(new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc));
}
[Fact]
public void Zeitumstellung_LaesstDenBerichtNichtAusfallen()
{
// 29.03.2026: Berlin stellt um 02:00 auf 03:00 vor die Stunde 02:00 existiert nicht.
// Der Bericht muss trotzdem laufen (auf die naechste gueltige Stunde ausweichen),
// statt den Tag zu ueberspringen.
var now = new DateTime(2026, 3, 28, 12, 0, 0, DateTimeKind.Utc);
var next = DailyReportService.NextRunUtc(now, 2, Berlin);
next.Should().BeAfter(now);
next.Should().BeBefore(now.AddDays(2), "der Bericht darf hoechstens einen Tag spaeter kommen");
}
[Fact]
public void Ergebnis_IstImmerUtc()
{
var now = new DateTime(2026, 7, 15, 8, 0, 0, DateTimeKind.Utc);
DailyReportService.NextRunUtc(now, 18, Berlin).Kind.Should().Be(DateTimeKind.Utc);
}
}

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