Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f66183f1c | ||
|
|
e1546bd1b1 | ||
|
|
0a72ea7e28 | ||
|
|
87194bfc48 | ||
|
|
c176b05ea1 | ||
|
|
d86a083438 | ||
|
|
3513a0b2d9 | ||
|
|
afae3c9c4a | ||
|
|
9c7e19149c | ||
|
|
8b9b993d1d | ||
|
|
d8273c3a1e | ||
|
|
123f38ab6f | ||
|
|
18b1059fa9 | ||
|
|
27577ef1b2 |
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -47,3 +47,7 @@ Logs/
|
|||||||
|
|
||||||
## Local Claude / agent config
|
## Local Claude / agent config
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
## Deploymentcenter Release-Pipeline (siehe scripts/release.ps1 / release.sh)
|
||||||
|
.dc-tools/
|
||||||
|
artifacts/
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<Project>
|
||||||
|
<!--
|
||||||
|
Zentrale Versionsangabe fuer alle Projekte der Solution. pack-and-deploy (Deploymentcenter)
|
||||||
|
liest die Version aus der Hauptassembly und bricht bei einer Abweichung zum uebergebenen
|
||||||
|
Versionsparameter ab - deshalb steht sie hier genau einmal statt in einzelnen csproj-Dateien.
|
||||||
|
-->
|
||||||
|
<PropertyGroup>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -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>
|
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,15 +13,34 @@
|
|||||||
</packageSources>
|
</packageSources>
|
||||||
<packageSourceMapping>
|
<packageSourceMapping>
|
||||||
<packageSource key="nuget.org">
|
<packageSource key="nuget.org">
|
||||||
<!-- App-Pakete -->
|
<!-- App-Pakete. Beide stehen NUR transitiv in den Projektdateien: MySqlConnector kommt ueber
|
||||||
|
Pomelo, Newtonsoft.Json ueber die Testplattform. Ohne Eintrag scheitert ein Restore aus
|
||||||
|
leerem Cache mit NU1100 - auf einem Entwicklungsrechner unsichtbar, weil dort gecacht.
|
||||||
|
Am 2026-08-23 geprueft, indem beide entfernt und der Restore in einen leeren
|
||||||
|
Paket-Ordner wiederholt wurde; er schlug fehl. Nicht entfernen. -->
|
||||||
<package pattern="MySqlConnector" />
|
<package pattern="MySqlConnector" />
|
||||||
<package pattern="Dapper" />
|
|
||||||
<package pattern="HtmlAgilityPack" />
|
|
||||||
<package pattern="Newtonsoft.Json" />
|
<package pattern="Newtonsoft.Json" />
|
||||||
<!-- Offizielle TWS-C#-API (NuGet-Mirror) für den IBKR-Broker-Adapter -->
|
<!-- Offizielle TWS-C#-API (NuGet-Mirror) für den IBKR-Broker-Adapter -->
|
||||||
<package pattern="IB.TWS.CSharpApi" />
|
<package pattern="IB.TWS.CSharpApi" />
|
||||||
<!-- EF Core / Pomelo (MySQL/MariaDB) -->
|
<!-- PDF-Export im Accounting-Modul (PDFsharp/MigraDoc, MIT) -->
|
||||||
|
<package pattern="PDFsharp*" />
|
||||||
|
<!-- Avalonia-Oberfläche (MIT) samt Renderer-Unterbau. SkiaSharp/HarfBuzz bringen die
|
||||||
|
nativen Bibliotheken mit, Tmds.DBus und MicroCom sind Linux- bzw. Windows-Unterbau. -->
|
||||||
|
<package pattern="Avalonia*" />
|
||||||
|
<package pattern="SkiaSharp*" />
|
||||||
|
<package pattern="HarfBuzzSharp*" />
|
||||||
|
<package pattern="Tmds.DBus*" />
|
||||||
|
<package pattern="MicroCom*" />
|
||||||
|
<!-- Diagramme (kommen mit den neuen Modulen, s. Kommentar im Avalonia-csproj) -->
|
||||||
|
<package pattern="LiveChartsCore*" />
|
||||||
|
<!-- EF Core / Pomelo (MySQL/MariaDB).
|
||||||
|
ACHTUNG: "Microsoft.EntityFrameworkCore.*" matcht das Basispaket OHNE Suffix NICHT –
|
||||||
|
deshalb steht es zusätzlich einzeln. Ohne das schlägt ein Restore gegen einen leeren
|
||||||
|
Paket-Ordner mit NU1100 fehl (auf dem Entwicklungsrechner unsichtbar, weil gecacht). -->
|
||||||
|
<package pattern="Microsoft.EntityFrameworkCore" />
|
||||||
<package pattern="Microsoft.EntityFrameworkCore.*" />
|
<package pattern="Microsoft.EntityFrameworkCore.*" />
|
||||||
|
<!-- Transitiv über EntityFrameworkCore.Design (Migrations-Codegenerierung). -->
|
||||||
|
<package pattern="Microsoft.CodeAnalysis.*" />
|
||||||
<package pattern="Pomelo.*" />
|
<package pattern="Pomelo.*" />
|
||||||
<package pattern="Humanizer.*" />
|
<package pattern="Humanizer.*" />
|
||||||
<package pattern="Mono.TextTemplating" />
|
<package pattern="Mono.TextTemplating" />
|
||||||
@@ -38,7 +57,6 @@
|
|||||||
<package pattern="Microsoft.Extensions.*" />
|
<package pattern="Microsoft.Extensions.*" />
|
||||||
<package pattern="Microsoft.NET.*" />
|
<package pattern="Microsoft.NET.*" />
|
||||||
<package pattern="Microsoft.NETCore.*" />
|
<package pattern="Microsoft.NETCore.*" />
|
||||||
<package pattern="Microsoft.WindowsDesktop.*" />
|
|
||||||
<package pattern="Microsoft.AspNetCore.*" />
|
<package pattern="Microsoft.AspNetCore.*" />
|
||||||
<package pattern="Microsoft.Bcl.*" />
|
<package pattern="Microsoft.Bcl.*" />
|
||||||
<package pattern="Microsoft.Win32.*" />
|
<package pattern="Microsoft.Win32.*" />
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -4,13 +4,20 @@ Modulares C#-Trading-Framework für Interactive-Brokers-Aktien. **Harter Core +
|
|||||||
Strategie-Module + Launcher**, der die Fenster der Module öffnet. Konzept nach dem Vorbild von
|
Strategie-Module + Launcher**, der die Fenster der Module öffnet. Konzept nach dem Vorbild von
|
||||||
PolytraderSharp (nur IBKR statt Polymarket).
|
PolytraderSharp (nur IBKR statt Polymarket).
|
||||||
|
|
||||||
|
**Läuft auf Windows und Linux** – wahlweise mit Oberfläche (Avalonia) oder kopflos als Dienst.
|
||||||
|
|
||||||
## Architektur (Kurzform)
|
## Architektur (Kurzform)
|
||||||
```
|
```
|
||||||
IBKRTrader.App WinExe – Generic Host + Launcher/Shell (WinForms)
|
src/IBKRTrader.App Oberfläche (Avalonia, plattformneutral)
|
||||||
|
src/IBKRTrader.Daemon kopfloser Dienst (systemd) – dieselbe Anwendung ohne Fenster
|
||||||
|
src/IBKRTrader.Hosting Host-Zusammenstellung, von beiden Einstiegspunkten geteilt
|
||||||
src/IBKRTrader.Core Contracts, EF-Persistenz, Trading-Kern, Worker, Security
|
src/IBKRTrader.Core Contracts, EF-Persistenz, Trading-Kern, Worker, Security
|
||||||
src/IBKRTrader.Modules.* je Modul ein eigenes Projekt (referenziert nur Core)
|
src/IBKRTrader.Modules.* je Modul ein eigenes Projekt (referenziert nur Core)
|
||||||
tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory)
|
tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory)
|
||||||
```
|
```
|
||||||
|
Alle Projekte sind `net10.0` ohne Plattformbindung. Der UI-Contract im Core ist toolkit-neutral
|
||||||
|
(`Func<object> CreateView`, `IconKey` statt Bild), damit Core und Module auch kopflos laufen –
|
||||||
|
die Fenster registriert die Shell zentral in `Shell/ModuleViews.cs`.
|
||||||
- **Generic Host** (`Host.CreateDefaultBuilder`), Worker/Services als `IHostedService`.
|
- **Generic Host** (`Host.CreateDefaultBuilder`), Worker/Services als `IHostedService`.
|
||||||
- **Module** über `IModule` (RegisterServices/RegisterUi/Start/Stop); UI über `IModuleUiHost`/`ModuleView`.
|
- **Module** über `IModule` (RegisterServices/RegisterUi/Start/Stop); UI über `IModuleUiHost`/`ModuleView`.
|
||||||
- **Persistenz**: EF Core (Pomelo/MariaDB), Migrationen **extern** angewendet (nicht zur Laufzeit).
|
- **Persistenz**: EF Core (Pomelo/MariaDB), Migrationen **extern** angewendet (nicht zur Laufzeit).
|
||||||
@@ -19,16 +26,27 @@ tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory)
|
|||||||
über die TWS API – aktivierbar mit `IBKR.UseTwsApi`.
|
über die TWS API – aktivierbar mit `IBKR.UseTwsApi`.
|
||||||
- **Analyse-Datenfundament**: `core_decision_journal` (jede Entscheidung + ReasonCode), `core_order_events`,
|
- **Analyse-Datenfundament**: `core_decision_journal` (jede Entscheidung + ReasonCode), `core_order_events`,
|
||||||
`SignalId`-Korrelation, JSONL-Log-Sink (`Logs/{yyyy-MM-dd}.jsonl`) – speist den Supervisor.
|
`SignalId`-Korrelation, JSONL-Log-Sink (`Logs/{yyyy-MM-dd}.jsonl`) – speist den Supervisor.
|
||||||
- Details: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
- Details: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · Offener Stand: [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||||
|
|
||||||
## Build & Test
|
## Build & Test
|
||||||
```bash
|
```bash
|
||||||
dotnet build IBKRTrader.slnx
|
dotnet build IBKRTrader.slnx
|
||||||
dotnet test IBKRTrader.slnx
|
dotnet test IBKRTrader.slnx
|
||||||
dotnet run --project IBKRTrader.App.csproj -- --smoke-ui # Headless-UI-Check
|
|
||||||
dotnet run --project IBKRTrader.App.csproj # App starten
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Anwendung starten:
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/IBKRTrader.App
|
||||||
|
```
|
||||||
|
|
||||||
|
Prüfläufe – beide ohne Anzeigegerät und ohne laufende Dienste, also CI-tauglich:
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/IBKRTrader.App -- --smoke-ui
|
||||||
|
dotnet run --project src/IBKRTrader.Daemon -- --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Kopflos auf Linux (systemd): siehe [deploy/README.md](deploy/README.md).
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
- `appsettings.Local.json` (gitignored) hält den DB-Connection-String (`Database:MySqlConnectionString`).
|
- `appsettings.Local.json` (gitignored) hält den DB-Connection-String (`Database:MySqlConnectionString`).
|
||||||
- `settings.json` (gitignored) – App-Settings (IBKR-Ports, Logging, Worker, Trading).
|
- `settings.json` (gitignored) – App-Settings (IBKR-Ports, Logging, Worker, Trading).
|
||||||
@@ -49,19 +67,25 @@ powershell -File scripts/provision-db.ps1
|
|||||||
- **Accounting** – von der Trading-DB unabhängige Buchführung aus dem IBKR-Kontoauszug (Activity Flex
|
- **Accounting** – von der Trading-DB unabhängige Buchführung aus dem IBKR-Kontoauszug (Activity Flex
|
||||||
Query) → append-only Ledger `acc_*`, Periodenabrechnung/BWA, FX (USD/EUR), CSV/PDF-Export. Kein Handel.
|
Query) → append-only Ledger `acc_*`, Periodenabrechnung/BWA, FX (USD/EUR), CSV/PDF-Export. Kein Handel.
|
||||||
Live-Abruf hinter Interfaces (Offline-Null-Stubs); Steuerschicht bewusst offen. Konzept:
|
Live-Abruf hinter Interfaces (Offline-Null-Stubs); Steuerschicht bewusst offen. Konzept:
|
||||||
[docs/konzepte/KONZEPT-Modul-Accounting.md](docs/konzepte/KONZEPT-Modul-Accounting.md).
|
[docs/archiv/KONZEPT-Modul-Accounting.md](docs/archiv/KONZEPT-Modul-Accounting.md).
|
||||||
- **Supervisor** – read-only KI-Analyse/Forensik über alle Module (OpenRouter-Agent + read-only
|
- **Supervisor** – read-only KI-Analyse/Forensik über alle Module (OpenRouter-Agent + read-only
|
||||||
Tool-Registry, Dossier-Browser, optional MCP-Light). Stützt sich auf das Core-Datenfundament
|
Tool-Registry, Dossier-Browser, optional MCP-Light). Stützt sich auf das Core-Datenfundament
|
||||||
(`core_decision_journal`, `core_order_events`, `SignalId`, JSONL-Logs). Konzept:
|
(`core_decision_journal`, `core_order_events`, `SignalId`, JSONL-Logs). Konzept:
|
||||||
[docs/konzepte/KONZEPT-Modul-Supervisor.md](docs/konzepte/KONZEPT-Modul-Supervisor.md).
|
[docs/archiv/KONZEPT-Modul-Supervisor.md](docs/archiv/KONZEPT-Modul-Supervisor.md).
|
||||||
|
|
||||||
## Status / Nächstes
|
## Status / Nächstes
|
||||||
- Kurskorrektur auf das PolytraderSharp-Konzept (R1–R7) abgeschlossen.
|
|
||||||
- **Accounting**- und **Supervisor**-Modul (inkl. Core-Datenfundament S-0) ergänzt; Live-Abruf (IBKR
|
**➡️ Was noch zu tun ist, steht vollständig in der [Roadmap](docs/ROADMAP.md).** Sie ist seit dem
|
||||||
Flex / OpenRouter-Key) und Steuerschicht sind bewusst noch offen (Stubs/Platzhalter).
|
2026-08-23 das einzige Dokument, das den offenen Stand führt – einschließlich der Ideen, die wir
|
||||||
- **IBKR-Broker über die TWS API / IB Gateway** ist implementiert (Paper-Konto steht, Verbindung
|
bewusst zurückstellen (🧊) und derer, die wir geprüft und verworfen haben (❌). Die früheren
|
||||||
verifiziert) – Design und offene Punkte: [docs/IBKR-Integration.md](docs/IBKR-Integration.md),
|
Konzepte liegen unverändert unter [docs/archiv/](docs/archiv/) und tragen die Herleitung.
|
||||||
TWS-Einstellungen: [docs/TWS-Setup-Checkliste.md](docs/TWS-Setup-Checkliste.md).
|
|
||||||
|
Kurzfassung:
|
||||||
|
- Gebaut: R1–R7 (Kurskorrektur auf das PolytraderSharp-Konzept), Accounting und Supervisor inkl.
|
||||||
|
Core-Datenfundament S-0, Linux-Portierung L0–L6, Deploymentcenter-Anbindung.
|
||||||
|
- **Es hat noch nie eine echte Order gegeben.** Der TWS-Adapter ist gegen das Paper-Konto
|
||||||
|
verifiziert (Verbindung, Konto, Kurse, Optionskette, Greeks, What-If-Order), aber
|
||||||
|
`PlaceOrderAsync` mit echter Ausführung steht aus – daran hängt alles Weitere.
|
||||||
- **Sicherheit:** DB-Passwort rotieren (liegt in der Git-Historie, Commit `ebeb035`).
|
- **Sicherheit:** DB-Passwort rotieren (liegt in der Git-Historie, Commit `ebeb035`).
|
||||||
|
|
||||||
## Sicherheitshinweis
|
## Sicherheitshinweis
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#
|
||||||
|
# systemd-Unit für den kopflosen IBKRTrader-Dienst.
|
||||||
|
#
|
||||||
|
# sudo cp deploy/ibkrtrader.service /etc/systemd/system/
|
||||||
|
# sudo systemctl daemon-reload
|
||||||
|
# sudo systemctl enable --now ibkrtrader
|
||||||
|
# journalctl -u ibkrtrader -f
|
||||||
|
#
|
||||||
|
# Voraussetzungen auf dem Host:
|
||||||
|
# - .NET-10-Runtime (dotnet-runtime-10.0)
|
||||||
|
# - libicu → PFLICHT. Ohne ICU fällt die Auflösung von Windows-Zeitzonen-IDs aus und die
|
||||||
|
# feste de-DE-Formatierung im PDF-Export kippt auf invariant. Beides würde
|
||||||
|
# lautlos falsche Ausgaben erzeugen, nicht etwa einen Fehler.
|
||||||
|
# - fonts-dejavu-core → nur für den PDF-Export des Accounting-Moduls.
|
||||||
|
# - mariadb-client → nur für den BackupWorker (mariadb-dump).
|
||||||
|
#
|
||||||
|
[Unit]
|
||||||
|
Description=IBKRTrader (kopfloser Handelsdienst)
|
||||||
|
Documentation=file:///opt/ibkrtrader/docs/ARCHITECTURE.md
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=notify
|
||||||
|
NotifyAccess=all
|
||||||
|
|
||||||
|
User=ibkrtrader
|
||||||
|
Group=ibkrtrader
|
||||||
|
|
||||||
|
WorkingDirectory=/opt/ibkrtrader
|
||||||
|
ExecStart=/opt/ibkrtrader/IBKRTrader.Daemon
|
||||||
|
|
||||||
|
# Ablageorte. Ohne diese Variablen weicht die Anwendung selbst auf die FHS-Pfade aus, sobald
|
||||||
|
# /opt/ibkrtrader nicht beschreibbar ist – ausdrücklich gesetzt ist es aber nachvollziehbarer.
|
||||||
|
Environment=IBKRTRADER_CONFIG_DIR=/etc/ibkrtrader
|
||||||
|
Environment=IBKRTRADER_DATA_DIR=/var/lib/ibkrtrader
|
||||||
|
Environment=IBKRTRADER_LOG_DIR=/var/log/ibkrtrader
|
||||||
|
|
||||||
|
# Der Master-Key gehört NICHT in diese Datei (sie ist für alle lesbar). Entweder als Datei
|
||||||
|
# /etc/ibkrtrader/master.key mit chmod 600, oder über eine EnvironmentFile mit 600:
|
||||||
|
# EnvironmentFile=/etc/ibkrtrader/secrets.env
|
||||||
|
Environment=DOTNET_EnableDiagnostics=0
|
||||||
|
|
||||||
|
# Geordnetes Herunterfahren: SIGTERM, dann Zeit für offene Broker-Anfragen und den Modul-Stopp.
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=60
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=15
|
||||||
|
|
||||||
|
# Absicherung. Der Dienst braucht nur seine drei Verzeichnisse beschreibbar.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/ibkrtrader /var/log/ibkrtrader
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
LockPersonality=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -8,18 +8,38 @@
|
|||||||
Ziel: modulares C#-Trading-Framework für Interactive-Brokers-Aktien, strukturell wie PolytraderSharp,
|
Ziel: modulares C#-Trading-Framework für Interactive-Brokers-Aktien, strukturell wie PolytraderSharp,
|
||||||
nur dass statt Polymarket über IBKR gehandelt wird.
|
nur dass statt Polymarket über IBKR gehandelt wird.
|
||||||
|
|
||||||
|
> **Was dieses Dokument ist – und was nicht.** Abschnitt 1 beschreibt den **heutigen Aufbau** und ist
|
||||||
|
> die lebende Architektur-Referenz. Abschnitt 3 ist die **Historie**: die Phasen-Checklisten, an denen
|
||||||
|
> nachlesbar ist, was wann und warum gebaut wurde. Beides bleibt gepflegt.
|
||||||
|
>
|
||||||
|
> **Der offene Stand steht seit dem 2026-08-23 nicht mehr hier, sondern in der
|
||||||
|
> [Roadmap](ROADMAP.md).** Die wenigen offenen Kästchen unten sind mit ihrer Roadmap-Kennung
|
||||||
|
> versehen, damit die beiden Listen nicht auseinanderlaufen. Neue Aufgaben gehören ausschließlich
|
||||||
|
> in die Roadmap.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Ziel-Architektur (nach PolytraderSharp)
|
## 1. Ziel-Architektur (nach PolytraderSharp)
|
||||||
|
|
||||||
|
> **Stand seit der Linux-Portierung (2026-08-07):** Alle Projekte sind `net10.0` ohne
|
||||||
|
> Plattformbindung. Die WinForms-Shell ist entfernt; der letzte Stand liegt im Tag
|
||||||
|
> `winforms-final`. Einstiegspunkte sind jetzt `IBKRTrader.App` (mit Oberfläche) und
|
||||||
|
> `IBKRTrader.Daemon` (kopflos, systemd); beide bauen ihren Host über `IBKRTrader.Hosting`.
|
||||||
|
> Analyse und Vorgehen: [archiv/KONZEPT-Linux-Portierung.md](archiv/KONZEPT-Linux-Portierung.md).
|
||||||
|
|
||||||
```
|
```
|
||||||
IBKRTrader.App (WinExe, Root) – Generic Host + Shell (Launcher) + Core-Views
|
src/IBKRTrader.App (WinExe, net10.0) – Oberfläche: Launcher, Shell, Core-Views, Modul-Fenster
|
||||||
│ Program.cs: Host.CreateDefaultBuilder, IConfiguration, Module laden, ShellUiHost, Application.Run
|
│ Program.cs: Host bauen (Hosting), Startprüfungen, dann Avalonia; --smoke-ui ohne Anzeigegerät
|
||||||
│ Ui/: LauncherForm, ShellUiHost, Views/ (Dashboard, Terminal, Settings, Jobs)
|
│ Shell/: AvaloniaUiHost, CoreViews, ModuleViews, ViewIcons, WindowMenu
|
||||||
|
│ Views/: LauncherWindow, Dashboard/Workers/Logs/Settings, Views/Modules/ (3 Modul-Fenster)
|
||||||
│
|
│
|
||||||
├── src/IBKRTrader.Core (classlib, net10.0-windows, UseWindowsForms)
|
src/IBKRTrader.Daemon (Exe, net10.0) – kopfloser Dienst: --check, --db-version, SIGTERM
|
||||||
|
src/IBKRTrader.Hosting (classlib, net10.0) – AppHostBuilder + RunStartupChecks, von beiden geteilt
|
||||||
|
│
|
||||||
|
├── src/IBKRTrader.Core (classlib, net10.0 – plattformneutral, kein UI-Toolkit)
|
||||||
│ ├── Modularity/ IModule (Name, DbPrefix, RegisterServices, RegisterUi, Start/Stop, ActivationBlocker)
|
│ ├── Modularity/ IModule (Name, DbPrefix, RegisterServices, RegisterUi, Start/Stop, ActivationBlocker)
|
||||||
│ │ ModuleView, IModuleUiHost, WindowMenu ← UI-Contract liegt im Core
|
│ │ ModuleView, IModuleUiHost ← toolkit-neutraler UI-Contract (Func<object>, IconKey)
|
||||||
|
│ ├── Time/ AppTimeZone (Betriebszeitzone der Instanz; Persistenz bleibt UTC)
|
||||||
│ ├── Configuration/ DatabaseOptions, ServerVersion-Pinning
|
│ ├── Configuration/ DatabaseOptions, ServerVersion-Pinning
|
||||||
│ ├── DependencyInjection/ AddCorePersistence(...)
|
│ ├── DependencyInjection/ AddCorePersistence(...)
|
||||||
│ ├── Persistence/Ef/ CoreDbContext + Entities + EF-Repositories (hinter Interfaces)
|
│ ├── Persistence/Ef/ CoreDbContext + Entities + EF-Repositories (hinter Interfaces)
|
||||||
@@ -32,7 +52,7 @@ IBKRTrader.App (WinExe, Root) – Generic Host + Shell (Launcher) + C
|
|||||||
│ ├── CongressTradingModule : IModule
|
│ ├── CongressTradingModule : IModule
|
||||||
│ ├── Persistence/Ef/ eigener DbContext (ct_) + Repos
|
│ ├── Persistence/Ef/ eigener DbContext (ct_) + Repos
|
||||||
│ ├── Services/ Scraper + Jobs (IHostedService)
|
│ ├── Services/ Scraper + Jobs (IHostedService)
|
||||||
│ └── Ui/ CongressTradingMainForm (Tabs) via RegisterUi
|
│ └── (kein UI-Code – das Modul-Fenster liegt in der Shell, s. App/Shell/ModuleViews.cs)
|
||||||
│
|
│
|
||||||
└── tests/IBKRTrader.Tests (xUnit, referenziert Core + Module)
|
└── tests/IBKRTrader.Tests (xUnit, referenziert Core + Module)
|
||||||
```
|
```
|
||||||
@@ -45,9 +65,10 @@ IBKRTrader.App (WinExe, Root) – Generic Host + Shell (Launcher) + C
|
|||||||
- **Config**: `appsettings.json` + `appsettings.Local.json` (gitignored, hält Connection-String/Secrets).
|
- **Config**: `appsettings.json` + `appsettings.Local.json` (gitignored, hält Connection-String/Secrets).
|
||||||
- **Modul-Vertrag** `IModule`: `Name`, `DbPrefix`, `RegisterServices(services, config)`,
|
- **Modul-Vertrag** `IModule`: `Name`, `DbPrefix`, `RegisterServices(services, config)`,
|
||||||
`RegisterUi(host, sp)`, `StartAsync/StopAsync`, `GetActivationBlocker(config)`.
|
`RegisterUi(host, sp)`, `StartAsync/StopAsync`, `GetActivationBlocker(config)`.
|
||||||
- **UI = Shell + Views**: Core und Module registrieren `ModuleView`s beim `IModuleUiHost`.
|
- **UI = Shell + Views**: Der Core stellt den toolkit-neutralen Contract (`ModuleView`,
|
||||||
Der Launcher öffnet je View ein Fenster (Einzelinstanz, Re-Open fokussiert). Gemeinsames
|
`IModuleUiHost`), die Shell setzt ihn in Avalonia um. Der Launcher öffnet je View ein Fenster
|
||||||
„Fenster"-Menü (`WindowMenu`) auf jedem Form. Views sind designbare Forms mit `Initialize(sp)`.
|
(Einzelinstanz, erneutes Öffnen fokussiert), jedes Fenster trägt das gemeinsame „Fenster"-Menü.
|
||||||
|
Layout deklarativ in `.axaml`; Module tragen keinen UI-Code.
|
||||||
- **Persistenz**: EF Core (Pomelo/MySQL), `AddDbContextFactory`, Repositories hinter Interfaces.
|
- **Persistenz**: EF Core (Pomelo/MySQL), `AddDbContextFactory`, Repositories hinter Interfaces.
|
||||||
- **Sicherheit**: Master-Key + AES-256-GCM-Verschlüsselung von Credentials at-rest; TLS-Warnung.
|
- **Sicherheit**: Master-Key + AES-256-GCM-Verschlüsselung von Credentials at-rest; TLS-Warnung.
|
||||||
- **Headless-Test**: `--smoke-ui` konstruiert jede View + Launcher ohne Message-Loop.
|
- **Headless-Test**: `--smoke-ui` konstruiert jede View + Launcher ohne Message-Loop.
|
||||||
@@ -85,7 +106,7 @@ IBKRTrader.App (WinExe, Root) – Generic Host + Shell (Launcher) + C
|
|||||||
- [x] `ShellUiHost` + `LauncherForm` (View-Buttons + gemeinsames Fenster-Menü); Core-Views (Workers/Logs/Settings) als eigene Fenster
|
- [x] `ShellUiHost` + `LauncherForm` (View-Buttons + gemeinsames Fenster-Menü); Core-Views (Workers/Logs/Settings) als eigene Fenster
|
||||||
- [x] CongressTrading auf neuen `IModule`-Vertrag; Modul-Worker als `IWorker` registriert
|
- [x] CongressTrading auf neuen `IModule`-Vertrag; Modul-Worker als `IWorker` registriert
|
||||||
- [x] `--smoke-ui` Headless-Test (alle Views + Launcher konstruieren) → grün
|
- [x] `--smoke-ui` Headless-Test (alle Views + Launcher konstruieren) → grün
|
||||||
- [ ] **Offen (R4/R5):** Worker von `WorkerEngine`/`IWorker` auf `IHostedService` umstellen (aktuell noch WorkerEngine)
|
- [x] Worker von `WorkerEngine`/`IWorker` auf `IHostedService` umgestellt — **in R4 erledigt**, die Zeile stand hier bis zum 2026-08-23 faelschlich noch offen
|
||||||
|
|
||||||
### R3 – Persistenz auf EF Core
|
### R3 – Persistenz auf EF Core
|
||||||
**Festgelegt:** EF-Migrationen **extern** (wie PolytraderSharp) – Schema per `dotnet ef database update`,
|
**Festgelegt:** EF-Migrationen **extern** (wie PolytraderSharp) – Schema per `dotnet ef database update`,
|
||||||
@@ -119,7 +140,7 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin
|
|||||||
- [x] `ConfigureSecretProtection` + TLS-Warnung (`SslMode`) beim Start; `master.key` gitignored
|
- [x] `ConfigureSecretProtection` + TLS-Warnung (`SslMode`) beim Start; `master.key` gitignored
|
||||||
- [x] Connection-String in `appsettings.Local.json` (gitignored)
|
- [x] Connection-String in `appsettings.Local.json` (gitignored)
|
||||||
- [x] **5 SecretProtection-Tests** (Round-Trip, Idempotenz, Passthrough, Tamper/Key-Fehler) → 56/56 grün
|
- [x] **5 SecretProtection-Tests** (Round-Trip, Idempotenz, Passthrough, Tamper/Key-Fehler) → 56/56 grün
|
||||||
- [ ] **Offen (Nutzer-Aktion):** geleaktes DB-Passwort rotieren (liegt in Git-Historie via `grundregeln.md`, Commit `ebeb035`); EF-Schema per `dotnet ef database update` auf die DB anwenden
|
- [ ] **Offen → Roadmap [B1](ROADMAP.md) / [B3](ROADMAP.md):** geleaktes DB-Passwort rotieren (liegt in Git-Historie via `grundregeln.md`, Commit `ebeb035`); EF-Schema per `dotnet ef database update` auf die DB anwenden
|
||||||
|
|
||||||
### R7 – Feinschliff ✅
|
### R7 – Feinschliff ✅
|
||||||
- [x] Core-**Dashboard-View** (Gesamtüberblick: Trading-Modus, aggregierte Kennzahlen, geladene Module) + Icon
|
- [x] Core-**Dashboard-View** (Gesamtüberblick: Trading-Modus, aggregierte Kennzahlen, geladene Module) + Icon
|
||||||
@@ -129,8 +150,56 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin
|
|||||||
- [x] Gegen Paper-Konto DUR371528 verifiziert: Verbindung, Konto (NetLiquidation 100.105,50 EUR), Kurse (AAPL/MSFT/NVDA, verzögert) und Fehlerpfade
|
- [x] Gegen Paper-Konto DUR371528 verifiziert: Verbindung, Konto (NetLiquidation 100.105,50 EUR), Kurse (AAPL/MSFT/NVDA, verzögert) und Fehlerpfade
|
||||||
- [x] Orderpfad bis zur Broker-Annahme per **What-If-Order** verifiziert (Aktie + Option, keine Ausführung); **Optionsberechtigung im Paper-Konto bestätigt**
|
- [x] Orderpfad bis zur Broker-Annahme per **What-If-Order** verifiziert (Aktie + Option, keine Ausführung); **Optionsberechtigung im Paper-Konto bestätigt**
|
||||||
- [x] **`IBrokerPortfolioReader`** (Bestand + Ausführungen beim Broker) – eigener Seam neben `IBrokerClient`, Grundlage für den Abgleich der eigenen Buchführung; gegen DUR371528 verifiziert (2 Positionen, 2 Ausführungen inkl. Kommissionen)
|
- [x] **`IBrokerPortfolioReader`** (Bestand + Ausführungen beim Broker) – eigener Seam neben `IBrokerClient`, Grundlage für den Abgleich der eigenen Buchführung; gegen DUR371528 verifiziert (2 Positionen, 2 Ausführungen inkl. Kommissionen)
|
||||||
- [ ] **Offen:** `PlaceOrderAsync` mit echter Ausführung verifizieren (Fill → Buchung); asynchrone Fill-Verfolgung (Orders ohne sofortige Ausführung)
|
- [ ] **Offen → Roadmap [H1](ROADMAP.md) / [H2](ROADMAP.md):** `PlaceOrderAsync` mit echter Ausführung verifizieren (Fill → Buchung); asynchrone Fill-Verfolgung (Orders ohne sofortige Ausführung)
|
||||||
- [ ] IBKR-Account-Credentials mit `EncryptedStringConverter` speichern
|
- [ ] **Offen → Roadmap [H3](ROADMAP.md):** IBKR-Account-Credentials mit `EncryptedStringConverter` speichern
|
||||||
|
|
||||||
|
### L0–L5 – Linux-Portierung: Avalonia statt WinForms ✅ (2026-08-07)
|
||||||
|
Analyse und Begründung: [archiv/KONZEPT-Linux-Portierung.md](archiv/KONZEPT-Linux-Portierung.md).
|
||||||
|
Rückfallpunkt für den letzten WinForms-Stand: Tag `winforms-final`.
|
||||||
|
|
||||||
|
- [x] **L0** `NuGet.config` repariert – drei Pakete hatten kein `packageSourceMapping`-Muster; ein frischer Klon konnte nicht wiederherstellen (auf dem Entwicklungsrechner unsichtbar, weil gecacht)
|
||||||
|
- [x] **L1a** Core und Module von WinForms entkoppelt: `net10.0` statt `net10.0-windows`. UI-Contract toolkit-neutral (`Func<object> CreateView`, `IconKey` statt `System.Drawing.Image` – letzteres ist seit .NET 7 Windows-only). `LoggingService` meldet über `EntryWritten` statt eine `RichTextBox` zu halten
|
||||||
|
- [x] **L1b** **Betriebszeitzone** (`AppTimeZone`, `Trading.ApplicationTimeZoneId`): EU- und US-Instanzen sauber getrennt, Persistenz bleibt UTC. `ParseExecutionTime` verwirft die von TWS gemeldete Börsenzeitzone nicht mehr, sondern rechnet gegen sie nach UTC. Kultur-Fixes (PDF-Beträge fest `de-DE`, Scraper-Datum `TryParseExact`), `BackupWorker` plattformunabhängig, DB-Passwort über `MYSQL_PWD` statt Kommandozeile
|
||||||
|
- [x] **L2** Kopfloser Dienst `IBKRTrader.Daemon` (systemd, `--check`, `--db-version`) + `IBKRTrader.Hosting` als geteilte Host-Zusammenstellung. `AppPaths` (Umgebungsvariable → Binärverzeichnis wenn beschreibbar → FHS)
|
||||||
|
- [x] **L3** Avalonia-Shell (11.3.19, DataGrid 11.3.13) + Core-Ansichten. `PropertyGrid` ersetzt durch eine aus den Attributen erzeugte Einstellungsmaske (11 Abschnitte, 41 Felder)
|
||||||
|
- [x] **L4** Die drei Modul-Fenster portiert; CI-Matrix ubuntu + windows
|
||||||
|
- [x] **L5** WinForms vollständig entfernt – `IBKRTrader.App`, `LauncherForm`, `UI/`, `Properties/Resources.*`
|
||||||
|
|
||||||
|
**Ergebnis:** Alle Projekte `net10.0` ohne Plattformbindung. `publish -r linux-x64` liefert Daemon (11 MB)
|
||||||
|
und Oberfläche (32 MB) ohne eine einzige Windows-Abhängigkeit. Der Smoke-UI-Lauf braucht kein
|
||||||
|
Anzeigegerät mehr und ist damit erstmals Teil der CI.
|
||||||
|
|
||||||
|
**Offen → Roadmap [H4](ROADMAP.md) / [T3](ROADMAP.md):** IB Gateway kopflos betreiben (IBC + Xvfb)
|
||||||
|
– eigene Baustelle, unabhängig vom Code; LiveCharts2 kommt mit den neuen Modulen (Avalonia deshalb
|
||||||
|
auf der 11er-Linie gepinnt).
|
||||||
|
|
||||||
|
### L6 – Namensgebung bereinigt ✅ (2026-08-07)
|
||||||
|
- [x] `IBKRTrader.App.Avalonia` → **`IBKRTrader.App`**. Das Suffix gab es nur, solange daneben eine
|
||||||
|
WinForms-`IBKRTrader.App` stand; seit L5 ist die weg. Assembly, Wurzel-Namensraum und die
|
||||||
|
`avares://`-Ressourcen-URI sind mitgezogen, Git erkennt alles als Umbenennung.
|
||||||
|
- [x] Die `global::Avalonia`-Qualifizierungen entfallen – sie waren nur nötig, weil der Namensraum
|
||||||
|
`IBKRTrader.App.Avalonia` das gleichnamige Paket verdeckte.
|
||||||
|
- [x] **Inhaltlich falsch gewordene Aussagen berichtigt** – das waren die eigentlichen Überbleibsel,
|
||||||
|
nicht die Kommentare: `.agents/rules/grundregeln.md` schrieb weiterhin „C# .NET 10 WinForms",
|
||||||
|
RichTextBox-Logging, `LauncherForm` und `PropertyGrid` vor und hätte die Portierung Stück für
|
||||||
|
Stück rückgängig gemacht. Dazu Core-Kommentare (`LogEntry`, `IWorker`, `ModuleView`) und Doku.
|
||||||
|
|
||||||
|
### DC – Deploymentcenter-Integration ✅ (2026-08-23)
|
||||||
|
Konzept und Begründung: [archiv/KONZEPT-Deploymentcenter-Integration.md](archiv/KONZEPT-Deploymentcenter-Integration.md).
|
||||||
|
Schritte 0–8 der dortigen Reihenfolge sind umgesetzt; der Stand je Schritt steht in §9 dieses Konzepts.
|
||||||
|
|
||||||
|
- [x] Lizenz (Sperrbetrieb statt Abbruch), Watchdog-Heartbeat, Fehler-Stream inkl. globaler
|
||||||
|
Ausnahmebehandler, Update-Prüfung mit `ReleaseCredentials`, `setup.json` + Release-Pipeline.
|
||||||
|
Einbauort ist `IBKRTrader.Hosting/Deploymentcenter/` – den Host teilen sich Shell und Daemon.
|
||||||
|
- [x] Vier Projekt-Befunde vorab bereinigt: Zugangsdaten aus `AppSettings` (P1), `AppPaths`-Rückfall
|
||||||
|
unter Windows (P2), globale Handler (P3), Version zentral in `Directory.Build.props` (P4).
|
||||||
|
- [ ] **Offen (P5) → Roadmap [D1](ROADMAP.md):** Die Gitea-CI checkt das Schwester-Repo `Deploymentcenter` nicht aus. Solange
|
||||||
|
das SDK als Cross-Repo-`ProjectReference` hängt, ist der CI-Lauf rot. Behebt sich mit
|
||||||
|
Schritt 10 (SDK als NuGet-Paket in der Gitea-Registry).
|
||||||
|
- [ ] **Offen (Schritt 9) → Roadmap [D2](ROADMAP.md) / [D5](ROADMAP.md):** Bugtracker-Baustein – setzt voraus, dass das Projekt `ibkrtrader` im
|
||||||
|
DC-WebUI angelegt ist und ein Token mit `bugtracker:report` vorliegt (serverseitige Handarbeit).
|
||||||
|
- [ ] **Offen → Roadmap [D3](ROADMAP.md):** Das Anwenden eines gefundenen Updates ist nicht verdrahtet. `DcUpdateService.LaunchAgent`
|
||||||
|
ist fertig und dokumentiert, es fehlt der Aufrufer, der danach geordnet herunterfährt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -138,7 +207,7 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin
|
|||||||
IBKRTrader entspricht jetzt strukturell dem PolytraderSharp-Konzept: Multi-Projekt (Core + Modul + App + Tests),
|
IBKRTrader entspricht jetzt strukturell dem PolytraderSharp-Konzept: Multi-Projekt (Core + Modul + App + Tests),
|
||||||
Generic Host + `IHostedService`, `IConfiguration`, `IModule`/`ModuleView`/`ShellUiHost`, EF Core (extern migriert),
|
Generic Host + `IHostedService`, `IConfiguration`, `IModule`/`ModuleView`/`ShellUiHost`, EF Core (extern migriert),
|
||||||
Trading-Kern (Risk/Execution/Portfolio, `NullBroker`-Default), CongressTrading-Strategie, Security (Master-Key/AES-GCM).
|
Trading-Kern (Risk/Execution/Portfolio, `NullBroker`-Default), CongressTrading-Strategie, Security (Master-Key/AES-GCM).
|
||||||
**Offen für später:** echte IBKR-Broker-Anbindung (Paper-Gateway), DB-Passwort-Rotation, EF-Schema anwenden.
|
**Offen für später:** siehe [Roadmap](ROADMAP.md) – die IBKR-Broker-Anbindung ist inzwischen gebaut, DB-Passwort-Rotation (B1) und EF-Schema (B3) stehen weiterhin aus.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -92,13 +92,13 @@ komplette Prüfkette – inklusive Handelsberechtigung – und verwirft die Orde
|
|||||||
Fehlte die Berechtigung, hätte IBKR die What-If-Order mit einem Berechtigungsfehler abgelehnt statt
|
Fehlte die Berechtigung, hätte IBKR die What-If-Order mit einem Berechtigungsfehler abgelehnt statt
|
||||||
eine Margin zu liefern. Für **Realtime**-Optionskurse wäre zusätzlich ein OPRA-Abo nötig; ohne Abo
|
eine Margin zu liefern. Für **Realtime**-Optionskurse wäre zusätzlich ein OPRA-Abo nötig; ohne Abo
|
||||||
kommen verzögerte Daten (siehe Marktdaten unten). Modul-Konzept:
|
kommen verzögerte Daten (siehe Marktdaten unten). Modul-Konzept:
|
||||||
[konzepte/KONZEPT-Modul-OptionsWheel.md](konzepte/KONZEPT-Modul-OptionsWheel.md).
|
[archiv/KONZEPT-Modul-OptionsWheel.md](archiv/KONZEPT-Modul-OptionsWheel.md).
|
||||||
|
|
||||||
> **What-If als Testwerkzeug:** Damit lässt sich der gesamte Orderpfad bis zur Broker-Annahme prüfen,
|
> **What-If als Testwerkzeug:** Damit lässt sich der gesamte Orderpfad bis zur Broker-Annahme prüfen,
|
||||||
> ohne eine Position zu eröffnen. Der Adapter nutzt es nicht produktiv – für Vorabprüfungen
|
> ohne eine Position zu eröffnen. Der Adapter nutzt es nicht produktiv – für Vorabprüfungen
|
||||||
> (Margin-Deckung vor einer echten Order) wäre es aber ein naheliegender Ausbau.
|
> (Margin-Deckung vor einer echten Order) wäre es aber ein naheliegender Ausbau.
|
||||||
Welche Daten die API auf diesem Konto tatsächlich liefert – und welche Strategien das trägt –
|
Welche Daten die API auf diesem Konto tatsächlich liefert – und welche Strategien das trägt –
|
||||||
steht gemessen in [konzepte/KONZEPT-Datenlage-und-Strategien.md](konzepte/KONZEPT-Datenlage-und-Strategien.md).
|
steht gemessen in [archiv/KONZEPT-Datenlage-und-Strategien.md](archiv/KONZEPT-Datenlage-und-Strategien.md).
|
||||||
Kurz: Kurshistorie (30 Jahre), Volatilitätshistorie, Optionsketten und Griechen ja;
|
Kurz: Kurshistorie (30 Jahre), Volatilitätshistorie, Optionsketten und Griechen ja;
|
||||||
Fundamentaldaten und Marktscanner nein (Abo nötig).
|
Fundamentaldaten und Marktscanner nein (Abo nötig).
|
||||||
|
|
||||||
@@ -126,7 +126,16 @@ Später zu entscheiden:
|
|||||||
[TWS-Setup-Checkliste](TWS-Setup-Checkliste.md), Abschnitt Verifikation.
|
[TWS-Setup-Checkliste](TWS-Setup-Checkliste.md), Abschnitt Verifikation.
|
||||||
|
|
||||||
## Offen
|
## Offen
|
||||||
1. `PlaceOrderAsync` gegen das Paper-Konto verifizieren (Order → Fill → Buchung).
|
Die offenen Punkte dieses Adapters werden seit dem 2026-08-23 in der
|
||||||
2. IBC für Auto-Login/Neustart einrichten (Server-Betrieb).
|
[Roadmap](ROADMAP.md) gefuehrt, nicht mehr hier – sie haengen mit Aufgaben aus anderen Konzepten
|
||||||
3. Asynchrone Fill-Verfolgung, siehe „Bekannte Grenze" oben.
|
zusammen und standen deshalb doppelt. Es sind:
|
||||||
4. Entscheiden, ob die Marktdaten-Historie von der CP Web API auf `reqHistoricalData` wandert.
|
|
||||||
|
| Roadmap | Punkt |
|
||||||
|
|---|---|
|
||||||
|
| **H1** | `PlaceOrderAsync` gegen das Paper-Konto verifizieren (Order → Fill → Buchung) |
|
||||||
|
| **H2** | Asynchrone Fill-Verfolgung, siehe „Bekannte Grenze" oben – zugleich Voraussetzung fuer OptionsWheel |
|
||||||
|
| **H4** | IBC fuer Auto-Login/Neustart einrichten (Server-Betrieb) |
|
||||||
|
| **T1** | Entscheiden, ob die Marktdaten-Historie von der CP Web API auf `reqHistoricalData` wandert |
|
||||||
|
|
||||||
|
Das **Design** und die **bekannten Grenzen** stehen weiterhin in diesem Dokument – es bleibt die
|
||||||
|
technische Referenz des Adapters.
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# IBKRTrader – Roadmap
|
||||||
|
|
||||||
|
> **Dies ist das einzige Dokument, das sagt, was noch zu tun ist.** Bis zum 2026-08-23 war der
|
||||||
|
> offene Stand über sieben Konzepte, zwei Referenzdokumente und die Phasen-Checkliste der
|
||||||
|
> Architektur verteilt; dieselbe Aufgabe stand teils doppelt unter zwei Namen. Diese Roadmap führt
|
||||||
|
> alles zusammen. Die Quelldokumente bleiben vollständig erhalten und liegen unter
|
||||||
|
> [archiv/](archiv/) – dort steht das **Warum** und die Herleitung, hier das **Was** und das **Wann**.
|
||||||
|
>
|
||||||
|
> Regel für die Zukunft: **Ein offener Punkt gehört hierher.** Steht er nur im Konzept, wird er
|
||||||
|
> vergessen. Wird er erledigt, bekommt er ein ✅ mit Datum – die Zeile bleibt stehen, damit
|
||||||
|
> nachvollziehbar ist, wann etwas fertig wurde.
|
||||||
|
|
||||||
|
**Stand: 2026-08-23**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legende
|
||||||
|
|
||||||
|
| Zeichen | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| ⬜ | Geplant, noch nicht angefangen |
|
||||||
|
| 🔶 | Angefangen, halbfertig – der genaue Rest steht in der Zeile |
|
||||||
|
| ✅ | Fertig, mit Datum |
|
||||||
|
| 🧊 | **Zurückgestellt** – bewusst nicht jetzt, Idee wird vorgehalten. Kein Versehen. |
|
||||||
|
| ❌ | **Verworfen** – geprüft und entschieden. Nicht erneut vorschlagen, ohne den Grund zu entkräften. |
|
||||||
|
|
||||||
|
Die Spalte *Herkunft* nennt die ursprüngliche Kennung im Quelldokument, damit die Herleitung
|
||||||
|
auffindbar bleibt (z. B. `W-2` im OptionsWheel-Konzept, `P5` im Deploymentcenter-Konzept).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wo wir stehen
|
||||||
|
|
||||||
|
Gebaut und grün: Multi-Projekt-Aufbau nach PolytraderSharp, Generic Host, EF Core, Trading-Kern,
|
||||||
|
drei Module (CongressTrading, Accounting, Supervisor), Avalonia-Oberfläche auf Windows **und**
|
||||||
|
Linux, kopfloser Daemon, Deploymentcenter-Anbindung. 198 Tests, Build ohne Warnungen.
|
||||||
|
|
||||||
|
Nicht gebaut: **Es hat noch nie eine echte Order gegeben.** Der Broker-Adapter ist gegen das
|
||||||
|
Paper-Konto verifiziert – Verbindung, Konto, Kurse, Optionskette, Greeks, What-If-Order – aber
|
||||||
|
`PlaceOrderAsync` mit echter Ausführung steht aus. Alles, was danach kommt (Fill-Buchung,
|
||||||
|
Kapitalmodell, OptionsWheel), hängt an diesem einen Nachweis.
|
||||||
|
|
||||||
|
Zwei Sicherungen sind absichtlich getrennt und beide stehen auf „aus": `IBKR.UseTwsApi` schaltet
|
||||||
|
den Adapter, `TradingEnabled` den Handel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stufe 1 – Fundament schließen
|
||||||
|
|
||||||
|
*Diese Punkte blockieren oder gefährden alles Weitere. Sie kommen zuerst.*
|
||||||
|
|
||||||
|
| # | Aufgabe | Stand | Herkunft |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **B1** | **DB-Passwort rotieren.** Das echte Passwort der produktiven MariaDB stand als Vorgabewert in `AppSettings.cs` und liegt damit **in der Git-Historie** (ab Commit `ebeb035`). Der Quelltext trägt seit 2026-08-23 Platzhalter – das ändert an der Historie nichts. Betriebsaktion, nur vom Betreiber ausführbar. | ⬜ | R6, DC-P1 |
|
||||||
|
| **B2** | **Gitea-Zugangstoken aus der Remote-URL nehmen.** `git remote -v` zeigt den Token im Klartext in der URL; er landet so in jedem Log, jeder Fehlermeldung und jedem Screenshot. Auf Credential-Helper oder SSH umstellen. | ⬜ | Befund 2026-08-23 |
|
||||||
|
| **B3** | **EF-Schema auf die Datenbank anwenden** (`dotnet ef database update`). Migrationen werden bewusst extern angewendet, nie zur Laufzeit. | ⬜ | R6 |
|
||||||
|
| **D1** | **CI wieder grün bekommen.** Das Deploymentcenter-SDK hängt als Cross-Repo-`ProjectReference` am Schwester-Repo; `.gitea/workflows/build.yml` checkt nur IBKRTrader aus, also ist der CI-Lauf seit dem 2026-08-23 rot. Das war die bewusst in Kauf genommene Folge der Interimslösung. Auflösung: `dotnet pack` im Deploymentcenter-Repo → Gitea-Registry → `PackageReference` statt Cross-Repo-Pfad → CI-Workflow anpassen. | ⬜ | DC-P5, DC-Schritt 10 |
|
||||||
|
| **H1** | **`PlaceOrderAsync` gegen das Paper-Konto verifizieren** – echte Order, echter Fill, korrekte Buchung. Der einzige Teil des Broker-Adapters, der nie unter realen Bedingungen lief. | ⬜ | R7, IBKR-Integration §Offen 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stufe 2 – Den Handelspfad belastbar machen
|
||||||
|
|
||||||
|
*Ohne das ist kein unbeaufsichtigter Betrieb zu verantworten.*
|
||||||
|
|
||||||
|
| # | Aufgabe | Stand | Herkunft |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **H2** | **Asynchrone Order- und Fill-Verfolgung.** `PlaceOrderAsync` ist synchron gedacht: Ausführung oder Fehlschlag innerhalb `OrderTimeoutSeconds`. Eine Limit-Order im Buch oder eine Market-Order außerhalb der Handelszeiten gilt damit als Fehlschlag – **ist bei IBKR aber weiter aktiv.** Nötig: Order-Zustand persistieren (`core_order_state`), `orderStatus`/`execDetails` dauerhaft mitschreiben, Fill nachbuchen, Zustand über einen App-Neustart hinweg rekonstruieren. Ändert den Seam. **Zugleich Voraussetzung für OptionsWheel (dort `W-2`)** – die Aufgabe stand doppelt in zwei Konzepten. | ⬜ | IBKR-Integration, W-2 |
|
||||||
|
| **H3** | **IBKR-Zugangsdaten mit `EncryptedStringConverter` ablegen.** Der Konverter ist gebaut und getestet, die Credentials nutzen ihn noch nicht. | ⬜ | R7 |
|
||||||
|
| **H4** | **IBC/IBController einrichten** – IBKR erzwingt 2FA und täglichen Neustart des Gateways. Ohne Auto-Login gibt es keinen unbeaufsichtigten Betrieb. Für Linux zusätzlich kopflos (IBC + Xvfb). Reine Betriebsarbeit, unabhängig vom Code. | ⬜ | L-Ergebnis, IBKR-Integration §Offen 2 |
|
||||||
|
| **T1** | **Entscheiden, welcher IBKR-Zugangsweg bleibt.** Es gibt zwei parallele: den TWS-API-Adapter (`Trading/Ibkr/`, der handelnde Pfad) und `IBKRGatewayService` (Client Portal REST, versorgt Instrument-Sync, Kurshistorie und den Watchdog-Heartbeat). Zwei Broker-APIs bedeuten zwei Fehlerbilder, zwei Authentifizierungen und zwei Betriebsvoraussetzungen. Entweder die Historie auf `reqHistoricalData` umziehen und den REST-Weg aufgeben – oder die Doppelung ausdrücklich begründen. | ⬜ | IBKR-Integration §Offen 4, Befund 2026-08-23 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stufe 3 – Options-Fundament im Core
|
||||||
|
|
||||||
|
*Reine Core-Arbeit. Nützt allen Modulen, nicht nur dem Wheel. Der Aktienpfad bleibt unverändert –
|
||||||
|
neue Felder sind optional, `Kind = Stock` ist der Default.*
|
||||||
|
|
||||||
|
| # | Aufgabe | Stand | Herkunft |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **O1** | `InstrumentKind`/`OptionSpec`, `IbkrMapping.Option`, Positionen mit Multiplikator. Heute rechnet `Position.Notional` als `Quantity × AvgPrice` – bei Optionen um Faktor 100 falsch, womit **jedes Risikolimit wirkungslos wäre**. | ⬜ | W-0 |
|
||||||
|
| **O2** | Optionskette + Greeks im Core (`reqSecDefOptParams`, `tickOptionComputation`). Gegen das Paper-Gateway bereits nachgemessen: 24 Verfallstermine, 127 Strikes, Greeks auch mit **verzögerten** Daten über die Tick-Felder 80–83 (Feld 83 = Modell ist die maßgebliche Variante). | ⬜ | W-1 |
|
||||||
|
| **O3** | *(= **H2**, siehe Stufe 2 – dieselbe Aufgabe, hier als Wheel-Voraussetzung geführt)* | ⬜ | W-2 |
|
||||||
|
| **O4** | Positionsabgleich (Zuteilung & Verfall ändern Positionen **ohne** Order von uns) + `RiskService` um sell-to-open und Deckungsprüfung erweitern. Heute lehnt `EvaluateSell` einen Verkauf ohne Bestand grundsätzlich ab – das ist die Kernoperation des Wheels. **Niemals nackt:** Short Call nur mit 100 freien Aktien je Kontrakt, Short Put nur mit reserviertem Cash über Strike × 100. Die Prüfung gehört in den Core, damit ein Modulfehler keine ungedeckte Option schreiben kann. | ⬜ | W-3 |
|
||||||
|
| **O5** | **IV-Rank als Core-Baustein**, nicht als Modul-Interna. Die Kennzahl braucht jede Prämienstrategie und sie gehört neben die Kurshistorie in die Datenschicht. | ⬜ | Datenlage §4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stufe 4 – Kapital- und Buchmodell
|
||||||
|
|
||||||
|
*Wird scharf, sobald ein **zweites** Modul handelt. Heute handelt nur CongressTrading; mit dem
|
||||||
|
Wheel sind es zwei, die sich ein Konto und ein Guthaben teilen. Das vollständig ausgearbeitete
|
||||||
|
Konzept liegt im [Archiv](archiv/Kapital-und-Buchmodell.md) – es ist Referenz, gegen die die
|
||||||
|
Umsetzung geprüft wird, und war nie umgesetzt.*
|
||||||
|
|
||||||
|
| # | Aufgabe | Stand | Herkunft |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **K1** | **Bücher + Eigentumsregeln.** Jede Position hat genau einen Eigentümer; kein Modul fasst die Position eines anderen oder eine manuell angelegte an. Im Core erzwungen, nicht per Konvention. | ⬜ | Kapitalmodell §2 |
|
||||||
|
| **K2** | **Ein Pool, Obergrenzen.** Kein Kapitaltransfer zwischen Büchern, niemals aktives Schließen zum Balancieren. Limit-Verletzung ist ein hartes Gate mit strukturiertem Feedback – die Reaktion entscheidet die Strategie, nicht der Core. | ⬜ | Kapitalmodell §3, §6 |
|
||||||
|
| **K3** | **Margin-Sperre in vier Schichten:** richtige Bemessungsgrundlage (`min(TotalCashValue, AvailableFunds)` − Hausreserve − offene Reservierungen), kein Short, Währungstrennung, Watchdog. Modul-Handel ist strikt cash-only; manueller Handel darf Margin nutzen. | ⬜ | Kapitalmodell §4 |
|
||||||
|
| **K4** | **Reservierungen über den Order-Lebenszyklus** inkl. Crash-Recovery: beim Start alle nicht-terminalen Reservierungen gegen die offenen Broker-Orders abgleichen. Hängt an **H2**. | ⬜ | Kapitalmodell §5 |
|
||||||
|
| **K5** | **Abgleich (Reconciliation)** gegen den Broker: `Broker < Ledger` heißt Break – betroffene Bücher und Symbol für neue Orders sperren, Alarm. Unzugeordnete Positionen in Quarantäne, Eskalation nach 30 Minuten mit Backoff. | ⬜ | Kapitalmodell §8, §9 |
|
||||||
|
| **K6** | **Benachrichtigungen** über Outbox + Sink-Abstraktion. Zielkanal Matrix, Telegram optional. | ⬜ | Kapitalmodell §10 |
|
||||||
|
| **K7** | **Startwerte festlegen** und in `settings.example.json` dokumentieren: `MaxSymbolPercent`, `HouseReserve`, FX-Haircut, Slippage-Puffer, Reservierungs-TTL, Mindestordergröße je Buch, Handelsplatz-Whitelist je Währung. Beim Bauen zu entscheiden. | ⬜ | Kapitalmodell §15 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stufe 5 – Modul OptionsWheel
|
||||||
|
|
||||||
|
*Erst ab hier entsteht das Modul selbst. Vollautomatisch von Anfang an – Sicherungen sind Schalter
|
||||||
|
und Limits, keine Klick-Freigabe. Strike-Wahl delta-basiert im Band 0,15–0,30. Nur Watchlist,
|
||||||
|
kein Screening.*
|
||||||
|
|
||||||
|
| # | Aufgabe | Stand | Herkunft |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **W1** | Modul-Gerüst: `IModule`, `ow_`-DbContext + Migration, UI-Tabs, Watchlist. Abschluss: App startet, `--smoke-ui` grün, **kein Handel**. | ⬜ | W-4 |
|
||||||
|
| **W2** | Reine Strategie-Logik + Tests: `StrikeSelector`, Zustandsautomat je Ticker, `RollDecider`, `PremiumMath`, `CoverageCalculator`. Hohe Testabdeckung ohne Broker. | ⬜ | W-5 |
|
||||||
|
| **W3** | Verdrahtung + vollautomatischer **Paper**-Betrieb über mehrere Verfallszyklen. Abschluss: mindestens ein vollständiger Wheel-Durchlauf im Paper. | ⬜ | W-6 |
|
||||||
|
| **W4** | **Earnings-Sperre über den IV-Behelf.** Keine neuen Legs, wenn die IV des Basiswerts deutlich über ihrem 30-Tage-Mittel liegt. Die saubere Lösung (echte Termine für Quartalszahlen) ist über die TWS API **nicht** erreichbar – Fehler 10358, Refinitiv-Abo nötig. Einzige Stelle, an der uns eine externe Quelle ernsthaft fehlt. | ⬜ | W-§7.4, Datenlage §2 |
|
||||||
|
| **W5** | **Accounting-Anschluss für Optionen:** eigene Buchungskategorien im `AccountingClassifier` für Prämien, Zuteilungen und Abrufe. Der `RealizedPnlEngine` (FIFO) kennt heute weder Multiplikator noch die Einstandsverschiebung durch Zuteilung. Arbeit im Accounting-Modul, nicht im Wheel. | ⬜ | W-§7.5 |
|
||||||
|
| **W6** | **Live-Freigabe.** Eigene Entscheidung nach W3, kein technischer Schritt. | ⬜ | W-7 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Laufende Bahnen
|
||||||
|
|
||||||
|
*Hängen an keiner Stufe und können jederzeit dazwischen laufen.*
|
||||||
|
|
||||||
|
### Auslieferung / Deploymentcenter
|
||||||
|
|
||||||
|
| # | Aufgabe | Stand | Herkunft |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **D2** | **Projekt `ibkrtrader` im DC-WebUI anlegen** + Token mit `watchdog:ping` und `bugtracker:report`. Serverseitige Handarbeit; ohne sie bleiben Heartbeat, Lizenz und Bugtracker wirkungslos. | ⬜ | DC-Schritt 9 |
|
||||||
|
| **D3** | **Update-Anwenden verdrahten.** Die Update-**Prüfung** läuft beim Start. `DcUpdateService.LaunchAgent` ist fertig und dokumentiert (`exitCurrentApp` bewusst immer `false`), aber **kein Aufrufer** fährt danach geordnet herunter. Gefundene Updates werden also nie eingespielt. | 🔶 | DC-Schritt 7 |
|
||||||
|
| **D4** | **Erstes echtes Release fahren.** Pipeline (`scripts/release.*`, `setup.json`) steht, ist aber nie gelaufen. Dabei entsteht `packager.config.json` unter `.dc-tools/`. **Achtung:** Die `preservePatterns` müssen beim *ersten* Release stimmen – ein Update, das `settings.json` überschreibt, nimmt einer laufenden Installation Datenbank, Token und Flex-Zugang gleichzeitig. Ebenso muss der erste Build bereits den `licenseKey` mitgeben, sonst fällt die Tür hinter dem ersten Release zu (bei Predictalytics genau so passiert). | ⬜ | DC-Schritt 8, P6, D2 |
|
||||||
|
| **D5** | **Bugtracker-Baustein** nach `AGENTS.md` / `.agents/rules`. Braucht **D2**. | ⬜ | DC-Schritt 9 |
|
||||||
|
| **D6** | **Lizenzfenster für die Shell.** `LicenseGuard` läuft mit `allowPrompt: false` – weder Shell noch Daemon dürfen auf eine Konsoleneingabe warten, die nie kommt. Ein eigenes Fenster für die Desktop-Shell war als Folgeschritt vorgesehen. | ⬜ | DC §5.2 |
|
||||||
|
|
||||||
|
### Accounting
|
||||||
|
|
||||||
|
| # | Aufgabe | Stand | Herkunft |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A1** | **Live-IBKR-Flex-Abruf** (Token + Query-Id) und Balance-Anker. Heute liegen dort Offline-Null-Stubs: Das Modul läuft vollständig und bucht dabei korrekt nichts. Ohne diesen Schritt entstehen **keine echten Buchungen** – das Modul ist lauffähig, aber nicht in Betrieb. | ⬜ | Accounting §6 |
|
||||||
|
| **A2** | **EZB-FX-Ingest** (`acc_fx_rates` füllen) für die EUR-Ansicht. USD als Basis ist sofort verfügbar. | ⬜ | Accounting §6 |
|
||||||
|
| **A3** | **Steuerschicht.** Jurisdiktion (DE-Kapitalertragsteuer / US Form 8949) ist **nicht festgelegt**. Der neutrale Ledger und die Abrechnung gelten unabhängig davon; die Steuer-Engine ist als klar abgetrennter Platzhalter angelegt. Das Jurisdiktionsprofil wird einmalig gesetzt und danach in der DB verankert. **Keine Steuerberatung.** | ⬜ | Accounting §6, Kapitalmodell §11 |
|
||||||
|
|
||||||
|
### Supervisor
|
||||||
|
|
||||||
|
| # | Aufgabe | Stand | Herkunft |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **S1** | **Counterfactual-Kursauflösung für Aktien** (späterer Kurs vs. Signalpreis) – Interface und Stub sind vorhanden, die Auflösung fehlt. | ⬜ | Supervisor §offen |
|
||||||
|
| **S2** | **Externer Versand des Tagesberichts.** Heute nur Persistenz und Log. Sollte denselben Outbox-/Sink-Weg nehmen wie **K6**, statt einen zweiten zu bauen. | ⬜ | Supervisor §offen |
|
||||||
|
|
||||||
|
### Technische Schulden
|
||||||
|
|
||||||
|
| # | Aufgabe | Stand | Herkunft |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **T2** | **Log-Level ist nicht konfigurierbar.** `LoggingService.SetMinLevel` ist der einzige Setter und wird nie gerufen; `_minLevel` steht damit fest auf `Info`, und da `Info = 0` der kleinste Wert ist, filtert die Prüfung `level < _minLevel` nie etwas. Entweder an die Einstellungen anbinden oder den Filter aufgeben. | ⬜ | Befund 2026-08-23 |
|
||||||
|
| **T3** | **Es gibt kein einziges Diagramm.** LiveCharts2 ist in der NuGet-Allowlist vorgesehen und **Avalonia ist deswegen auf der 11er-Linie festgehalten** (11.3.19 / DataGrid 11.3.13). Diese Pinnung kostet uns Avalonia 12, ohne dass bisher ein Diagramm existiert. Entweder mit den neuen Modulen einlösen oder die Pinnung aufgeben. | ⬜ | L-Ergebnis, grundregeln |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧊 Zurückgestellt – vorgehalten, aber nicht jetzt
|
||||||
|
|
||||||
|
*Bewusste Entscheidungen, keine Versäumnisse. Jede Zeile nennt, **was sie wieder aktuell macht**.*
|
||||||
|
|
||||||
|
| Idee | Warum nicht jetzt | Wird aktuell, wenn … | Herkunft |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Trendfolge-/Momentum-Modul** auf Tagesbasis als zweites Standbein | Datenlage ist komfortabel (10 J Tagesbars, dividendenbereinigt), das Risiko liegt in der Strategie. Aber ein zweites handelndes Modul vor dem Kapitalmodell wäre fahrlässig. | Wheel im Paper läuft **und** Stufe 4 steht | Datenlage §4 |
|
||||||
|
| **Marktdaten-Abo US-Realtime** (NYSE/AMEX/NASDAQ) | Schaltet Scanner und Realtime frei. Für Tages- und Prämienstrategien **nicht nötig** – unsere Signale kommen aus abgeschlossenen Bars, die 15-Minuten-Verzögerung ist dabei irrelevant. | wir Screening oder Intraday wollen | Datenlage §4 |
|
||||||
|
| **OPRA-Abo** (Optionen-Realtime) | Verbessert die Ausführungsqualität beim Wheel; für die Strike-Auswahl nachweislich nicht erforderlich (Greeks funktionieren verzögert). | die Ausführungsqualität im Paper-Betrieb messbar stört | W-§7.3 |
|
||||||
|
| **Refinitiv-Fundamentaldaten** | Der Kandidat mit dem größten qualitativen Sprung: löst die Earnings-Sperre und öffnet fundamentale Ansätze. Kostet aber Geld für ein System, das noch nie eine Order platziert hat. | der IV-Behelf (**W4**) sich als zu unscharf erweist | Datenlage §4 |
|
||||||
|
| **Self-Cross-Netting** (Modul A kauft, B verkauft dasselbe Symbol) | Bei unserer Handelsfrequenz unrealistisch. **Billige Vorstufe stattdessen:** ist im Order-Gateway eine gegenläufige Order für dasselbe Symbol pending, wird protokolliert und gewarnt – der Lock ist ohnehin da. Das liefert Daten darüber, ob das Problem je real wird. | die Warnung tatsächlich anschlägt | Kapitalmodell §14 |
|
||||||
|
| **Corporate Actions** (Splits, Spin-offs) in der Buchzuordnung | Vorerst über den Flex-Abgleich als Break sichtbar, manuelle Zuordnung. | ein Break real auftritt | Kapitalmodell §14 |
|
||||||
|
| **Web-UI** über die interne REST-API | Die REST-API existiert, ein Web-UI ist reine Zusatzarbeit ohne Betriebsnutzen, solange Shell und Daemon reichen. | Fernzugriff nötig wird | grundregeln |
|
||||||
|
| **Desktop-Distribution der Shell** über die Release-Pipeline | Die Vorlage nimmt genau eine csproj für alle Runtimes, und das Skript bleibt laut Anleitung unverändert. Release-Kandidat ist deshalb `IBKRTrader.Daemon`. | die Shell auf fremden Rechnern laufen soll | release.config.json |
|
||||||
|
| **Ungeprüfte Datenfragen**: `reqNewsArticle` (Volltext), Reichweite der Nachrichtenhistorie, Markttiefe (`reqMktDepth`), leeres `reqHistogramData`, Verlässlichkeit verzögerter Greeks außerhalb der Handelszeiten, Ratenbegrenzung bei Historienabrufen | Keine dieser Fragen blockiert die geplanten Strategien. | eine Strategie sie braucht – die Ratenbegrenzung schon bei einem nächtlichen Watchlist-Abruf | Datenlage §5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Verworfen – geprüft und entschieden
|
||||||
|
|
||||||
|
*Damit nichts davon in sechs Monaten erneut als gute Idee auftaucht.*
|
||||||
|
|
||||||
|
| Idee | Warum verworfen |
|
||||||
|
|---|---|
|
||||||
|
| **Echte IBKR-Sub-Accounts** (Advisor-/Family-Struktur) zur brokerseitigen Trennung der Bücher | Erfordert Kontotypwechsel und feste Vorabaufteilung des Kapitals – deutlich unflexibler als virtuelle Bücher, die denselben Zweck erfüllen. |
|
||||||
|
| **`InvariantGlobalization=true`** für ein schlankes Linux-Image | In der Portierungsanalyse noch empfohlen, **wäre ein Fehler gewesen**: ohne ICU fällt die Auflösung von Windows-Zeitzonen-IDs aus und die feste `de-DE`-Formatierung des PDF-Exports kippt auf invariant. Beides lautlos. Der Daemon setzt es ausdrücklich auf `false`. |
|
||||||
|
| **Fundamentales Screening** (Value, Quality, Growth) | Keine Fundamentaldaten über die TWS API. |
|
||||||
|
| **Earnings-Strategien** (Straddle vor Zahlen, Post-Earnings-Drift) | Keine Termine für Quartalszahlen erreichbar (Fehler 10358). |
|
||||||
|
| **Marktweite Anomalie-Suche / Screener-getriebene Auswahl** | Der Marktscanner ist ohne Realtime-Abo gesperrt. Jede Strategie arbeitet auf einer gepflegten Watchlist. |
|
||||||
|
| **Daytrading, Scalping, Orderbuch-Strategien** | Verzögerte Kurse (~15 Min). Ausführung träfe den Markt zu spät. |
|
||||||
|
| **Gap-Strategien auf Eröffnung** | Eröffnungskurs kommt verzögert – nur mit Realtime-Abo sinnvoll. |
|
||||||
|
| **Strike-Wahl über prozentualen Abstand** statt delta-basiert | Als Ersatzlösung gedacht, falls Greeks mit verzögerten Daten nicht funktionieren. Sie funktionieren (am 2026-08-04 gemessen). Bleibt im `StrikeSelector` nur als Rückfalllinie. |
|
||||||
|
| **Agent-zu-Agent-Orchestrierung im Supervisor** | Bewusst nicht: Profile sind System-Prompt + Tool-Subset über *einer* Infrastruktur. Einfacher und nachvollziehbarer. |
|
||||||
|
| **Modulzuordnung als Korrektur des Steuerbuchs** | Harte Regel: Das Managementbuch beeinflusst das Steuerbuch **niemals**. Die Zuordnung ist eine optionale, nicht-autoritative Beistelltabelle – fehlt sie oder ist sie falsch, ändert sich am Steuerergebnis exakt nichts. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fertig – der Weg bis hierher
|
||||||
|
|
||||||
|
*Kurzfassung. Die vollständigen Phasen-Checklisten stehen weiterhin in [ARCHITECTURE.md](ARCHITECTURE.md).*
|
||||||
|
|
||||||
|
| Abschnitt | Inhalt | Fertig |
|
||||||
|
|---|---|---|
|
||||||
|
| **R1–R7** | Kurskorrektur auf das PolytraderSharp-Konzept: Multi-Projekt, Generic Host, EF Core, Trading-Kern, CongressTrading-Strategie, Security, Dashboard, TWS-Broker-Adapter | 2026-07 |
|
||||||
|
| **S-0–S-4** | Supervisor: Datenfundament (Entscheidungsjournal, Order-Events, `SignalId`, JSONL-Logs), Dossiers, OpenRouter-Agent mit read-only Tools, Berichte, MCP-Light | 2026-07-30 |
|
||||||
|
| **Accounting** | `acc_`-Schema, append-only Ingest, Klassifizierung, Abrechnung, FX, CSV-/PDF-Export – mit Offline-Null-Stubs | 2026-07-30 |
|
||||||
|
| **L0–L6** | Linux-Portierung: Avalonia statt WinForms, Betriebszeitzone, kopfloser Daemon, geteiltes Hosting, WinForms vollständig entfernt, Namensgebung bereinigt | 2026-08-07 |
|
||||||
|
| **DC 0–8** | Deploymentcenter: Lizenz mit Sperrbetrieb, Watchdog-Heartbeat, globale Ausnahmebehandler + Fehler-Stream, Update-Prüfung, `setup.json`, Release-Pipeline | 2026-08-23 |
|
||||||
|
| **Frühjahrsputz** | Toter Code entfernt, ungenutzte Symbole und Paketmuster raus, Dokumentenstand an die Wirklichkeit angeglichen | 2026-08-23 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archiv
|
||||||
|
|
||||||
|
Die Quelldokumente sind **vollständig erhalten** und liegen unter [archiv/](archiv/). Sie werden
|
||||||
|
nicht mehr gepflegt – der Stand steht hier –, aber sie tragen die Herleitung, die Messwerte und die
|
||||||
|
Begründungen, die eine Roadmap nicht fassen kann.
|
||||||
|
|
||||||
|
| Dokument | Was darin steht, das hier fehlt |
|
||||||
|
|---|---|
|
||||||
|
| [Kapital-und-Buchmodell.md](archiv/Kapital-und-Buchmodell.md) | Die vollständige Spezifikation: die drei Wahrheiten, Eigentumsregeln, Reservierungs-Lebenszyklus, Invarianten und Testkatalog. Referenz für Stufe 4. |
|
||||||
|
| [KONZEPT-Modul-OptionsWheel.md](archiv/KONZEPT-Modul-OptionsWheel.md) | Zustandsautomat, Regelwerk mit Vorgabewerten, `ow_`-Datenmodell, Sicherungen. Referenz für Stufe 5. |
|
||||||
|
| [KONZEPT-Datenlage-und-Strategien.md](archiv/KONZEPT-Datenlage-und-Strategien.md) | **Gegen das laufende Paper-Gateway gemessen**, nicht aus der Doku übernommen: was die TWS API liefert und welche Strategien das trägt. Grundlage der Zurückgestellt- und Verworfen-Listen. |
|
||||||
|
| [KONZEPT-Deploymentcenter-Integration.md](archiv/KONZEPT-Deploymentcenter-Integration.md) | Die bewussten Abweichungen vom DC-Leitfaden (Sperrbetrieb statt Prozessende, `exitCurrentApp: false`) samt Begründung, die Befunde P1–P6 und D1–D7. |
|
||||||
|
| [KONZEPT-Linux-Portierung.md](archiv/KONZEPT-Linux-Portierung.md) | Die Analyse vor dem Umbau: Fundstellenverzeichnis, Aufwandsschätzung und was anders kam als geschätzt. |
|
||||||
|
| [KONZEPT-Modul-Accounting.md](archiv/KONZEPT-Modul-Accounting.md) | Leitprinzipien (unabhängige Quelle, Idempotenz, append-only), Datenbeschaffung über Flex Query. |
|
||||||
|
| [KONZEPT-Modul-Supervisor.md](archiv/KONZEPT-Modul-Supervisor.md) | S-0 bis S-4 im Detail, Tool-Registry, Sicherheitsgrenzen des Agenten. |
|
||||||
|
|
||||||
|
**Weiter gepflegt** werden – sie sind Referenz, keine Planung:
|
||||||
|
[ARCHITECTURE.md](ARCHITECTURE.md) (Aufbau und Phasen-Historie),
|
||||||
|
[IBKR-Integration.md](IBKR-Integration.md) (Adapter-Design und Grenzen),
|
||||||
|
[TWS-Setup-Checkliste.md](TWS-Setup-Checkliste.md) (Einrichtung eines neuen Systems).
|
||||||
@@ -68,7 +68,9 @@ der Adapter ab und bleibt inaktiv, statt auf dem falschen Konto zu handeln.
|
|||||||
|
|
||||||
## 7. Verifikation
|
## 7. Verifikation
|
||||||
|
|
||||||
- [ ] Port erreichbar? `Test-NetConnection 127.0.0.1 -Port 4002` → `TcpTestSucceeded: True`
|
- [ ] Port erreichbar?
|
||||||
|
Windows: `Test-NetConnection 127.0.0.1 -Port 4002` → `TcpTestSucceeded: True`
|
||||||
|
Linux: `ss -ltn '( sport = :4002 )'` bzw. `nc -zv 127.0.0.1 4002`
|
||||||
- [ ] API-Verbindung: Verbindungstest ausführen (Konto-ID, NetLiquidation, Positionen müssen
|
- [ ] API-Verbindung: Verbindungstest ausführen (Konto-ID, NetLiquidation, Positionen müssen
|
||||||
kommen). Hängt der Handshake > 10 s → Trusted IP fehlt (Punkt 3) oder Popup offen.
|
kommen). Hängt der Handshake > 10 s → Trusted IP fehlt (Punkt 3) oder Popup offen.
|
||||||
|
|
||||||
@@ -108,6 +110,103 @@ bei jeder Kursanfrage der Normalfall, die Kurse kommen danach trotzdem.
|
|||||||
- Einstellungsänderungen im API-Dialog immer mit **Übernehmen/OK** abschließen; solange der
|
- Einstellungsänderungen im API-Dialog immer mit **Übernehmen/OK** abschließen; solange der
|
||||||
Dialog offen ist, gelten sie nicht.
|
Dialog offen ist, gelten sie nicht.
|
||||||
|
|
||||||
|
## Unterschiede unter Linux
|
||||||
|
|
||||||
|
Gilt für TWS bzw. IB Gateway auf einem Linux-Host. **Am Protokoll ändert sich nichts**: dieselbe
|
||||||
|
Java-Anwendung, dieselben Ports, derselbe Einstellungsdialog, dieselbe API-Version. Der Adapter
|
||||||
|
(`IBKRTrader.Core/Trading/Ibkr/`) braucht keine Anpassung – das TWS-API-Paket referenziert nur
|
||||||
|
`mscorlib`, `System` und `System.Core`, keine Windows-Assembly, und der Core baut fehlerfrei für
|
||||||
|
`linux-x64` (geprüft 2026-08-04).
|
||||||
|
|
||||||
|
Anders sind Betrieb und Umgebung:
|
||||||
|
|
||||||
|
| Thema | Windows | Linux |
|
||||||
|
|---|---|---|
|
||||||
|
| Installationsverzeichnis | `C:\Jts` | `~/Jts` |
|
||||||
|
| Einstellungen (inkl. Trusted IPs) | verschleiertes Unterverzeichnis je Login | genauso, unter `~/Jts` |
|
||||||
|
| Start | Desktop-Sitzung vorhanden | **X-Server nötig** – headless: `Xvfb` |
|
||||||
|
| Auto-Login/Neustart | IBC als geplanter Task | IBC als **systemd**-Unit (der besser unterstützte Weg) |
|
||||||
|
| Port prüfen | `Test-NetConnection` | `ss -ltn` / `nc -zv` |
|
||||||
|
|
||||||
|
### Worauf konkret zu achten ist
|
||||||
|
|
||||||
|
- [x] **Grafische Sitzung.** TWS ist eine GUI-Anwendung und startet ohne Display nicht.
|
||||||
|
**Unser Aufbau:** Ubuntu-Desktop-VM mit gespiegelter Bildschirmfreigabe per RDP – also eine
|
||||||
|
echte, dauerhaft laufende X-Sitzung. Damit entfällt die Xvfb-Frage, und der Einstellungsdialog
|
||||||
|
(Punkt 3) ist jederzeit erreichbar. **Wichtig:** gespiegelte Freigabe, **keine**
|
||||||
|
RDP-Remoteanmeldung – eine eigene Anmeldesitzung startet einen zweiten Desktop, in dem das
|
||||||
|
laufende TWS nicht sichtbar ist und die Sitzung beim Abmelden mitgeht.
|
||||||
|
(Nur für einen echten headless Server wäre `Xvfb` + `x11vnc` nötig.)
|
||||||
|
- [ ] **Schriftarten installieren** (`fontconfig` plus z. B. `dejavu`). Fehlen sie, startet die
|
||||||
|
Java-Oberfläche gar nicht oder rendert leer – die häufigste Stolperfalle bei schlanken Images.
|
||||||
|
- [ ] **Einstellungen neu setzen, nicht kopieren.** Die API-Einstellungen hängen am Login-Profil
|
||||||
|
unter `~/Jts`. Auf dem neuen Host Punkte 3–5 dieser Checkliste einmal komplett durchgehen.
|
||||||
|
- [ ] **Offline-Installer bevorzugen.** Der selbstaktualisierende Installer kann TWS unbemerkt auf
|
||||||
|
eine neue Version heben, die eine andere API-Serverversion spricht.
|
||||||
|
- [ ] **Zeitzone je Instanz setzen.** `Trading.ApplicationTimeZoneId` steuert die Betriebszeitzone
|
||||||
|
und ist **unabhängig** von der des Hosts. EU-Instanzen `Europe/Berlin`, US-Instanzen
|
||||||
|
`America/New_York`. TWS meldet Ausführungszeiten teils mit, teils ohne Zonenangabe – ohne
|
||||||
|
Angabe greift dieser Wert als Rückfall. Wie sich das im Betrieb beobachten lässt, steht
|
||||||
|
unten unter „Zeitverhalten beobachten".
|
||||||
|
- [ ] **ICU im Image sicherstellen** (`libicu` / `icu-data-full`). Die Zonenauflösung nutzt
|
||||||
|
IANA-IDs (`US/Eastern`); ohne ICU wirft `FindSystemTimeZoneById`. Die Projekte setzen
|
||||||
|
deshalb bewusst `InvariantGlobalization=false` – siehe `IBKRTrader.Daemon.csproj`.
|
||||||
|
- [ ] **Schreibrechte des Dienstbenutzers** auf `~/Jts` prüfen. Bei systemd mit eigenem `User=`
|
||||||
|
braucht dieser ein echtes Home-Verzeichnis. Für die App selbst regelt `AppPaths` die
|
||||||
|
Ablage bereits FHS-konform.
|
||||||
|
- [ ] **Java-Heap** in `tws.vmoptions` (im Installationsverzeichnis) prüfen, wenn viele
|
||||||
|
Instrumente abonniert werden – gleiche Datei wie unter Windows, anderer Pfad.
|
||||||
|
- [ ] **Wenn TWS und App auf verschiedenen Rechnern laufen:** „Nur Verbindungen vom lokalen Host“
|
||||||
|
aus, IP der App-Maschine als Trusted IP eintragen – und weil der API-Socket **unverschlüsselt**
|
||||||
|
ist, nur über VPN oder SSH-Tunnel, nie offen übers Netz.
|
||||||
|
|
||||||
|
> Diese Liste beruht auf Erfahrungswerten zum TWS-Betrieb, **nicht** auf einer Messung auf einem
|
||||||
|
> Linux-Host – anders als der Rest dieser Checkliste. Beim ersten Aufsetzen entsprechend prüfen
|
||||||
|
> und die Punkte hier korrigieren.
|
||||||
|
|
||||||
|
## Zeitverhalten beobachten (Paper-Phase)
|
||||||
|
|
||||||
|
Zwischen einer EU- und einer US-Instanz ist die Zeitzone die gefährlichste Stelle: Ein falscher
|
||||||
|
Wert wirft keinen Fehler, er verschiebt nur Buchungszeiten. Damit das während der Paper-Phase
|
||||||
|
auffällt statt später im Echtbetrieb, schreibt der Adapter drei Dinge mit (Modul `IBKR`).
|
||||||
|
|
||||||
|
**1. Zeitkontext bei jedem Verbindungsaufbau** – eine Info-Zeile, die den gesamten Rahmen festhält:
|
||||||
|
|
||||||
|
```
|
||||||
|
Zeitkontext: Betriebszeitzone Europe/Berlin, Systemzeitzone Europe/Berlin,
|
||||||
|
TWS-Serverzeit 2026-08-04 15:39:18Z, Uhrenversatz +0.2 s.
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit lässt sich jeder spätere Zeitfehler an einer Zeile aufklären, statt im Nachhinein zu raten,
|
||||||
|
wie die Instanz konfiguriert war.
|
||||||
|
|
||||||
|
**2. Uhrenversatz gegen den TWS-Server.** Mehr als 5 s Abweichung ergeben eine **Warnung**. In
|
||||||
|
virtuellen Maschinen ist eine driftende Uhr ein häufiger Fehler – besonders nach Snapshots oder
|
||||||
|
Pausieren der VM.
|
||||||
|
|
||||||
|
**3. Herkunft jedes Ausführungs-Zeitstempels.** Nach jedem Abruf steht im Log, wie viele Zeitpunkte
|
||||||
|
TWS **mit** Zonenangabe gemeldet hat und wie viele über die Betriebszeitzone **angenommen** wurden:
|
||||||
|
|
||||||
|
```
|
||||||
|
Zeitstempel von 2 Ausführung(en): 0 mit gemeldeter Zone, 2 über die Betriebszeitzone Europe/Berlin.
|
||||||
|
```
|
||||||
|
|
||||||
|
Beobachtungsstand 2026-08-04: `execDetails` lieferte die Zeit **ohne** Zonenangabe
|
||||||
|
(`"20260804 17:39:18"`), `reqCompletedOrders` dagegen **mit** (`"... Europe/Berlin"`). Der
|
||||||
|
Normalfall beim Ausführungsabruf ist also die Annahme – genau deshalb wird sie gezählt.
|
||||||
|
|
||||||
|
### Worauf zu achten ist
|
||||||
|
|
||||||
|
| Logmeldung | Bedeutung | Reaktion |
|
||||||
|
|---|---|---|
|
||||||
|
| `Uhrenversatz` über 5 s | Host-Uhr läuft auseinander | Zeitsynchronisation der VM prüfen |
|
||||||
|
| `... wurden gegen die Betriebszeitzone X gerechnet, das System läuft aber auf Y` | Betriebs- und Systemzeitzone gehen auseinander | Prüfen, gegen welche Uhr TWS meldet; einmal mit dem TWS-Fenster gegenlesen |
|
||||||
|
| `Ausführung(en) mit unlesbarem Zeitstempel` | TWS-Format hat sich geändert | Defekt – `IbkrMapping.ResolveExecutionTime` anpassen |
|
||||||
|
|
||||||
|
**Gegenprobe beim Aufsetzen einer Instanz:** Eine Ausführung im TWS-Fenster ansehen und die dort
|
||||||
|
angezeigte Uhrzeit mit der gebuchten vergleichen. Stimmen beide, ist die Zeitzone richtig gesetzt.
|
||||||
|
Das kostet zwei Minuten und ist die einzige verlässliche Probe – alles andere ist Papier.
|
||||||
|
|
||||||
## Unterschiede Live-Betrieb (später)
|
## Unterschiede Live-Betrieb (später)
|
||||||
|
|
||||||
| Punkt | Paper | Live |
|
| Punkt | Paper | Live |
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
> ### 📦 Archiviert am 2026-08-23
|
||||||
|
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
|
||||||
|
> [Roadmap](../ROADMAP.md) – dort und nur dort wird der Stand nachgeführt.
|
||||||
|
>
|
||||||
|
> Es bleibt erhalten, weil es **gegen das laufende Paper-Gateway gemessen** ist und nicht aus der IBKR-Doku uebernommen.
|
||||||
|
> Die Zurueckgestellt- und Verworfen-Listen der Roadmap stuetzen sich auf diese Messwerte. Zum Nachschlagen also weiterhin richtig,
|
||||||
|
> als Aufgabenliste nicht mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Analyse: Datenlage über die TWS API – und welche Strategien sie trägt
|
# Analyse: Datenlage über die TWS API – und welche Strategien sie trägt
|
||||||
|
|
||||||
> Stand: 2026-08-04. **Alle Angaben in Abschnitt 1 und 2 sind gegen das laufende Paper-Gateway
|
> Stand: 2026-08-04. **Alle Angaben in Abschnitt 1 und 2 sind gegen das laufende Paper-Gateway
|
||||||
@@ -0,0 +1,672 @@
|
|||||||
|
> ### 📦 Archiviert am 2026-08-23
|
||||||
|
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
|
||||||
|
> [Roadmap](../ROADMAP.md) (Bahn „Auslieferung / Deploymentcenter") – dort und nur dort wird der Stand nachgeführt.
|
||||||
|
>
|
||||||
|
> Es bleibt erhalten, weil es die bewussten Abweichungen vom DC-Leitfaden begruendet – Sperrbetrieb statt
|
||||||
|
> Prozessende, `exitCurrentApp: false` – und die Befunde P1–P6 und D1–D7 nachweist. Zum Nachschlagen also weiterhin richtig,
|
||||||
|
> als Aufgabenliste nicht mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# KONZEPT: Deploymentcenter-Integration
|
||||||
|
|
||||||
|
> Stand: 2026-08-14 · Deploymentcenter-Version **2.5.1** · Quelle: `J:\Softwareprojekte\Deploymentcenter\docs`,
|
||||||
|
> `GET /api/updateservice/v1/changelog?since=2.4`
|
||||||
|
> Zielprojekt: IBKRTrader (App + Daemon), Projekt-Slug **`ibkrtrader`**
|
||||||
|
> SDK-Zielversion: `Deploymentcenter.Client` **2.5.1** (`DeploymentcenterSdk.Version` mitführen, damit
|
||||||
|
> künftige Anbindungen über `?since=2.5.1` direkt sehen, was sich seither geändert hat)
|
||||||
|
|
||||||
|
> **Nachtrag 2026-08-14:** Die ursprüngliche Fassung dieses Dokuments (Stand DC 2.4) enthielt sieben
|
||||||
|
> Befunde/Bedenken in §8, die dem Deploymentcenter-Entwickler mitgeteilt wurden. Alle sieben sind mit
|
||||||
|
> 2.5.0/2.5.1 behoben — Einzelheiten weiterhin in §8, jetzt mit Fix-Vermerk. Zwei Neuerungen aus
|
||||||
|
> derselben Runde gehen über die ursprünglichen Befunde hinaus und sind in §5.1, §7.2 und §2.2
|
||||||
|
> eingearbeitet: `exitCurrentApp` ist jetzt als harter Kill dokumentiert, und `BuildInfo.targets` liegt
|
||||||
|
> im NuGet-Paket selbst.
|
||||||
|
|
||||||
|
Das Deploymentcenter (`https://dc.mhdf.de`) bündelt fünf Dienste, die IBKRTrader bisher
|
||||||
|
alle nicht hat: **Lizenz**, **Watchdog**, **Error-Reporting**, **Bugtracker** und
|
||||||
|
**UpdateService inkl. Erstinstallation**. Dieses Dokument beschreibt, in welcher
|
||||||
|
Reihenfolge sie angebunden werden, was dabei am Projekt vorher aufgeräumt werden muss
|
||||||
|
und wo die vom Deploymentcenter vorgeschlagenen Muster für ein *handelndes* System
|
||||||
|
nicht ohne Weiteres passen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Ausgangslage
|
||||||
|
|
||||||
|
| | Zustand heute |
|
||||||
|
|---|---|
|
||||||
|
| DC-Anbindung | **keine** — kein Treffer für `Deploymentcenter`, `dc.mhdf.de` oder `DC_TOKEN` im Quelltext |
|
||||||
|
| Version | Zeichenkette `"1.0.0"` in `App/Program.cs:34` und `Daemon/Program.cs:43`, keine `<Version>`, keine `Directory.Build.props` |
|
||||||
|
| Globale Fehlerbehandlung | **keine** — weder `AppDomain.UnhandledException` noch `TaskScheduler.UnobservedTaskException` |
|
||||||
|
| Auslieferung | `dotnet publish` von Hand, `deploy/README.md`, systemd-Unit |
|
||||||
|
| CI | Gitea Actions, Matrix `ubuntu-latest` / `windows-latest`, Checkout **nur** von IBKRTrader |
|
||||||
|
|
||||||
|
Referenzintegration ist **Predictalytics** (`src/Predictalytics.Hosting/Dc*.cs`,
|
||||||
|
`docs/BETRIEB-Deploymentcenter.md`). Sie liegt auf SDK-Stand 2.2 und ist damit
|
||||||
|
unvollständig — siehe [§8](#8-befunde-und-offene-punkte), Punkt D2. Übernommen wird
|
||||||
|
das Muster, nicht der Code eins zu eins.
|
||||||
|
|
||||||
|
Der Einbauort ist **`IBKRTrader.Hosting`**: den Host teilen sich Shell und Daemon
|
||||||
|
(`AppHostBuilder`), damit gilt jede Anbindung automatisch für beide Betriebsformen.
|
||||||
|
`IBKRTrader.Core` bleibt frei davon — der Core soll keine Netzabhängigkeit zu einem
|
||||||
|
Verwaltungsserver bekommen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Voraussetzungen im Deploymentcenter (vor jedem Code)
|
||||||
|
|
||||||
|
Ohne diese Schritte läuft nichts von dem, was danach kommt:
|
||||||
|
|
||||||
|
1. **Projekt `ibkrtrader` anlegen** (WebUI → Projekte). Slug wird von Lizenz,
|
||||||
|
UpdateService, Fehler-Stream und Bugtracker gemeinsam benutzt.
|
||||||
|
2. **Offline-Gnadenfrist festlegen** (`default_cache_ttl_hours`). Vorgabe 168 h.
|
||||||
|
Für einen Handelsdienst, der auch bei gestörter Internetverbindung weiterlaufen
|
||||||
|
soll, ist das die entscheidende Stellschraube — siehe [§5](#5-lizenz).
|
||||||
|
3. **Lizenz für `ibkrtrader` ausstellen.** Ohne sie kommt die Anwendung nach dem
|
||||||
|
ersten Release nicht mehr an ihre Updates (Zugangsschutz seit 2.4).
|
||||||
|
4. **Master-Token** mit `watchdog:ping`, `bugtracker:report` — für die Anwendung.
|
||||||
|
Zweites Token mit `updateservice:publish` — für die Release-Pipeline, gehört
|
||||||
|
**nicht** in die Anwendung.
|
||||||
|
5. **Installationskonto** (Rolle `installer`) für die Erstinstallation.
|
||||||
|
6. **Evaluator-Cron prüfen.** `* * * * * php cli/tick.php --quiet`. Ohne ihn bleibt
|
||||||
|
ein abgestürzter Trading-Daemon im Dashboard dauerhaft grün — der Watchdog wäre
|
||||||
|
dekorativ. Derselbe Job erzeugt auch den Release-Zugangsschutz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Build-Grundlage
|
||||||
|
|
||||||
|
### 2.1 `Directory.Build.props` mit `<Version>`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
`pack-and-deploy` liest die Version aus der Hauptassembly und **bricht bei einer
|
||||||
|
Abweichung zu `--version` ab**. Der Grund ist unangenehm genug dafür: Wird `1.0.1`
|
||||||
|
als `1.0.2` veröffentlicht, aktualisiert die gesamte Installationsbasis, meldet
|
||||||
|
danach weiterhin `1.0.1`, hält das Release erneut für neu — und aktualisiert bei
|
||||||
|
jedem Start wieder.
|
||||||
|
|
||||||
|
Gleichzeitig entfallen die beiden hartkodierten `"1.0.0"` in den `Program.cs`;
|
||||||
|
sie werden durch `BuildInfo.Version` ersetzt.
|
||||||
|
|
||||||
|
### 2.2 SDK-Anbindung — entschieden, in zwei Stufen
|
||||||
|
|
||||||
|
`Deploymentcenter.Client` (`netstandard2.0;net8.0`) wird von `net10.0` problemlos
|
||||||
|
konsumiert. Seit SDK 2.5.0 liegt `BuildInfo.targets` im Paket selbst
|
||||||
|
(`build/`-Ordner, von NuGet automatisch importiert) — der frühere `<Import>` per
|
||||||
|
Cross-Repo-Pfad ist damit **nicht mehr nötig**. Das entscheidet die Frage
|
||||||
|
grundsätzlich zugunsten eines echten NuGet-Bezugs. Geprüft (2026-08-14): Weder
|
||||||
|
`https://dc.mhdf.de/nuget/` noch die Gitea-Package-Registry unter
|
||||||
|
`http://192.168.178.10:8418/api/packages/Richard/nuget/` führen aktuell ein
|
||||||
|
Paket `Deploymentcenter.Client` — es gibt also noch keine Quelle, gegen die eine
|
||||||
|
`PackageReference` auflösen könnte.
|
||||||
|
|
||||||
|
**Deshalb zweistufig:**
|
||||||
|
|
||||||
|
| Stufe | Weg | Zustand |
|
||||||
|
|---|---|---|
|
||||||
|
| **Jetzt (umgesetzt)** | Cross-Repo-`ProjectReference` (Muster Predictalytics), `Deploymentcenter.BuildInfo.targets` weiterhin per `<Import>` eingebunden — der pfadbasierte Import funktioniert laut Leitfaden unverändert weiter, auch wenn er nicht mehr nötig wäre | funktioniert sofort, keine neue Infrastruktur, keine Push-Entscheidung nötig |
|
||||||
|
| **Folgeschritt (offen)** | `dotnet pack` im Deploymentcenter-Repo, Push nach `.../api/packages/Richard/nuget/`, `NuGet.config` um Quelle `gitea` mit `packageSourceMapping` auf `Deploymentcenter.*` ergänzen, `<Import>` durch `<PackageReference Include="Deploymentcenter.Client" Version="2.5.1" />` ersetzen | braucht einen Push in ein fremdes Repo (Deploymentcenter) und Package-Registry-Zugangsdaten — bewusst nicht Teil dieses Durchlaufs |
|
||||||
|
|
||||||
|
Der Cross-Repo-Bezug bricht die Gitea-CI von IBKRTrader (der Runner checkt nur
|
||||||
|
dieses eine Repo aus) — siehe **P5**. Das ist mit der Wahl von Stufe 1 bewusst in
|
||||||
|
Kauf genommen, weil eine funktionierende lokale Integration wichtiger ist als eine
|
||||||
|
grüne CI, die eine noch nicht existierende Paketquelle voraussetzt.
|
||||||
|
|
||||||
|
### 2.3 `BuildInfo`
|
||||||
|
|
||||||
|
`Deploymentcenter.BuildInfo.targets` in die csproj von `IBKRTrader.Hosting`
|
||||||
|
importieren. Die Klasse entsteht in `$(RootNamespace)`, also `IBKRTrader.Hosting.BuildInfo`
|
||||||
|
— erreichbar ohne `using`. Liefert `Version`, `GitCommit`, `GitCommitShort`,
|
||||||
|
`BuildDateUtc`, `Channel`, `Summary`.
|
||||||
|
|
||||||
|
> **Erledigt mit 2.5.1** (vormals §8 D4): `BuildDateUtc` trug die volle Uhrzeit,
|
||||||
|
> `WriteOnlyWhenDifferent` griff dadurch nie, und jedes importierende Projekt wurde
|
||||||
|
> bei jedem Build neu übersetzt. Seit 2.5.1 ist das Feld tagesgenau — höchstens ein
|
||||||
|
> Neubau pro Tag. Vorbedingung: die Targets-Datei im Schwester-Repo muss auf 2.5.1
|
||||||
|
> oder neuer stehen (`git -C ../Deploymentcenter log -1 --oneline -- client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Konfiguration und gemeinsame Bausteine
|
||||||
|
|
||||||
|
### 3.1 `DcConfig` (kompiliert, nicht einstellbar)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace IBKRTrader.Hosting.Deploymentcenter;
|
||||||
|
|
||||||
|
public static class DcConfig
|
||||||
|
{
|
||||||
|
public const string BaseUrl = "https://dc.mhdf.de"; // bewusst KEINE Einstellung
|
||||||
|
public const string ProductSlug = "ibkrtrader";
|
||||||
|
public const string MonitorGroup = "Trading";
|
||||||
|
public static string AppVersion => BuildInfo.Version;
|
||||||
|
public static string GitCommitShort => BuildInfo.GitCommitShort;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Basis-URL entscheidet, wohin die Lizenzprüfung geht und woher ausführbarer
|
||||||
|
Code kommt. Einstellbar wäre sie eine Einladung, die Anwendung auf einen
|
||||||
|
gefälschten Update-Server zu zeigen.
|
||||||
|
|
||||||
|
### 3.2 Neuer Abschnitt in `AppSettings`
|
||||||
|
|
||||||
|
Ergänzt `src/IBKRTrader.Core/Settings/AppSettings.cs` um
|
||||||
|
`DeploymentcenterSettings` (gleiches Muster wie `LoggingSettings`, mit
|
||||||
|
`Category`/`DisplayName`/`Description`, damit es in der Settings-Ansicht auftaucht):
|
||||||
|
|
||||||
|
| Feld | Vorgabe | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `ApiToken` | *(leer)* | `Authorization: Bearer`. Leer = kein Heartbeat, keine Fehlermeldung. `[PasswordPropertyText]` |
|
||||||
|
| `HeartbeatEnabled` | `true` | Dead-Man's-Switch |
|
||||||
|
| `MonitorSource` | *(Hostname)* | Monitorname im Dashboard |
|
||||||
|
| `MonitorInstance` | `default` | mehrere Instanzen (EU/US) desselben Dienstes |
|
||||||
|
| `HeartbeatIntervalSeconds` | `60` | `warning` nach 2×, `down` nach 4× |
|
||||||
|
| `ReportErrors` | `true` | Error/Fatal an den Fehler-Stream |
|
||||||
|
| `CheckUpdatesOnStart` | `true` | prüft nur, installiert nie von selbst |
|
||||||
|
| `UpdateChannel` | `prod` | `prod`, `beta`, `dev` |
|
||||||
|
| `LicenseKey` | *(leer)* | nur Rückfall; Regelfall ist der verschlüsselte Cache |
|
||||||
|
|
||||||
|
`ApiToken` und `LicenseKey` sind schutzbedürftig und laufen über
|
||||||
|
`SecretProtection` (AES-256-GCM at rest), wie die übrigen Geheimnisse des Projekts.
|
||||||
|
|
||||||
|
### 3.3 `DcApiClient`
|
||||||
|
|
||||||
|
Schlanker JSON-Client für Heartbeat und Fehler-Stream (Lizenz und Update bringen
|
||||||
|
ihren eigenen mit). Übernimmt von Predictalytics:
|
||||||
|
|
||||||
|
- `DcApiException` mit stabilem `error.code` aus dem Umschlag
|
||||||
|
`{"status":"error","error":{"code":"…"}}` — reagiert wird auf den Code, nie auf den Text.
|
||||||
|
- `IsPermanent` für `401`/`403`/`unauthorized`: danach wird nicht weiter versucht.
|
||||||
|
- Zeitgrenze 10 s. **Ein nicht erreichbares Deploymentcenter darf den Handel
|
||||||
|
niemals beeinflussen** — jeder Aufruf ist best effort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Watchdog
|
||||||
|
|
||||||
|
Neuer Worker `DcHeartbeatWorker` in `IBKRTrader.Hosting`, registriert wie die
|
||||||
|
übrigen (`IWorker` + `AddHostedService`), damit er in der Worker-Ansicht
|
||||||
|
auftaucht und manuell auslösbar ist.
|
||||||
|
|
||||||
|
`POST /api/watchdog/v1/ping` alle *n* Sekunden mit `source`, `instance`,
|
||||||
|
`interval`, `os`, `version = BuildInfo.Version`, `group = "Trading"`.
|
||||||
|
|
||||||
|
### 4.1 `checks` — was IBKRTrader wirklich über sich weiß
|
||||||
|
|
||||||
|
Ein Heartbeat beweist nur, dass ein Timer läuft. Der klassische Fehlerfall dieses
|
||||||
|
Projekts ist ein anderer: der Prozess lebt, aber die TWS-Verbindung ist seit einer
|
||||||
|
Stunde tot und es kommen keine Kurse mehr.
|
||||||
|
|
||||||
|
| Check | Quelle | `ok` wenn |
|
||||||
|
|---|---|---|
|
||||||
|
| `db` | EF/`MySqlConnector`, `SELECT 1`, gedeckelt auf 5 s | Verbindung steht |
|
||||||
|
| `ibkr_gateway` | `IBKRGatewayService` | verbunden **oder** `UseTwsApi = false` (NullBroker ist ein gewollter Zustand, kein Fehler) |
|
||||||
|
| `market_data` | `IBKRMarketDataRepository` | letzter Tick jünger als *x* min — nur wenn TWS aktiv |
|
||||||
|
| `worker_engine` | `WorkerEngine` | kein Worker im Fehlerzustand |
|
||||||
|
|
||||||
|
Bewusst **kein** Check auf „Trading aktiv": `TradingEnabled = false` ist ein
|
||||||
|
gewollter Betriebszustand und würde den Monitor sonst dauerhaft auf `warning`
|
||||||
|
halten. Er wandert als Metrik mit.
|
||||||
|
|
||||||
|
### 4.2 `metrics`
|
||||||
|
|
||||||
|
`uptime_sec`, `trading_enabled` (0/1), `open_positions`, `orders_today`,
|
||||||
|
`account_value`, `workers_running`, `memory_mb`, `decision_journal_lag_sec`.
|
||||||
|
|
||||||
|
Serverseitig 14 Tage Verlauf mit Abweichung zum 7-Tage-Mittel desselben Monitors —
|
||||||
|
damit lässt sich „heute plötzlich 40 Orders statt 3" erkennen, ohne im
|
||||||
|
Deploymentcenter Schwellwerte für jedes Projekt zu pflegen.
|
||||||
|
|
||||||
|
### 4.3 Sauberes Beenden
|
||||||
|
|
||||||
|
Beim geordneten Herunterfahren (SIGTERM/systemd, Fenster schließen) geht ein Ping
|
||||||
|
mit `status: "stopped"` raus. Ohne ihn folgt wenige Minuten nach jedem
|
||||||
|
planmäßigen Neustart ein Fehlalarm. Der Aufruf gehört in `StopAsync`
|
||||||
|
**vor** das Beenden der übrigen Dienste und braucht eine eigene, kurze Zeitgrenze
|
||||||
|
(3 s), damit ein hängender Monitoring-Server das Herunterfahren nicht blockiert.
|
||||||
|
|
||||||
|
### 4.4 Hierarchie
|
||||||
|
|
||||||
|
Läuft der Daemon auf einem eigenen Host, gehört im WebUI unter
|
||||||
|
**WatchDog → System-Hierarchie** die *Übergeordnete Entität* auf diesen Host
|
||||||
|
gesetzt. Sonst erzeugt ein Maschinenausfall zwei Meldungen für ein Problem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Lizenz
|
||||||
|
|
||||||
|
`LicenseGuard` in `IBKRTrader.Hosting`, aufgerufen aus `RunStartupChecks` —
|
||||||
|
nach der Zeitzone (Logausgaben) und **vor** dem Start der Worker.
|
||||||
|
|
||||||
|
- Produkt-Slug `ibkrtrader`, Hardware-ID v2 (`2:win:…` / `2:lin:…`).
|
||||||
|
- `LicenseClient.DefaultAppVersion = BuildInfo.Version` einmalig beim Start,
|
||||||
|
sonst trägt jede Installation in der Aktivierungsliste dieselbe `1.0.0`.
|
||||||
|
- Revalidierung zur Laufzeit alle 12 h; Warnung, wenn die Gnadenfrist in unter
|
||||||
|
48 h abläuft.
|
||||||
|
- **`LicenseConfig.SetStorageDirectory(AppPaths.ConfigFile("license"))`** —
|
||||||
|
ausdrücklich setzen. Die Vorgabe des SDK löst unter Linux über `$XDG_CONFIG_HOME`
|
||||||
|
bzw. `$HOME` auf; ein systemd-Dienst mit `User=ibkrtrader` und ohne Heimatverzeichnis
|
||||||
|
landet im Rückfall auf `AppContext.BaseDirectory/license`, also unter `/opt` —
|
||||||
|
dort ist nichts schreibbar, und es gäbe **keinen Offline-Cache**. Mit `AppPaths`
|
||||||
|
fügt sich die Ablage in die bereits vorhandene Verzeichnisauflösung des Projekts ein.
|
||||||
|
**Erledigt/entschärft mit 2.5.1** (vormals §8 D4): der Rückfall ohne `$HOME` zeigt
|
||||||
|
jetzt serverseitig auf `/var/lib/<slug>` statt ins Installationsverzeichnis — unser
|
||||||
|
eigenes explizites Setzen bleibt trotzdem die robustere Wahl und ist unabhängig vom
|
||||||
|
SDK-Rückfall.
|
||||||
|
⚠️ **Breaking Change 2.5.1:** `SetStorageDirectory` hängt jetzt selbst `<slug>/license`
|
||||||
|
an (vorher wurde der Pfad roh benutzt). Der Aufruf wird zu
|
||||||
|
`LicenseConfig.SetStorageDirectory(AppPaths.Config)` — **ohne** das eigene
|
||||||
|
`"license"`-Suffix, sonst entstünde `…/license/ibkrtrader/license`.
|
||||||
|
|
||||||
|
### 5.1 Kopflose Schalter
|
||||||
|
|
||||||
|
Konvention aus dem Lizenz-Leitfaden, im Daemon umzusetzen (das SDK bringt keine CLI mit):
|
||||||
|
|
||||||
|
```
|
||||||
|
IBKRTrader.Daemon --license-status
|
||||||
|
IBKRTrader.Daemon --license-set-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
`--license-deactivate` **nicht** verdrahten: es verlangt den `shared_key` des
|
||||||
|
Servers, der gehört nicht in eine ausgelieferte Anwendung. Freigeben läuft über
|
||||||
|
das WebUI (Hardware-Liste → „Freigeben").
|
||||||
|
|
||||||
|
> **Geändert mit 2.5.0:** der `update-agent` nimmt den Lizenzschlüssel nicht mehr
|
||||||
|
> als `--license-key`-Argument entgegen — `ps` zeigt Prozessargumente jedem Nutzer
|
||||||
|
> der Maschine, dieselbe Begründung wie beim Crontab-Verzicht in UPGRADE §5. Der
|
||||||
|
> Weg ist jetzt die Umgebungsvariable `DC_LICENSE_KEY` (`LaunchUpdateAgent` setzt
|
||||||
|
> sie selbst, wenn `licenseKey` übergeben wird — kein eigener Code nötig). Unsere
|
||||||
|
> `--license-set-key`-Implementierung ruft also `LicenseClient.ValidateAsync`
|
||||||
|
> direkt auf und schreibt **nicht** `--license-key` in einen eigenen Aufruf des
|
||||||
|
> Agenten.
|
||||||
|
>
|
||||||
|
> Ebenfalls neu: `EnsureLicensedAsync()` auf `LicenseClient` — nimmt den
|
||||||
|
> zwischengespeicherten Schlüssel, fragt bei Bedarf über `ILicensePrompt` nach,
|
||||||
|
> prüft, fragt bei Ablehnung erneut. `allowPrompt:false` lehnt ohne Cache ab, statt
|
||||||
|
> im Daemon auf eine Eingabe zu warten, die nie kommt — **das ist für den Daemon
|
||||||
|
> die richtige Einstiegsmethode**, nicht das manuelle Zusammensetzen aus
|
||||||
|
> `TryGetCachedKey` + `ValidateAsync`.
|
||||||
|
|
||||||
|
### 5.2 Reaktion — hier weicht IBKRTrader bewusst vom Leitfaden ab
|
||||||
|
|
||||||
|
Die DC-Doku empfiehlt bei einem endgültigen Urteil `Environment.Exit(1)`.
|
||||||
|
**Für einen Dienst mit offenen Positionen ist das die falsche Reaktion.** Ein
|
||||||
|
abgelaufener Vertrag ist kein Grund, ein Portfolio unbeaufsichtigt zu lassen —
|
||||||
|
Stop-Loss- und Exit-Logik müssen weiterlaufen, sonst richtet die Lizenzdurchsetzung
|
||||||
|
mehr Schaden an als der unlizenzierte Betrieb.
|
||||||
|
|
||||||
|
Vorschlag — drei Stufen statt zwei:
|
||||||
|
|
||||||
|
| Ergebnis | Zustände | Reaktion |
|
||||||
|
|---|---|---|
|
||||||
|
| `IsValid` | `valid`, `valid_offline` | Normalbetrieb |
|
||||||
|
| `IsTransient` | `server_unavailable`, `cache_expired` | **kein Urteil, nur keine Auskunft.** Warnen, weiterlaufen, beim nächsten Durchlauf erneut fragen |
|
||||||
|
| Urteil | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | **Sperrbetrieb:** `TradingEnabled` hart auf `false`, keine neuen Einstiege, Risiko-/Exit-Worker und Buchhaltung laufen weiter, Meldung an Watchdog (`status: "warning"`) und Fehler-Stream. Beendet wird erst, wenn keine Position mehr offen ist — oder auf ausdrückliche Anweisung |
|
||||||
|
|
||||||
|
Bei der Desktop-Shell darf es beim Start weiterhin ein Lizenzfenster geben; der
|
||||||
|
**Daemon** darf niemals auf eine Eingabe warten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Error-Reporting
|
||||||
|
|
||||||
|
Zwei Wege in `POST /api/errors/v1/report`, beide in `IBKRTrader.Hosting`:
|
||||||
|
|
||||||
|
1. **Senke am `LoggingService`.** `LoggingService.EntryWritten` existiert bereits
|
||||||
|
und ist genau dafür gedacht (die Oberfläche hängt schon dran). Ein
|
||||||
|
`DcErrorSink` filtert auf `AppLogLevel.Error` und meldet mit
|
||||||
|
`level: "error"`, `build: BuildInfo.Version`, `file`/`line` aus dem Stacktrace,
|
||||||
|
`context` mit `GitCommitShort` und `CorrelationId`/`SignalId`, wenn vorhanden.
|
||||||
|
Die `SignalId` mitzuschicken ist der eigentliche Gewinn: eine Fehlermeldung
|
||||||
|
lässt sich damit im Deploymentcenter direkt gegen das Entscheidungsjournal halten.
|
||||||
|
2. **Globale Handler** — `AppDomain.UnhandledException` (`level: "fatal"`,
|
||||||
|
**blockierend** gesendet, der Prozess ist gleich weg) und
|
||||||
|
`TaskScheduler.UnobservedTaskException`. Beide fehlen im Projekt heute
|
||||||
|
vollständig; sie müssen sowieso her.
|
||||||
|
|
||||||
|
### 6.1 Bremse
|
||||||
|
|
||||||
|
Verpflichtend, sonst verbrennt ein Fehler in einer Handelsschleife das
|
||||||
|
serverseitige Rate-Limit binnen Sekunden:
|
||||||
|
|
||||||
|
- höchstens 20 Meldungen pro Minute,
|
||||||
|
- derselbe Fehler höchstens alle 5 Minuten,
|
||||||
|
- bei `401`/`403` dauerhaft abschalten,
|
||||||
|
- bei `429` Intervall verdoppeln.
|
||||||
|
|
||||||
|
Betriebsbedingtes Rauschen gehört nicht in diese Bremse, sondern in die
|
||||||
|
**Ignore-Regeln** des WebUI: dort wird weitergezählt, aber nicht gemeldet — und
|
||||||
|
eine Alarmschwelle schlägt an, wenn ein bekannter Fehler plötzlich hundertmal so
|
||||||
|
oft auftritt.
|
||||||
|
|
||||||
|
> **Nicht melden:** alles, was Kontodaten, Positionen oder Schlüsselmaterial im
|
||||||
|
> Text führen könnte. Der Fehler-Stream ist eine externe Ablage. Vor dem Senden
|
||||||
|
> wird der Text durch dieselbe Maskierung geschickt, die schon für die Logs gilt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UpdateService, Erstinstallation und Release-Pipeline
|
||||||
|
|
||||||
|
### 7.1 Prüfen, nicht installieren
|
||||||
|
|
||||||
|
`DcUpdateService.CheckAsync` beim Start (`GET /api/updateservice/v1/check`).
|
||||||
|
**Mit Zugangsdaten**, sonst gibt es seit 2.4 nur noch `401`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var key = settings.Deploymentcenter.LicenseKey is { Length: > 0 } k
|
||||||
|
? k
|
||||||
|
: LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
|
||||||
|
|
||||||
|
var check = await new UpdateClient().CheckForUpdateAsync(
|
||||||
|
baseUrl: DcConfig.BaseUrl,
|
||||||
|
projectId: DcConfig.ProductSlug,
|
||||||
|
currentVersion: BuildInfo.Version,
|
||||||
|
channel: settings.Deploymentcenter.UpdateChannel,
|
||||||
|
credentials: ReleaseCredentials.FromLicenseKey(key),
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
if (check.Unauthorized) { /* Lizenzproblem, KEIN Netzwerkfehler — eigene Meldung */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Gefunden wird nur, installiert wird nichts von selbst. Ein Trading-Dienst, der
|
||||||
|
sich unangekündigt neu startet, ist keine akzeptable Vorgabe.
|
||||||
|
|
||||||
|
- Kein Update → Info ins Log.
|
||||||
|
- Update verfügbar → Warnung + Hinweis in der Shell.
|
||||||
|
- Kritisch → zusätzlich Dialog (Shell) bzw. Watchdog-Event (Daemon).
|
||||||
|
|
||||||
|
### 7.2 Anwenden
|
||||||
|
|
||||||
|
Über `UpdateClient.LaunchUpdateAgent(..., licenseKey: key)`; der Agent bekommt
|
||||||
|
`--restart`, `--wait-for-pid`, `--platform` von selbst, den Lizenzschlüssel seit
|
||||||
|
2.5.0 **nicht mehr** als `--license-key`-Argument, sondern über die
|
||||||
|
Umgebungsvariable `DC_LICENSE_KEY` (siehe §5.1). Vorher geht ein
|
||||||
|
`status: "stopped"` an den Watchdog.
|
||||||
|
|
||||||
|
**Vorbedingung, die die Anwendung selbst prüfen muss:** kein offener Auftrag,
|
||||||
|
keine laufende Order. Ein Update, das den Prozess zwischen Order-Absendung und
|
||||||
|
Statusrückmeldung abräumt, hinterlässt eine Order bei IBKR ohne Gegenstück im
|
||||||
|
Journal. Das Update wird also erst nach dem Handelsschluss bzw. bei leerem
|
||||||
|
Auftragsbuch angeboten.
|
||||||
|
|
||||||
|
> **Verschärft mit 2.5.0:** `exitCurrentApp: true` ist jetzt ausdrücklich als
|
||||||
|
> **harter Kill** dokumentiert — laufende `finally`-Blöcke, `IHostApplicationLifetime`
|
||||||
|
> und Destruktoren kommen nicht mehr zum Zug. Für IBKRTrader heißt das: Solange
|
||||||
|
> die Vorbedingung oben nicht sicher geprüft ist (offenes Auftragsbuch, laufende
|
||||||
|
> Buchungstransaktion), muss `exitCurrentApp: false` gesetzt werden. Der Agent
|
||||||
|
> wartet über `--wait-for-pid` ohnehin auf das Prozessende; das eigene
|
||||||
|
> Herunterfahren läuft dann über `host.StopAsync(...)` wie in `App/Program.cs`
|
||||||
|
> bereits vorhanden — **nicht** über `LaunchUpdateAgent` selbst. Zusätzlich
|
||||||
|
> `waitTimeoutSeconds` heraufsetzen, falls das geordnete Beenden (EF-Flush,
|
||||||
|
> Kestrel, Log-Flush, DB-Backup-Worker) länger als die Vorgabe von 60 s dauern
|
||||||
|
> kann — läuft die Zeit ab, bricht der Agent ohne Änderung ab.
|
||||||
|
|
||||||
|
Für den Linux-Dienst siehe [§8 D3](#8-befunde-und-offene-punkte) — dafür gibt es
|
||||||
|
im Deploymentcenter derzeit keinen fertigen Weg.
|
||||||
|
|
||||||
|
### 7.3 Was ausgeliefert wird — Ausschließen vs. Schützen
|
||||||
|
|
||||||
|
Das ist die Stelle, an der Updates in anderen Projekten schon Konfigurationen
|
||||||
|
zerstört haben. Für IBKRTrader gilt:
|
||||||
|
|
||||||
|
| Datei | Liste | Begründung |
|
||||||
|
|---|---|---|
|
||||||
|
| `settings.example.json` | *(im Paket)* | Vorlage mit Platzhaltern — Erstinstallation braucht sie |
|
||||||
|
| `settings.json` | `preservePatterns` | enthält DB-Zugang, DC-Token, Flex-Token. Muss ein Update überleben |
|
||||||
|
| `appsettings.Local.json` | `preservePatterns` | Verbindungszeichenfolge des Zielsystems |
|
||||||
|
| `master.key`, `openrouter.key` | `excludePatterns` | **darf nie ins Paket** |
|
||||||
|
| `Logs/**`, `Backups/**` | `excludePatterns` | Laufzeitdaten |
|
||||||
|
| `*.pdb`, `*.xml` | `excludePatterns` | Build-Artefakte |
|
||||||
|
| `update-agent*` | bewusst entscheiden | mitliefern = aktualisiert sich selbst mit |
|
||||||
|
|
||||||
|
`pack-and-deploy` warnt von sich aus bei Dateien, die nach Zugangsdaten aussehen.
|
||||||
|
Die Warnung ist ernst zu nehmen: Release-Pakete liegen zwar hinter einem
|
||||||
|
Zugangsschutz, aber jeder lizenzierte Kunde kann sie auspacken.
|
||||||
|
|
||||||
|
### 7.4 `setup.json`
|
||||||
|
|
||||||
|
Beschreibt der Erstinstallation, was abzufragen ist; wird ins Paket kopiert.
|
||||||
|
Entwurf für IBKRTrader:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"displayName": "IBKRTrader",
|
||||||
|
"targets": [
|
||||||
|
{ "id": "app", "file": "settings.json", "location": "install", "format": "json" }
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{ "key": "Database:Host", "label": "MySQL-Host", "type": "string" },
|
||||||
|
{ "key": "Database:Database", "label": "Datenbank", "type": "string" },
|
||||||
|
{ "key": "Database:User", "label": "Benutzer", "type": "string" },
|
||||||
|
{ "key": "Database:Password", "label": "Passwort", "type": "secret" },
|
||||||
|
{ "key": "IBKR:Host", "label": "TWS/Gateway", "default": "127.0.0.1" },
|
||||||
|
{ "key": "IBKR:Port", "label": "Port (Paper 4002 / Live 4001)", "type": "int", "default": "4002" },
|
||||||
|
{ "key": "Trading:ApplicationTimeZoneId", "label": "Betriebszeitzone", "default": "Europe/Berlin" },
|
||||||
|
{ "key": "Deploymentcenter:BaseUrl", "source": "detect:baseurl", "type": "url" },
|
||||||
|
{ "key": "Deploymentcenter:ApiToken", "label": "Token dieser Installation",
|
||||||
|
"source": "provision", "scopes": ["watchdog:ping", "bugtracker:report"] },
|
||||||
|
{ "key": "Deploymentcenter:MonitorSource", "source": "detect:hostname" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Zwei Punkte dazu:
|
||||||
|
|
||||||
|
- **`location: "install"` ist nur unter Windows richtig** — und auch dort nur,
|
||||||
|
solange nicht nach `C:\Program Files` installiert wird. Siehe
|
||||||
|
[§8 P2](#8-befunde-und-offene-punkte); solange `AppPaths` nicht korrigiert ist,
|
||||||
|
passen `setup.json` und die tatsächliche Leseposition der Anwendung unter Linux
|
||||||
|
nicht zusammen. **Dieser Punkt blockiert die Erstinstallation und gehört vor §7.4.**
|
||||||
|
- Der Installer schreibt **Klartext**. `SecretProtection` muss also Klartext
|
||||||
|
unverändert durchreichen und beim ersten Speichern verschlüsseln — die Weiche
|
||||||
|
ist zu prüfen und im Test ausdrücklich als *aktiv genutzt* zu beschreiben, damit
|
||||||
|
sie nicht eines Tages als tot entfernt wird.
|
||||||
|
|
||||||
|
### 7.5 Release-Pipeline
|
||||||
|
|
||||||
|
`scripts/release.ps1` und `scripts/release.sh` sind per `curl` von
|
||||||
|
`/docs/release-template/` geholt (wie `release.md` empfiehlt, statt aus dem
|
||||||
|
lokalen Schwester-Repo kopiert — damit ist sichergestellt, dass die kanonische
|
||||||
|
Fassung im Projekt liegt) und **unverändert** gelassen. Nur
|
||||||
|
`scripts/release.config.json` ist ausgefüllt:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project": "ibkrtrader",
|
||||||
|
"csproj": "src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj",
|
||||||
|
"runtimes": ["win-x64", "linux-x64"],
|
||||||
|
"selfContained": true,
|
||||||
|
"setupJson": "setup.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Entschieden: nur der Daemon, keine Zwei-Produkte-Aufteilung.** IBKRTrader hat
|
||||||
|
zwei Einstiegspunkte (`App` = Avalonia-Shell, `Daemon` = kopflos). Die Vorlage
|
||||||
|
nimmt aber genau **eine** `csproj` für alle Runtimes in der Liste — sie ruft
|
||||||
|
`dotnet publish <csproj> -r <rid>` in einer Schleife auf, es gibt keine
|
||||||
|
Möglichkeit, App für `win-x64` und Daemon für `linux-x64` in **demselben** Lauf
|
||||||
|
zu veröffentlichen, ohne das (laut Anleitung unveränderte) Skript anzufassen.
|
||||||
|
Ursprünglich in diesem Dokument vorgeschlagen war genau das ("Windows = App,
|
||||||
|
Linux = Daemon") — das ist mit der Vorlage so nicht umsetzbar und wurde beim
|
||||||
|
Umsetzen korrigiert.
|
||||||
|
|
||||||
|
`IBKRTrader.Daemon` ist der bewusste Kandidat: laut README „plattformneutral"
|
||||||
|
(`net10.0` ohne Plattformbindung), läuft kopflos auf beiden Zielsystemen und
|
||||||
|
ist ohnehin die für den Serverbetrieb vorgesehene Form (`deploy/README.md`).
|
||||||
|
Eine automatisierte Auslieferung der Avalonia-Shell (`App`) als
|
||||||
|
Windows-Desktop-Distribution ist damit **nicht** Teil dieses Durchlaufs — dafür
|
||||||
|
bräuchte es entweder einen zweiten Produkt-Slug (`ibkrtrader-app`, doppelte
|
||||||
|
Lizenz-/Update-Pflege) oder ein angepasstes Release-Skript. Zurückgestellt, bis
|
||||||
|
tatsächlich Bedarf an einer automatisierten Desktop-Auslieferung besteht — bis
|
||||||
|
dahin bleibt `dotnet run --project src/IBKRTrader.App` bzw. eine manuelle
|
||||||
|
`dotnet publish` der übliche Weg für die Shell.
|
||||||
|
|
||||||
|
Zugangsdaten kommen aus `DC_FTP_HOST` / `DC_FTP_USER` / `DC_FTP_PASS` / `DC_TOKEN`,
|
||||||
|
`.dc-tools/` und `artifacts/` sind in der `.gitignore`.
|
||||||
|
|
||||||
|
**`packager.config.json` (`excludePatterns`/`preservePatterns`) ist nicht Teil
|
||||||
|
dieses Durchlaufs.** Sie liegt "neben dem Werkzeug", also unter `.dc-tools/` —
|
||||||
|
das entsteht erst, wenn `pack-and-deploy` beim ersten echten Lauf von
|
||||||
|
`release.ps1`/`release.sh` selbst heruntergeladen wird (Prüfsummen-geprüft).
|
||||||
|
Ein erster Release-Lauf braucht `DC_FTP_*`/`DC_TOKEN` und ist damit eine
|
||||||
|
credentialierte, vom Betreiber auszulösende Aktion. **Sicherheitsnetz bis
|
||||||
|
dahin:** Der Packager bricht seit 2.5.0 ohnehin hart ab, wenn er im
|
||||||
|
Publish-Verzeichnis nach Zugangsdaten aussehende Dateien findet (Name *und*
|
||||||
|
Inhalt) — die in §7.3 beschriebenen Dateien (`settings.json`,
|
||||||
|
`appsettings.Local.json`) sind ohnehin gitignored und landen nur dann im
|
||||||
|
Publish-Output, wenn sie lokal im Projektverzeichnis liegen (`<None Update
|
||||||
|
Condition="Exists(...)">` in den `.csproj`-Dateien kopiert sie dann mit). Bricht
|
||||||
|
`release.ps1` mit einem Hinweis auf eine vermutete Zugangsdatei ab, ist das der
|
||||||
|
Packager, der genau das abfängt — kein Fehlschlag, sondern die Absicherung, die
|
||||||
|
2.5.0 dafür eingeführt hat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Befunde und offene Punkte
|
||||||
|
|
||||||
|
> **Status 2026-08-23:** P1–P4 sind umgesetzt (Einzelheiten in §9). **P5 ist eingetreten**,
|
||||||
|
> P6 entscheidet sich erst beim ersten Release. Der ursprüngliche Vermerk von 2026-08-14:
|
||||||
|
>
|
||||||
|
> **Status 2026-08-14:** Alle sieben ursprünglich an den Deploymentcenter-Entwickler
|
||||||
|
> gemeldeten Befunde (D1–D7) sind mit den Versionen 2.5.0/2.5.1 behoben — bestätigt über
|
||||||
|
> `GET /api/updateservice/v1/changelog?since=2.4`. Der Abschnitt bleibt als Nachweis
|
||||||
|
> stehen, jeder Punkt trägt jetzt einen Fix-Vermerk. Die Projekt-Befunde (P1–P6) sind
|
||||||
|
> davon unabhängig und weiterhin offen — sie werden in diesem Durchlauf abgearbeitet.
|
||||||
|
|
||||||
|
### Am Projekt (P) — muss vor der Integration bereinigt werden
|
||||||
|
|
||||||
|
**P1 · Echte Zugangsdaten als Vorgabewerte im Quelltext.**
|
||||||
|
`src/IBKRTrader.Core/Settings/AppSettings.cs:14-35` trägt Host, Datenbankname,
|
||||||
|
Benutzer und **Passwort** einer produktiven MariaDB als Vorgabe. Die Datei ist
|
||||||
|
versioniert, die Werte stehen damit auch in der Git-Historie — `settings.json`
|
||||||
|
im `.gitignore` hilft dagegen nicht. Das Passwort ist als kompromittiert zu
|
||||||
|
behandeln und zu wechseln; die Vorgaben gehören auf Platzhalter. Zusätzlich
|
||||||
|
brisant, weil die Vorlage künftig im Release-Paket ausgeliefert wird.
|
||||||
|
|
||||||
|
> **Verschärft mit 2.5.0:** der Packager **bricht jetzt hart ab**, wenn er
|
||||||
|
> Zugangsdaten im Publish-Verzeichnis erkennt (Dateiname *und* Inhalt, u. a.
|
||||||
|
> gefülltes `Password=`) — vorher nur eine Warnung, die auf der Preserve-Liste
|
||||||
|
> sogar unterdrückt wurde. Damit ist P1 nicht mehr „sollte bereinigt werden",
|
||||||
|
> sondern **release-blockierend**: Ohne Fix lässt sich `settings.example.json`
|
||||||
|
> gar nicht mehr veröffentlichen.
|
||||||
|
|
||||||
|
**P2 · `AppPaths` fällt unter Windows auf einen Linux-Pfad zurück.**
|
||||||
|
`Configuration/AppPaths.cs:57-71`: ist das Binärverzeichnis nicht beschreibbar,
|
||||||
|
gilt der FHS-Rückfall `/etc/ibkrtrader`. Unter Windows löst .NET das zu
|
||||||
|
`C:\etc\ibkrtrader` auf. Genau dieser Fall tritt ein, sobald der DC-Installer
|
||||||
|
seinen Windows-Vorschlag `Programme\<slug>` benutzt — dann liegen `settings.json`,
|
||||||
|
`master.key` und `Logs/` in `C:\etc\`. Nötig: plattformabhängiger Rückfall
|
||||||
|
(`%ProgramData%\IBKRTrader` bzw. `%APPDATA%`).
|
||||||
|
|
||||||
|
**P3 · Keine globalen Ausnahmebehandler.** Ohne
|
||||||
|
`AppDomain.UnhandledException` und `TaskScheduler.UnobservedTaskException` gibt es
|
||||||
|
keine `fatal`-Meldungen — und heute auch keinen Logeintrag, wenn der Prozess
|
||||||
|
wegbricht. Unabhängig vom Deploymentcenter überfällig.
|
||||||
|
|
||||||
|
**P4 · Version an zwei Stellen hartkodiert.** `App/Program.cs:34` und
|
||||||
|
`Daemon/Program.cs:43`. Ohne `Directory.Build.props` bricht `pack-and-deploy` beim
|
||||||
|
ersten Release ab — zu Recht.
|
||||||
|
|
||||||
|
**P5 · CI kennt das Schwester-Repo nicht.** `.gitea/workflows/build.yml` checkt
|
||||||
|
nur IBKRTrader aus. Eine `ProjectReference` nach `..\..\..\..\Deploymentcenter\…`
|
||||||
|
macht den Build auf beiden Matrix-Zielen rot. Siehe die Entscheidung in §2.2.
|
||||||
|
|
||||||
|
> **Seit 2026-08-23 eingetreten, nicht mehr nur vorhergesagt.** Die
|
||||||
|
> `ProjectReference` ist mit der Integration auf `main` gelandet, die CI ist
|
||||||
|
> damit rot. Das war die bewusst in Kauf genommene Folge der Interimslösung aus
|
||||||
|
> §2.2 — lokal baut die Projektmappe, sobald das Schwester-Repo daneben liegt.
|
||||||
|
> Auflösung ist Schritt 10: SDK als NuGet-Paket in die Gitea-Registry, dann
|
||||||
|
> `PackageReference` statt Cross-Repo-Pfad.
|
||||||
|
|
||||||
|
**P6 · Konfiguration liegt im Installationsverzeichnis.** Unter Windows schreibt
|
||||||
|
`AppPaths` neben die Binärdatei. Das ist mit `preservePatterns` beherrschbar
|
||||||
|
(§7.3), muss aber beim ersten Release stimmen — ein Update, das `settings.json`
|
||||||
|
überschreibt, nimmt einer laufenden Installation Datenbank, Token und Flex-Zugang
|
||||||
|
gleichzeitig.
|
||||||
|
|
||||||
|
### Am Deploymentcenter (D) — gemeldet, Stand vor 2.5.0
|
||||||
|
|
||||||
|
**D1 · Der Lizenzschlüssel als Update-Zugang war nirgends vollständig erklärt.**
|
||||||
|
✅ **Behoben (2.5.1, Doku).** Laut Changelog explizit als „Lückenschluss" genannt:
|
||||||
|
„Woher die Anwendung den Lizenzschlüssel für den Update-Zugang nimmt
|
||||||
|
(`TryGetCachedKey`)". Für uns bleibt zusätzlich `EnsureLicensedAsync()` (neu in
|
||||||
|
2.5.0) relevant — siehe §5.1.
|
||||||
|
|
||||||
|
**D2 · Predictalytics wurde vom Zugangsschutz ausgesperrt.**
|
||||||
|
✅ **Bestätigt und behoben (2.5.1).** Der Changelog bestätigt den Verdacht wörtlich:
|
||||||
|
„bei Predictalytics ist genau das passiert". Fix: Zugangsschutz ist jetzt je
|
||||||
|
Produkt abschaltbar (WebUI → UpdateService → Zugangsschutz) — für Installationsbasen
|
||||||
|
ohne SDK 2.4 lässt sich das Produkt ausnehmen, mit Schlüssel neu ausliefern, danach
|
||||||
|
wieder einschalten. **Für IBKRTrader heißt das:** genau diese Falle von Anfang an
|
||||||
|
vermeiden, indem der erste ausgelieferte Build bereits `licenseKey` mitgibt (§7.5,
|
||||||
|
§9 Reihenfolge).
|
||||||
|
|
||||||
|
**D3 · Für einen systemd-Dienst gab es keinen dokumentierten Update-Weg.**
|
||||||
|
✅ **Behoben (2.5.1, Doku).** Laut Changelog ergänzt: „wie ein laufender
|
||||||
|
systemd-Dienst aktualisiert wird". Bleibt in §7.2/§8-P laufend zu beobachten, ob
|
||||||
|
die neue Beschreibung mit unserem tatsächlichen Vorgehen (`--target-dir`,
|
||||||
|
Dienstbenutzer-Rechte) übereinstimmt — bei der Umsetzung von Aufgabe „Update-Prüfung
|
||||||
|
mit ReleaseCredentials" gegenprüfen.
|
||||||
|
|
||||||
|
**D4 · `LicenseConfig.GetStorageDirectory`/`SetStorageDirectory` war für Dienste
|
||||||
|
unglücklich.** ✅ **Behoben, mit Breaking Change (2.5.1).** Zwei Korrekturen:
|
||||||
|
(a) Rückfall ohne `$HOME` zeigt jetzt auf `/var/lib/<slug>` statt ins
|
||||||
|
Installationsverzeichnis. (b) `SetStorageDirectory` hängt jetzt selbst
|
||||||
|
`<slug>/license` an, statt den Pfad roh zu verwenden — **das ändert unseren
|
||||||
|
Aufruf**, siehe §5 (`AppPaths.Config` statt `AppPaths.ConfigFile("license")`).
|
||||||
|
|
||||||
|
**D5 · `BuildInfo.targets` verhinderte inkrementelle Builds.**
|
||||||
|
✅ **Behoben (2.5.1).** `BuildDateUtc` ist jetzt tagesgenau statt mit voller Uhrzeit,
|
||||||
|
`WriteOnlyWhenDifferent` greift wieder. Siehe §2.3.
|
||||||
|
|
||||||
|
**D6 · Die Empfehlung „bei einem Urteil beenden" passte für handelnde Systeme nicht.**
|
||||||
|
✅ **Behoben (2.5.1, Doku)** — als Lückenschluss genannt: „was statt
|
||||||
|
`Environment.Exit(1)` zu tun ist, wenn Beenden die gefährlichere Option wäre".
|
||||||
|
Unser Sperrbetrieb-Ansatz (§5.2) bleibt die konkrete Umsetzung für IBKRTrader.
|
||||||
|
**Zusätzlich, über D6 hinausgehend:** 2.5.0 dokumentiert `exitCurrentApp: true`
|
||||||
|
jetzt als harten Kill ohne `finally`/Lifecycle-Hooks — praktisch relevanter für uns
|
||||||
|
als die Lizenz-Frage allein, siehe §7.2.
|
||||||
|
|
||||||
|
**D7 · Kleinigkeiten** (README-Verweis auf `AGENT_RELEASE_GUIDE.md`, Rate-Limits
|
||||||
|
nicht durchgängig beziffert). ✅ **Behoben (2.5.1, Doku)** — Changelog nennt
|
||||||
|
„die Drosselungsgrenzen aller Endpunkte" als ergänzt; `docs/README.md` führt jetzt
|
||||||
|
`/docs/release.md` und den Changelog-Endpunkt im Index.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Reihenfolge
|
||||||
|
|
||||||
|
| Schritt | Inhalt | Abhängig von | Stand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | **P1** Zugangsdaten wechseln, Vorgaben auf Platzhalter | — | ✅ Vorgaben sind Platzhalter. **Die Rotation des geleakten Passworts steht weiterhin aus** (Nutzer-Aktion) |
|
||||||
|
| 1 | **P2** `AppPaths`-Rückfall plattformabhängig, **P4** `Directory.Build.props` | — | ✅ 2026-08-23 |
|
||||||
|
| 2 | SDK-Bezug Stufe 1: Cross-Repo-`ProjectReference` (§2.2) | — | ✅ 2026-08-23, inkl. Prüf-Target mit lesbarer Fehlermeldung |
|
||||||
|
| 3 | `DcConfig`, `DeploymentcenterSettings`, `DcApiClient`, `BuildInfo` | 1, 2 | ✅ 2026-08-23 |
|
||||||
|
| 4 | **P3** globale Handler + Fehler-Stream | 3 | ✅ 2026-08-23 (`DcCrashHandlers`, `DcErrorSink`, `DcErrorReporter`) |
|
||||||
|
| 5 | Watchdog-Heartbeat inkl. `stopped` | 3 | ✅ 2026-08-23 (`DcHeartbeatWorker`) |
|
||||||
|
| 6 | Lizenz mit Sperrbetrieb statt Abbruch (§5.2), `EnsureLicensedAsync` | 3 | ✅ 2026-08-23 (`LicenseGuard`) |
|
||||||
|
| 7 | Update-Prüfung **mit** `ReleaseCredentials`, `exitCurrentApp:false` bei offenem Zustand | 6 (braucht den Schlüssel) | 🔶 Die **Prüfung** läuft beim Start. Das **Anwenden** ist es nicht: `LaunchAgent` ist fertig, aber kein Aufrufer fährt danach geordnet herunter |
|
||||||
|
| 8 | `setup.json`, `preserve`/`exclude`, Release-Pipeline (Vorlage per `curl` von `/docs/release-template/`) | 1–7 | ✅ 2026-08-23 (`setup.json`, `scripts/release.*`). **Noch nie ausgeführt** – das erste Release steht aus |
|
||||||
|
| 9 | Bugtracker-Baustein nach `AGENTS.md` / `.agents/rules` | Projekt-Slug angelegt | ⬜ offen — Projekt `ibkrtrader` muss im WebUI erst angelegt werden |
|
||||||
|
| 10 | **Folgeschritt, nicht Teil dieses Durchlaufs:** `Deploymentcenter.Client` als NuGet-Paket in die Gitea-Registry pushen, `<Import>` durch `PackageReference` ersetzen, Gitea-CI (**P5**) auf den Paketbezug umstellen | 2 | ⬜ offen — siehe §2.2 |
|
||||||
|
|
||||||
|
Schritt 7 und 8 hängen zusammen: **der erste ausgelieferte Build muss die
|
||||||
|
Zugangsdaten schon mitbringen.** Für ein Produkt, das noch nie veröffentlicht hat,
|
||||||
|
entsteht `/releases/ibkrtrader/` mit dem ersten Upload und wird vom nächsten
|
||||||
|
`tick.php`-Lauf geschützt — es gibt kein Zeitfenster, das SDK danach nachzurüsten.
|
||||||
|
Wird Schritt 7 übersprungen, fällt die Tür hinter dem ersten Release zu (genau der
|
||||||
|
Fall, der laut Changelog bei Predictalytics bereits eingetreten ist, siehe D2).
|
||||||
|
|
||||||
|
Schritt 9 (Bugtracker) setzt voraus, dass das Projekt `ibkrtrader` im
|
||||||
|
Deploymentcenter-WebUI angelegt ist und ein Token mit `bugtracker:report`
|
||||||
|
vorliegt — beides serverseitige Handarbeit, die dieser Durchlauf nicht auslösen
|
||||||
|
kann. Wird zurückgestellt.
|
||||||
@@ -0,0 +1,714 @@
|
|||||||
|
> ### 📦 Archiviert am 2026-08-23
|
||||||
|
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
|
||||||
|
> [Roadmap](../ROADMAP.md) – dort und nur dort wird der Stand nachgeführt.
|
||||||
|
>
|
||||||
|
> Es bleibt erhalten, weil es die Analyse vor dem Umbau ist: Fundstellenverzeichnis, Aufwandsschaetzung und
|
||||||
|
> was anders kam als geschaetzt. Die Portierung selbst ist abgeschlossen (L0–L6). Zum Nachschlagen also weiterhin richtig,
|
||||||
|
> als Aufgabenliste nicht mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Analyse: Linux-Fähigkeit des IBKRTrader
|
||||||
|
|
||||||
|
> **UMGESETZT am 2026-08-07 (L0–L5).** 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 21–25 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 1–9 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 | **5–7 Personentage** |
|
||||||
|
| **B. Desktop-Linux** (zusätzlich die komplette UI auf Avalonia) | ~1.300 LOC WinForms neu bauen | **+10–14 Personentage** |
|
||||||
|
| **C. LiveCharts2** (heute existiert **kein einziges** Diagramm) | Neubau, keine Migration | **+1–3 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 1–2 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: 1–2 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: 1–1,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 `1024–65535` 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.000–2.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: 10–14 Personentage** für jemanden, der Avalonia kennt. Ohne Vorerfahrung
|
||||||
|
realistisch +3–5 Tage Einarbeitung. LiveCharts2 zusätzlich 1–3 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: 1–2 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äß **1–2 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: 1–3 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 | 1–3 |
|
||||||
|
| **I** | Betrieb: systemd, Deployment, ggf. IB Gateway headless (IBC/Xvfb) | 1–3 |
|
||||||
|
| | **Gesamt** | **~21–25 Personentage** |
|
||||||
|
|
||||||
|
Ohne Avalonia-Vorerfahrung auf Block G realistisch **+3–5 Tage** aufschlagen.
|
||||||
|
|
||||||
|
Zum Vergleich in Wochen: **headless in gut einer Woche**, **komplett mit Desktop-UI in etwa
|
||||||
|
4–5 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,5–3 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 1–2 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, 100–138 | `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` | 99–113 | verwirft Zeitzone, `Kind = Unspecified` ⚠ | D |
|
||||||
|
| `Core/IBKR/IBKRGatewayService.cs` | 247 | `.ToLower()` kulturabhängig, geht in URL | C |
|
||||||
|
| `Core/Workers/BuiltIn/BackupWorker.cs` | 95–118 | `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, 270–320 | `[STAThread]`, `ApplicationConfiguration`, `master.key`, Smoke-UI | A/B/E |
|
||||||
|
| `LauncherForm.cs`, `UI/**` | gesamt (541) | WinForms-Shell | G |
|
||||||
|
| `tests/…csproj` + `UiConstructionTests.cs` | – | `net10.0-windows` nur wegen einer Datei | H |
|
||||||
@@ -1,5 +1,25 @@
|
|||||||
|
> ### 📦 Archiviert am 2026-08-23
|
||||||
|
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
|
||||||
|
> [Roadmap](../ROADMAP.md) (Bahn „Accounting") – dort und nur dort wird der Stand nachgeführt.
|
||||||
|
>
|
||||||
|
> Es bleibt erhalten, weil es die Leitprinzipien traegt – unabhaengige Quelle, Idempotenz, append-only –
|
||||||
|
> und die Datenbeschaffung ueber die Flex Query beschreibt. Zum Nachschlagen also weiterhin richtig,
|
||||||
|
> als Aufgabenliste nicht mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Konzept: Modul „Accounting" (Buchhaltung/Reporting aller Konten)
|
# Konzept: Modul „Accounting" (Buchhaltung/Reporting aller Konten)
|
||||||
|
|
||||||
|
> **UMGESETZT (Modulgerüst).** Das Modul steht: `acc_`-Schema mit Migration `InitialAccounting`,
|
||||||
|
> `AccountingIngestService` (append-only, idempotent über `IdempotencyKey`), `AccountingClassifier`,
|
||||||
|
> `AccountingEngine`, `FxConverter`, `AccountingReportService` sowie CSV- und PDF-Export. Die
|
||||||
|
> Ingest-Quellen liegen hinter Interfaces mit **Offline-Null-Stubs** — das Modul läuft vollständig
|
||||||
|
> und bucht dabei korrekt nichts.
|
||||||
|
>
|
||||||
|
> **Weiterhin offen ist genau die Zielland-Arbeit aus §6** — vor allem der Live-Flex-Abruf, ohne den
|
||||||
|
> keine echten Buchungen entstehen, und die Steuerschicht, deren Jurisdiktion nicht festgelegt ist.
|
||||||
|
> Das Modul ist damit lauffähig, aber noch nicht in Betrieb.
|
||||||
|
|
||||||
> Stand: 2026-07-30
|
> Stand: 2026-07-30
|
||||||
> Ziel: Vollständige, **von unserer Trading-DB unabhängige**, buchhalterisch korrekte Erfassung ALLER
|
> Ziel: Vollständige, **von unserer Trading-DB unabhängige**, buchhalterisch korrekte Erfassung ALLER
|
||||||
> Kontobewegungen der IBKR-Konten. Periodische (meist monatliche), vor einer Steuerbehörde
|
> Kontobewegungen der IBKR-Konten. Periodische (meist monatliche), vor einer Steuerbehörde
|
||||||
@@ -56,7 +76,7 @@ Registrierung in `Program.cs`. Referenziert nur den Core. Eigener `AccountingDbC
|
|||||||
`PdfExporter` (PDFsharp/MigraDoc, MIT).
|
`PdfExporter` (PDFsharp/MigraDoc, MIT).
|
||||||
- Realisierte GuV nutzt den Core-`RealizedPnlEngine` (FIFO) — kein Duplikat.
|
- Realisierte GuV nutzt den Core-`RealizedPnlEngine` (FIFO) — kein Duplikat.
|
||||||
|
|
||||||
## 5. UI (WinForms, ein Fenster mit Tabs)
|
## 5. UI (Avalonia, ein Fenster mit Registerkarten)
|
||||||
Übersicht/BWA (KPI-Kacheln + Monatsvergleich, Zeitraum-/Konto-/Währungswahl), Ledger (filterbar),
|
Übersicht/BWA (KPI-Kacheln + Monatsvergleich, Zeitraum-/Konto-/Währungswahl), Ledger (filterbar),
|
||||||
Steuer (Platzhalter, s. u.), Abrechnung/Export (CSV/PDF), Abruf/Status (Ingest-Läufe, Soll-Ist, manueller
|
Steuer (Platzhalter, s. u.), Abrechnung/Export (CSV/PDF), Abruf/Status (Ingest-Läufe, Soll-Ist, manueller
|
||||||
Trigger). DB-Zugriff nur auf Interaktion (Smoke-UI-sicher).
|
Trigger). DB-Zugriff nur auf Interaktion (Smoke-UI-sicher).
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
> ### 📦 Archiviert am 2026-08-23
|
||||||
|
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
|
||||||
|
> [Roadmap](../ROADMAP.md) (Stufe 5, dazu die Core-Voraussetzungen in Stufe 3) – dort und nur dort wird der Stand nachgeführt.
|
||||||
|
>
|
||||||
|
> Es bleibt erhalten, weil es den Zustandsautomaten, das Regelwerk mit allen Vorgabewerten und die Begruendung enthaelt,
|
||||||
|
> warum das Modul nicht additiv auf den heutigen aktienbasierten Core passt. Zum Nachschlagen also weiterhin richtig,
|
||||||
|
> als Aufgabenliste nicht mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Konzept: Modul „OptionsWheel" (Covered Call / Cash-Secured Put)
|
# Konzept: Modul „OptionsWheel" (Covered Call / Cash-Secured Put)
|
||||||
|
|
||||||
> Stand: 2026-08-03
|
> Stand: 2026-08-03
|
||||||
@@ -1,5 +1,23 @@
|
|||||||
|
> ### 📦 Archiviert am 2026-08-23
|
||||||
|
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
|
||||||
|
> [Roadmap](../ROADMAP.md) (Bahn „Supervisor") – dort und nur dort wird der Stand nachgeführt.
|
||||||
|
>
|
||||||
|
> Es bleibt erhalten, weil es S-0 bis S-4 im Einzelnen beschreibt, samt Tool-Registry und den
|
||||||
|
> Sicherheitsgrenzen des Agenten. Zum Nachschlagen also weiterhin richtig,
|
||||||
|
> als Aufgabenliste nicht mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Konzept: Modul „Supervisor" (KI-gestützte Handels-Analyse & Forensik)
|
# Konzept: Modul „Supervisor" (KI-gestützte Handels-Analyse & Forensik)
|
||||||
|
|
||||||
|
> **UMGESETZT (S-0 bis S-4).** Datenfundament im Core (`core_decision_journal`, `core_order_events`,
|
||||||
|
> durchgereichte `SignalId`, JSONL-Log-Sink), `DossierService`/`DossierBuilder`, der
|
||||||
|
> OpenRouter-Agent mit read-only Tool-Registry und Profilen, `sup_reports`, `CounterfactualJob`,
|
||||||
|
> `DailyReportService` und der MCP-Server (`McpLightServer`, `McpJsonRpc`).
|
||||||
|
>
|
||||||
|
> **Weiterhin offen** sind die beiden Punkte am Ende dieses Dokuments: die Counterfactual-Kursauflösung
|
||||||
|
> (Interface + Stub vorhanden) und der externe Versand des Tagesberichts.
|
||||||
|
|
||||||
> Stand: 2026-07-30
|
> Stand: 2026-07-30
|
||||||
> Ziel: ALLES, was IBKRTrader getan (und bewusst NICHT getan) hat, detailliert analysierbar machen —
|
> Ziel: ALLES, was IBKRTrader getan (und bewusst NICHT getan) hat, detailliert analysierbar machen —
|
||||||
> Entscheidungen, Orders, Trades und Logs — und die Analyse durch ein KI-Modell (OpenRouter) durchführen
|
> Entscheidungen, Orders, Trades und Logs — und die Analyse durch ein KI-Modell (OpenRouter) durchführen
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
> ### 📦 Archiviert am 2026-08-23
|
||||||
|
> Dieses Dokument wird **nicht mehr gepflegt**. Was davon noch offen ist, steht in der
|
||||||
|
> [Roadmap](../ROADMAP.md) (Stufe 4) – dort und nur dort wird der Stand nachgeführt.
|
||||||
|
>
|
||||||
|
> Es bleibt erhalten, weil es die vollstaendige Spezifikation ist, gegen die die Umsetzung geprueft wird –
|
||||||
|
> die drei Wahrheiten, Eigentumsregeln, Reservierungs-Lebenszyklus, Invarianten und Testkatalog. Zum Nachschlagen also weiterhin richtig,
|
||||||
|
> als Aufgabenliste nicht mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Kapital- und Buchmodell
|
# Kapital- und Buchmodell
|
||||||
|
|
||||||
> **Status: Konzept (2026-08-03) – noch nicht implementiert.**
|
> **Status: Konzept (2026-08-03) – noch nicht implementiert.**
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Kopie als scripts/release.config.json angelegt und ausgefuellt. Diese Datei enthaelt KEINE Zugangsdaten - die kommen aus Umgebungsvariablen (DC_FTP_HOST, DC_FTP_USER, DC_FTP_PASS, DC_TOKEN) oder aus einer packager.config.json neben dem Werkzeug. release.config.json darf deshalb versioniert werden.",
|
||||||
|
|
||||||
|
"_project_comment": "IBKRTrader.Daemon ist der Release-Kandidat: plattformneutral (net10.0 ohne Plattformbindung, siehe README), laeuft kopflos auf beiden Zielsystemen. IBKRTrader.App (Avalonia-Shell) ist bewusst NICHT Teil dieser automatisierten Pipeline - das Vorlagenskript nimmt genau eine csproj fuer alle Runtimes, und das Skript bleibt laut Anleitung unveraendert. Eine Desktop-Distribution der Shell ist ein separater, spaeterer Schritt.",
|
||||||
|
"project": "ibkrtrader",
|
||||||
|
|
||||||
|
"csproj": "src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj",
|
||||||
|
|
||||||
|
"_runtimes_comment": "Je Eintrag entsteht ein eigenes Release - ohne Plattformangabe wuerden sie sich gegenseitig ueberschreiben.",
|
||||||
|
"runtimes": ["win-x64", "linux-x64"],
|
||||||
|
|
||||||
|
"_selfContained_comment": "true nimmt die .NET-Laufzeit ins Paket, damit ein frisch aufgesetztes System kein vorinstalliertes .NET braucht (siehe deploy/README.md zur ICU-Warnung).",
|
||||||
|
"selfContained": true,
|
||||||
|
|
||||||
|
"publishSingleFile": false,
|
||||||
|
|
||||||
|
"setupJson": "setup.json",
|
||||||
|
|
||||||
|
"mainAssembly": "",
|
||||||
|
|
||||||
|
"baseUrl": "https://dc.mhdf.de"
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Veroeffentlicht dieses Projekt im Deploymentcenter.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Vorlage zum Kopieren nach scripts/release.ps1 des eigenen Projekts.
|
||||||
|
Anzupassen ist nur der Kopf von release.config.json - dieses Skript
|
||||||
|
selbst bleibt unveraendert.
|
||||||
|
|
||||||
|
Der Ablauf je Zielplattform:
|
||||||
|
dotnet publish -r <rid> -> pack-and-deploy --platform <rid>
|
||||||
|
|
||||||
|
pack-and-deploy uebernimmt dabei Pruefsummen, Dateimanifest, das
|
||||||
|
Fortschreiben der latest.json und die Anmeldung beim Deploymentcenter.
|
||||||
|
Das hier nachzubauen waere ein zweiter Ort fuer dieselben Fehler; das
|
||||||
|
Skript orchestriert nur.
|
||||||
|
|
||||||
|
Fehlt das Werkzeug, wird es geholt und die Pruefsumme geprueft.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\scripts\release.ps1 -Version 1.4.3 -Changelog "Behebt den Login-Fehler."
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\scripts\release.ps1 -Version 1.5.0 -Channel beta -WhatIf
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||||
|
param(
|
||||||
|
# Ohne Angabe wird die Version aus Directory.Build.props bzw. der csproj gelesen.
|
||||||
|
[string] $Version,
|
||||||
|
|
||||||
|
[ValidateSet('prod', 'beta', 'dev')]
|
||||||
|
[string] $Channel = 'prod',
|
||||||
|
|
||||||
|
[string] $Changelog,
|
||||||
|
|
||||||
|
# Als kritisches Update kennzeichnen (Rollout priorisieren).
|
||||||
|
[switch] $Critical,
|
||||||
|
|
||||||
|
[string] $ConfigFile = (Join-Path $PSScriptRoot 'release.config.json')
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Konfiguration
|
||||||
|
if (-not (Test-Path $ConfigFile)) {
|
||||||
|
throw "Konfiguration fehlt: $ConfigFile`nVorlage kopieren: release.config.example.json -> release.config.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
foreach ($required in @('project', 'csproj', 'runtimes')) {
|
||||||
|
if (-not $config.$required) {
|
||||||
|
throw "In $ConfigFile fehlt der Eintrag '$required'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
|
||||||
|
$csprojRel = $config.csproj
|
||||||
|
$csproj = Join-Path $repoRoot $csprojRel
|
||||||
|
|
||||||
|
if (-not (Test-Path $csproj)) {
|
||||||
|
throw "Projektdatei nicht gefunden: $csproj"
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUrl = if ($config.baseUrl) { $config.baseUrl.TrimEnd('/') } else { 'https://dc.mhdf.de' }
|
||||||
|
$toolDir = Join-Path $repoRoot '.dc-tools'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- Version
|
||||||
|
function Get-ProjectVersion {
|
||||||
|
# Directory.Build.props zuerst: Steht <Version> nur in einem von mehreren
|
||||||
|
# Projekten, laufen die Angaben frueher oder spaeter auseinander - und
|
||||||
|
# pack-and-deploy bricht dann zu Recht mit einem Versionskonflikt ab.
|
||||||
|
foreach ($candidate in @(
|
||||||
|
(Join-Path $repoRoot 'Directory.Build.props'),
|
||||||
|
$csproj
|
||||||
|
)) {
|
||||||
|
if (-not (Test-Path $candidate)) { continue }
|
||||||
|
|
||||||
|
$match = [regex]::Match((Get-Content $candidate -Raw), '<Version>\s*([^<]+?)\s*</Version>')
|
||||||
|
if ($match.Success) {
|
||||||
|
return $match.Groups[1].Value.Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Version) {
|
||||||
|
$Version = Get-ProjectVersion
|
||||||
|
if (-not $Version) {
|
||||||
|
throw "Keine <Version> in Directory.Build.props oder $csprojRel gefunden. Bitte -Version angeben."
|
||||||
|
}
|
||||||
|
Write-Host "Version aus dem Projekt gelesen: $Version" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Changelog) {
|
||||||
|
$Changelog = "Release v$Version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Werkzeug
|
||||||
|
function Get-PackAndDeploy {
|
||||||
|
$exe = Join-Path $toolDir 'pack-and-deploy.exe'
|
||||||
|
if (Test-Path $exe) { return $exe }
|
||||||
|
|
||||||
|
Write-Host "pack-and-deploy wird geholt ..." -ForegroundColor Cyan
|
||||||
|
New-Item -ItemType Directory -Force -Path $toolDir | Out-Null
|
||||||
|
|
||||||
|
$name = 'pack-and-deploy-win-x64.exe'
|
||||||
|
$temp = Join-Path $toolDir 'download.tmp'
|
||||||
|
|
||||||
|
Invoke-WebRequest -Uri "$baseUrl/installer/$name" -OutFile $temp -UseBasicParsing
|
||||||
|
|
||||||
|
$expectedRaw = (Invoke-WebRequest -Uri "$baseUrl/installer/$name.sha256" -UseBasicParsing).Content
|
||||||
|
$expected = if ($expectedRaw -is [byte[]]) {
|
||||||
|
[System.Text.Encoding]::ASCII.GetString($expectedRaw)
|
||||||
|
} else { [string]$expectedRaw }
|
||||||
|
$expected = $expected.Trim().ToLower()
|
||||||
|
|
||||||
|
$actual = (Get-FileHash $temp -Algorithm SHA256).Hash.ToLower()
|
||||||
|
|
||||||
|
if ($actual -ne $expected) {
|
||||||
|
Remove-Item $temp -Force
|
||||||
|
throw "Pruefsumme von $name stimmt nicht.`n erwartet: $expected`n erhalten: $actual"
|
||||||
|
}
|
||||||
|
|
||||||
|
Move-Item $temp $exe -Force
|
||||||
|
try { Unblock-File $exe -ErrorAction SilentlyContinue } catch { }
|
||||||
|
|
||||||
|
Write-Host " Pruefsumme in Ordnung." -ForegroundColor DarkGray
|
||||||
|
return $exe
|
||||||
|
}
|
||||||
|
|
||||||
|
$packAndDeploy = Get-PackAndDeploy
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- Ablauf
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Projekt : $($config.project)" -ForegroundColor White
|
||||||
|
Write-Host "Version : $Version"
|
||||||
|
Write-Host "Kanal : $Channel"
|
||||||
|
Write-Host "Plattform : $($config.runtimes -join ', ')"
|
||||||
|
Write-Host ''
|
||||||
|
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
foreach ($rid in $config.runtimes) {
|
||||||
|
Write-Host "=== $rid ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$publishDir = Join-Path $repoRoot "artifacts/publish/$rid"
|
||||||
|
|
||||||
|
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'dotnet publish')) {
|
||||||
|
# Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden
|
||||||
|
# sonst mit ins Paket wandern.
|
||||||
|
if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force }
|
||||||
|
|
||||||
|
$publishArgs = @(
|
||||||
|
'publish', $csproj,
|
||||||
|
'-c', 'Release',
|
||||||
|
'-r', $rid,
|
||||||
|
'-o', $publishDir,
|
||||||
|
'--nologo'
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($config.selfContained) { $publishArgs += '--self-contained', 'true' }
|
||||||
|
else { $publishArgs += '--self-contained', 'false' }
|
||||||
|
|
||||||
|
if ($config.publishSingleFile) { $publishArgs += '-p:PublishSingleFile=true' }
|
||||||
|
|
||||||
|
& dotnet @publishArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "dotnet publish fuer $rid ist fehlgeschlagen." }
|
||||||
|
}
|
||||||
|
|
||||||
|
# setup.json mitliefern, damit der Installer weiss, was einzurichten ist.
|
||||||
|
# Bewusst kein Ternary-Operator: den gibt es erst ab PowerShell 7, und
|
||||||
|
# diese Vorlage soll auch mit dem mitgelieferten 5.1 laufen.
|
||||||
|
$setupRel = if ($config.setupJson) { $config.setupJson } else { 'setup.json' }
|
||||||
|
$setupJson = Join-Path $repoRoot $setupRel
|
||||||
|
if (Test-Path $setupJson) {
|
||||||
|
Copy-Item $setupJson (Join-Path $publishDir 'setup.json') -Force
|
||||||
|
Write-Host " setup.json mitgenommen" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'pack-and-deploy')) {
|
||||||
|
$packArgs = @(
|
||||||
|
'--project', $config.project,
|
||||||
|
'--version', $Version,
|
||||||
|
'--channel', $Channel,
|
||||||
|
'--platform', $rid,
|
||||||
|
'--publish-dir', $publishDir,
|
||||||
|
'--changelog', $Changelog
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Critical) { $packArgs += '--critical' }
|
||||||
|
if ($config.mainAssembly) { $packArgs += '--main-assembly', $config.mainAssembly }
|
||||||
|
|
||||||
|
& $packAndDeploy @packArgs
|
||||||
|
$code = $LASTEXITCODE
|
||||||
|
|
||||||
|
$results += [pscustomobject]@{ Runtime = $rid; ExitCode = $code }
|
||||||
|
|
||||||
|
# 1 = Konfigurationsfehler oder Versionskonflikt: dann stimmt etwas
|
||||||
|
# Grundsaetzliches, und die weiteren Plattformen wuerden genauso
|
||||||
|
# scheitern.
|
||||||
|
if ($code -eq 1) { throw "pack-and-deploy meldet einen Konfigurations- oder Versionsfehler." }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Ergebnis
|
||||||
|
Write-Host '=== Ergebnis ===' -ForegroundColor White
|
||||||
|
|
||||||
|
foreach ($r in $results) {
|
||||||
|
$text = switch ($r.ExitCode) {
|
||||||
|
0 { 'vollstaendig veroeffentlicht' }
|
||||||
|
2 { 'TEILWEISE - Upload oder Registrierung fehlgeschlagen' }
|
||||||
|
default { "unerwarteter Rueckgabewert $($r.ExitCode)" }
|
||||||
|
}
|
||||||
|
$color = if ($r.ExitCode -eq 0) { 'Green' } else { 'Yellow' }
|
||||||
|
Write-Host (" {0,-16} {1}" -f $r.Runtime, $text) -ForegroundColor $color
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed = @($results | Where-Object { $_.ExitCode -ne 0 })
|
||||||
|
|
||||||
|
if ($failed.Count -gt 0) {
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen,' -ForegroundColor Yellow
|
||||||
|
Write-Host 'ob die bereits hochgeladenen Dateien konsistent sind.' -ForegroundColor Yellow
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "Fertig. $($config.project) $Version ist im Kanal $Channel verfuegbar." -ForegroundColor Green
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Veroeffentlicht dieses Projekt im Deploymentcenter.
|
||||||
|
#
|
||||||
|
# Vorlage zum Kopieren nach scripts/release.sh des eigenen Projekts.
|
||||||
|
# Anzupassen ist nur scripts/release.config.json - dieses Skript selbst
|
||||||
|
# bleibt unveraendert.
|
||||||
|
#
|
||||||
|
# Der Ablauf je Zielplattform:
|
||||||
|
# dotnet publish -r <rid> -> pack-and-deploy --platform <rid>
|
||||||
|
#
|
||||||
|
# pack-and-deploy uebernimmt Pruefsummen, Dateimanifest, das Fortschreiben der
|
||||||
|
# latest.json und die Anmeldung beim Deploymentcenter. Das hier nachzubauen
|
||||||
|
# waere ein zweiter Ort fuer dieselben Fehler; das Skript orchestriert nur.
|
||||||
|
#
|
||||||
|
# ./scripts/release.sh --version 1.4.3 --changelog "Behebt den Login-Fehler."
|
||||||
|
# ./scripts/release.sh --version 1.5.0 --channel beta --dry-run
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
CONFIG_FILE="${DC_RELEASE_CONFIG:-$SCRIPT_DIR/release.config.json}"
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
CHANNEL="prod"
|
||||||
|
CHANGELOG=""
|
||||||
|
CRITICAL=0
|
||||||
|
DRY_RUN=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--version) VERSION="$2"; shift 2 ;;
|
||||||
|
--channel) CHANNEL="$2"; shift 2 ;;
|
||||||
|
--changelog) CHANGELOG="$2"; shift 2 ;;
|
||||||
|
--critical) CRITICAL=1; shift ;;
|
||||||
|
--dry-run|-n) DRY_RUN=1; shift ;;
|
||||||
|
--help|-h) usage ;;
|
||||||
|
*) echo "Unbekannte Option: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Voraussetzungen
|
||||||
|
command -v dotnet >/dev/null 2>&1 || {
|
||||||
|
echo "FEHLER: dotnet wird gebraucht, ist aber nicht installiert." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[ -f "$CONFIG_FILE" ] || {
|
||||||
|
echo "FEHLER: Konfiguration fehlt: $CONFIG_FILE" >&2
|
||||||
|
echo " Vorlage kopieren: release.config.example.json -> release.config.json" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# JSON lesen - mit jq, sonst mit Python.
|
||||||
|
#
|
||||||
|
# Bewusst nicht nur jq: Es ist auf den wenigsten Systemen vorinstalliert, und
|
||||||
|
# an einer fehlenden Abhaengigkeit soll die Vorlage nicht scheitern. Python
|
||||||
|
# liegt auf den meisten Entwickler- und CI-Systemen ohnehin bereit.
|
||||||
|
JSON_READER=""
|
||||||
|
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
JSON_READER="jq"
|
||||||
|
else
|
||||||
|
# Nicht nur pruefen, ob der Befehl existiert, sondern ob er laeuft:
|
||||||
|
# Windows legt unter WindowsApps einen python3-Platzhalter ab, der
|
||||||
|
# gefunden wird, beim Aufruf aber nur auf den Store verweist.
|
||||||
|
for candidate in python3 python; do
|
||||||
|
if command -v "$candidate" >/dev/null 2>&1 \
|
||||||
|
&& "$candidate" -c "import json" >/dev/null 2>&1; then
|
||||||
|
JSON_READER="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$JSON_READER" ]; then
|
||||||
|
echo "FEHLER: Zum Lesen von $CONFIG_FILE wird jq oder ein lauffaehiges Python gebraucht." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Liest einen Skalar. Pfad in jq-Schreibweise, z. B. .project
|
||||||
|
cfg() {
|
||||||
|
if [ "$JSON_READER" = "jq" ]; then
|
||||||
|
jq -r "$1 // empty" "$CONFIG_FILE"
|
||||||
|
else
|
||||||
|
"$JSON_READER" -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(open(sys.argv[1], encoding='utf-8'))
|
||||||
|
for part in sys.argv[2].lstrip('.').split('.'):
|
||||||
|
if not isinstance(d, dict): d=None; break
|
||||||
|
d=d.get(part)
|
||||||
|
if d is None: print('')
|
||||||
|
elif isinstance(d, bool): print('true' if d else 'false')
|
||||||
|
else: print(d)
|
||||||
|
" "$CONFIG_FILE" "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Liest ein Feld mit Zeichenketten, eine je Zeile.
|
||||||
|
cfg_list() {
|
||||||
|
if [ "$JSON_READER" = "jq" ]; then
|
||||||
|
jq -r "$1[]?" "$CONFIG_FILE"
|
||||||
|
else
|
||||||
|
"$JSON_READER" -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(open(sys.argv[1], encoding='utf-8'))
|
||||||
|
for part in sys.argv[2].lstrip('.').split('.'):
|
||||||
|
d = d.get(part) if isinstance(d, dict) else None
|
||||||
|
for item in (d or []): print(item)
|
||||||
|
" "$CONFIG_FILE" "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
PROJECT="$(cfg .project)"
|
||||||
|
CSPROJ_REL="$(cfg .csproj)"
|
||||||
|
BASE_URL="$(cfg .baseUrl)"
|
||||||
|
BASE_URL="${BASE_URL:-https://dc.mhdf.de}"
|
||||||
|
BASE_URL="${BASE_URL%/}"
|
||||||
|
SETUP_REL="$(cfg .setupJson)"
|
||||||
|
SETUP_REL="${SETUP_REL:-setup.json}"
|
||||||
|
MAIN_ASSEMBLY="$(cfg .mainAssembly)"
|
||||||
|
|
||||||
|
[ -n "$PROJECT" ] || { echo "FEHLER: 'project' fehlt in $CONFIG_FILE" >&2; exit 1; }
|
||||||
|
[ -n "$CSPROJ_REL" ] || { echo "FEHLER: 'csproj' fehlt in $CONFIG_FILE" >&2; exit 1; }
|
||||||
|
|
||||||
|
CSPROJ="$REPO_ROOT/$CSPROJ_REL"
|
||||||
|
[ -f "$CSPROJ" ] || { echo "FEHLER: Projektdatei nicht gefunden: $CSPROJ" >&2; exit 1; }
|
||||||
|
|
||||||
|
mapfile -t RUNTIMES < <(cfg_list .runtimes)
|
||||||
|
[ "${#RUNTIMES[@]}" -gt 0 ] || { echo "FEHLER: 'runtimes' ist leer." >&2; exit 1; }
|
||||||
|
|
||||||
|
SELF_CONTAINED="$(cfg .selfContained)"
|
||||||
|
SINGLE_FILE="$(cfg .publishSingleFile)"
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- Version
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
# Directory.Build.props zuerst: Steht <Version> nur in einem von mehreren
|
||||||
|
# Projekten, laufen die Angaben auseinander - und pack-and-deploy bricht
|
||||||
|
# dann zu Recht mit einem Versionskonflikt ab.
|
||||||
|
for candidate in "$REPO_ROOT/Directory.Build.props" "$CSPROJ"; do
|
||||||
|
[ -f "$candidate" ] || continue
|
||||||
|
VERSION="$(sed -n 's:.*<Version>\s*\([^<]*\)\s*</Version>.*:\1:p' "$candidate" | head -1 | tr -d '[:space:]')"
|
||||||
|
[ -n "$VERSION" ] && break
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$VERSION" ] || {
|
||||||
|
echo "FEHLER: Keine <Version> gefunden. Bitte --version angeben." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "Version aus dem Projekt gelesen: $VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CHANGELOG="${CHANGELOG:-Release v$VERSION}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Werkzeug
|
||||||
|
TOOL_DIR="$REPO_ROOT/.dc-tools"
|
||||||
|
PACK="$TOOL_DIR/pack-and-deploy"
|
||||||
|
|
||||||
|
fetch_tool() {
|
||||||
|
[ -x "$PACK" ] && return 0
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64) rid="linux-x64" ;;
|
||||||
|
aarch64|arm64) rid="linux-arm64" ;;
|
||||||
|
*) echo "FEHLER: Nicht unterstuetzte Architektur $(uname -m)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "pack-and-deploy wird geholt ($rid) ..."
|
||||||
|
mkdir -p "$TOOL_DIR"
|
||||||
|
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid" -o "$tmp"
|
||||||
|
expected="$(curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid.sha256" | tr -d ' \t\r\n')"
|
||||||
|
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
actual="$(sha256sum "$tmp" | cut -d' ' -f1)"
|
||||||
|
else
|
||||||
|
actual="$(shasum -a 256 "$tmp" | cut -d' ' -f1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$actual" != "$expected" ]; then
|
||||||
|
rm -f "$tmp"
|
||||||
|
echo "FEHLER: Pruefsumme stimmt nicht." >&2
|
||||||
|
echo " erwartet: $expected" >&2
|
||||||
|
echo " erhalten: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$tmp"
|
||||||
|
mv "$tmp" "$PACK"
|
||||||
|
echo " Pruefsumme in Ordnung."
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_tool
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- Ablauf
|
||||||
|
echo
|
||||||
|
echo "Projekt : $PROJECT"
|
||||||
|
echo "Version : $VERSION"
|
||||||
|
echo "Kanal : $CHANNEL"
|
||||||
|
echo "Plattform : ${RUNTIMES[*]}"
|
||||||
|
[ "$DRY_RUN" -eq 1 ] && echo "(Probelauf - es wird nichts hochgeladen)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
for rid in "${RUNTIMES[@]}"; do
|
||||||
|
echo "=== $rid ==="
|
||||||
|
|
||||||
|
PUBLISH_DIR="$REPO_ROOT/artifacts/publish/$rid"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" -eq 0 ]; then
|
||||||
|
# Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden
|
||||||
|
# sonst mit ins Paket wandern.
|
||||||
|
rm -rf "$PUBLISH_DIR"
|
||||||
|
|
||||||
|
publish_args=(publish "$CSPROJ" -c Release -r "$rid" -o "$PUBLISH_DIR" --nologo)
|
||||||
|
|
||||||
|
if [ "$SELF_CONTAINED" = "true" ]; then
|
||||||
|
publish_args+=(--self-contained true)
|
||||||
|
else
|
||||||
|
publish_args+=(--self-contained false)
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$SINGLE_FILE" = "true" ] && publish_args+=(-p:PublishSingleFile=true)
|
||||||
|
|
||||||
|
dotnet "${publish_args[@]}"
|
||||||
|
|
||||||
|
# setup.json mitliefern, damit der Installer weiss, was einzurichten ist.
|
||||||
|
if [ -f "$REPO_ROOT/$SETUP_REL" ]; then
|
||||||
|
cp "$REPO_ROOT/$SETUP_REL" "$PUBLISH_DIR/setup.json"
|
||||||
|
echo " setup.json mitgenommen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pack_args=(
|
||||||
|
--project "$PROJECT"
|
||||||
|
--version "$VERSION"
|
||||||
|
--channel "$CHANNEL"
|
||||||
|
--platform "$rid"
|
||||||
|
--publish-dir "$PUBLISH_DIR"
|
||||||
|
--changelog "$CHANGELOG"
|
||||||
|
)
|
||||||
|
|
||||||
|
[ "$CRITICAL" -eq 1 ] && pack_args+=(--critical)
|
||||||
|
[ -n "$MAIN_ASSEMBLY" ] && pack_args+=(--main-assembly "$MAIN_ASSEMBLY")
|
||||||
|
|
||||||
|
set +e
|
||||||
|
"$PACK" "${pack_args[@]}"
|
||||||
|
code=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$code" in
|
||||||
|
0) echo " -> vollstaendig veroeffentlicht" ;;
|
||||||
|
1)
|
||||||
|
# Konfigurationsfehler oder Versionskonflikt: die weiteren
|
||||||
|
# Plattformen wuerden genauso scheitern.
|
||||||
|
echo "FEHLER: Konfigurations- oder Versionsfehler - Abbruch." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
2) echo " -> TEILWEISE: Upload oder Registrierung fehlgeschlagen"; FAILED=1 ;;
|
||||||
|
*) echo " -> unerwarteter Rueckgabewert $code"; FAILED=1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FAILED" -ne 0 ]; then
|
||||||
|
echo "Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen," >&2
|
||||||
|
echo "ob die bereits hochgeladenen Dateien konsistent sind." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Fertig. $PROJECT $VERSION ist im Kanal $CHANNEL verfuegbar."
|
||||||
@@ -30,5 +30,16 @@
|
|||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"IntervalMinutes": 30
|
"IntervalMinutes": 30
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Deploymentcenter": {
|
||||||
|
"ApiToken": "",
|
||||||
|
"HeartbeatEnabled": true,
|
||||||
|
"MonitorSource": "",
|
||||||
|
"MonitorInstance": "default",
|
||||||
|
"HeartbeatIntervalSeconds": 60,
|
||||||
|
"ReportErrors": true,
|
||||||
|
"CheckUpdatesOnStart": true,
|
||||||
|
"UpdateChannel": "prod",
|
||||||
|
"LicenseKey": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"displayName": "IBKRTrader",
|
||||||
|
"description": "Modulares Trading-Framework fuer Interactive Brokers. Fuer den Betrieb werden eine MySQL/MariaDB-Datenbank und ein IBKR-Gateway (TWS/IB Gateway) benoetigt.",
|
||||||
|
|
||||||
|
"targets": [
|
||||||
|
{ "id": "app", "file": "settings.json", "location": "install", "format": "json" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"fields": [
|
||||||
|
{ "key": "Database:Host", "label": "MySQL-Host", "type": "string", "required": true },
|
||||||
|
{ "key": "Database:Port", "label": "MySQL-Port", "type": "int", "default": "3306", "required": false },
|
||||||
|
{ "key": "Database:Database", "label": "Datenbankname", "type": "string", "required": true },
|
||||||
|
{ "key": "Database:User", "label": "MySQL-Benutzer", "type": "string", "required": true },
|
||||||
|
{ "key": "Database:Password", "label": "MySQL-Passwort", "type": "secret", "required": true },
|
||||||
|
|
||||||
|
{ "key": "IBKR:Host", "label": "TWS/Gateway-Adresse", "type": "string", "default": "127.0.0.1", "required": false },
|
||||||
|
{ "key": "IBKR:Port", "label": "TWS/Gateway-Port (Paper 4002 / Live 4001)", "type": "int", "default": "4002", "required": false },
|
||||||
|
|
||||||
|
{ "key": "Trading:ApplicationTimeZoneId", "label": "Betriebszeitzone (IANA, z. B. Europe/Berlin)", "type": "string", "default": "Europe/Berlin", "required": false },
|
||||||
|
|
||||||
|
{ "key": "Deploymentcenter:BaseUrl", "label": "Deploymentcenter", "type": "url", "source": "detect:baseurl" },
|
||||||
|
{ "key": "Deploymentcenter:MonitorSource", "label": "Name im Watchdog-Dashboard", "source": "detect:hostname" },
|
||||||
|
{ "key": "Deploymentcenter:ApiToken", "label": "Token dieser Installation",
|
||||||
|
"source": "provision", "scopes": ["watchdog:ping", "bugtracker:report"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Plattformneutrale Oberfläche: dieselbe Anwendung läuft unter Windows und Linux.
|
||||||
|
WinExe unterdrückt nur das Konsolenfenster unter Windows – mit WinForms hat das nichts
|
||||||
|
zu tun. Der letzte Stand vor der Portierung liegt im Git-Tag winforms-final. -->
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AssemblyName>IBKRTrader.App</AssemblyName>
|
||||||
|
<RootNamespace>IBKRTrader.App</RootNamespace>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
|
<!-- Layout wird deklarativ in .axaml gebaut, nicht zur Laufzeit im Code. Kompilierte Bindings
|
||||||
|
verlangen ein x:DataType je Datenkontext – dafür fallen Tippfehler in Bindings beim
|
||||||
|
Kompilieren auf statt erst zur Laufzeit. -->
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<!-- AVLN3001: Die Fenster haben bewusst KEINEN parameterlosen Konstruktor – sie bekommen ihre
|
||||||
|
Dienste per DI. Damit sind sie für den XAML-Previewer nicht ladbar, was wir hinnehmen:
|
||||||
|
ein parameterloser Konstruktor würde ein Fenster ohne seine Dienste konstruierbar machen
|
||||||
|
und genau den Fehler verdecken, den die Konstruktionsprüfung finden soll. -->
|
||||||
|
<NoWarn>$(NoWarn);AVLN3001</NoWarn>
|
||||||
|
<!-- Wie beim Daemon: ICU ist Pflicht. Ohne sie kippt die feste de-DE-Formatierung des
|
||||||
|
PDF-Exports auf invariant und Windows-Zeitzonen-IDs lassen sich nicht mehr auflösen. -->
|
||||||
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- BEWUSST die 11er-Linie, nicht 12.x. LiveCharts2 (2.0.5, aktuellste Fassung) ist gegen
|
||||||
|
Avalonia 11 gebaut und bricht unter 12: Avalonia.Input.Gestures.PinchEvent gibt es dort
|
||||||
|
nicht mehr. Diagramme kommen mit den neuen Modulen – erst wenn LiveCharts2 Avalonia 12
|
||||||
|
unterstützt, darf hier angehoben werden. Erfahrung aus PolytraderSharp. -->
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.19" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.19" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.19" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.19" />
|
||||||
|
<!-- DataGrid folgt einer eigenen Versionsreihe und endet in der 11er-Linie bei 11.3.13. -->
|
||||||
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\IBKRTrader.Hosting\IBKRTrader.Hosting.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Resources/ enthält genau die Symbole, die Shell/ViewIcons.cs auf einen Schlüssel abbildet.
|
||||||
|
Der Ordner trug bis 2026-08-23 zusätzlich 17 Symbole der WinForms-Oberfläche mit, die das
|
||||||
|
Wildcard-Muster mit in die Binärdatei nahm, ohne dass sie je aufgelöst wurden. Kommt ein
|
||||||
|
neuer Schlüssel dazu, gehört das PNG hierher – eine Datei ohne Eintrag in ViewIcons nicht. -->
|
||||||
|
<AvaloniaResource Include="..\..\Resources\*.png" Link="Assets\%(Filename)%(Extension)" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using IBKRTrader.App.Shell;
|
||||||
|
using IBKRTrader.Core.Logging;
|
||||||
|
using IBKRTrader.Core.Modularity;
|
||||||
|
using IBKRTrader.Hosting;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace IBKRTrader.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Einstiegspunkt der plattformneutralen Oberfläche.
|
||||||
|
///
|
||||||
|
/// <para>Bewusst zweigeteilt: <see cref="AppHostBuilder"/> stellt den Host mit Persistenz,
|
||||||
|
/// Diensten und Modulen zusammen – ohne jeden Bezug zur Oberfläche. Erst danach wird Avalonia
|
||||||
|
/// daran gehängt. Derselbe Host trägt den kopflosen Linux-Dienst
|
||||||
|
/// (<c>IBKRTrader.Daemon</c>).</para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
public static int Main(string[] args)
|
||||||
|
{
|
||||||
|
// Konstruktionsprüfung aller Fenster ohne Message-Loop und ohne laufende Dienste.
|
||||||
|
if (HasFlag(args, "--smoke-ui")) return SmokeUi.Run();
|
||||||
|
|
||||||
|
var modules = AppHostBuilder.CreateModules();
|
||||||
|
using var host = AppHostBuilder.Build(modules, ShellServices.Register);
|
||||||
|
|
||||||
|
AppHostBuilder.RunStartupChecks(host.Services);
|
||||||
|
|
||||||
|
var logger = host.Services.GetRequiredService<LoggingService>();
|
||||||
|
logger.Info("Core", "=== IBKRTrader startet ===");
|
||||||
|
logger.Info("Core", $"Version: {IBKRTrader.Hosting.BuildInfo.Summary} | .NET {Environment.Version}");
|
||||||
|
|
||||||
|
host.Start();
|
||||||
|
StartModules(modules, logger);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
App.Services = host.Services;
|
||||||
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Geordnetes Herunterfahren – erst nachdem die Oberfläche beendet ist.
|
||||||
|
StopModules(modules, logger);
|
||||||
|
host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Von Avalonia erwartete Fabrikmethode (auch vom XAML-Previewer genutzt).</summary>
|
||||||
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
|
AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
|
||||||
|
private static void StartModules(IReadOnlyList<IModule> modules, LoggingService logger)
|
||||||
|
{
|
||||||
|
foreach (var module in modules)
|
||||||
|
{
|
||||||
|
try { module.StartAsync(default).GetAwaiter().GetResult(); }
|
||||||
|
catch (Exception ex) { logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
|
||||||
|
}
|
||||||
|
logger.Info("Core", "IBKRTrader bereit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void StopModules(IReadOnlyList<IModule> modules, LoggingService logger)
|
||||||
|
{
|
||||||
|
foreach (var module in modules)
|
||||||
|
{
|
||||||
|
try { module.StopAsync(default).GetAwaiter().GetResult(); }
|
||||||
|
catch (Exception ex) { logger.Warn(module.Name, $"{module.Name}: Stopp fehlgeschlagen: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasFlag(string[] args, string flag) =>
|
||||||
|
args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}";
|
||||||
|
}
|
||||||
@@ -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). 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. 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 0–23." + 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}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"Database": {
|
||||||
|
"MySqlConnectionString": ""
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
using IBKRTrader.Core.Logging;
|
|
||||||
|
|
||||||
namespace IBKRTrader.Core.AI;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Platzhalter für KI-Modell-Integration.
|
|
||||||
/// Wird in einer späteren Phase mit echten ML-Modellen ersetzt.
|
|
||||||
/// </summary>
|
|
||||||
public class AIModelService
|
|
||||||
{
|
|
||||||
private readonly LoggingService _logger;
|
|
||||||
|
|
||||||
public AIModelService(LoggingService logger)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Platzhalter: Gibt immer 0.5 zurück (keine echte Vorhersage).</summary>
|
|
||||||
public Task<double> PredictAsync(string symbol, object features)
|
|
||||||
{
|
|
||||||
_logger.Warn("AI", $"AIModelService ist ein Platzhalter – keine echte Vorhersage für {symbol}.");
|
|
||||||
return Task.FromResult(0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,13 +19,6 @@ public class BudgetService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<decimal> GetAvailableBudgetAsync(string module)
|
|
||||||
{
|
|
||||||
await using var db = await _dbf.CreateDbContextAsync();
|
|
||||||
var b = await db.Budgets.FindAsync(module);
|
|
||||||
return b is null ? 0m : b.TotalBudget - b.UsedBudget;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ReserveBudgetAsync(string module, decimal amount)
|
public async Task ReserveBudgetAsync(string module, decimal amount)
|
||||||
{
|
{
|
||||||
await using var db = await _dbf.CreateDbContextAsync();
|
await using var db = await _dbf.CreateDbContextAsync();
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
namespace IBKRTrader.Core.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Die Verzeichnisse, in die die Anwendung schreibt.
|
||||||
|
///
|
||||||
|
/// <para><b>Warum das nötig wurde:</b> Bisher lag alles neben der Binärdatei – <c>Logs/</c>,
|
||||||
|
/// <c>Backups/</c>, <c>settings.json</c>, <c>master.key</c>. Unter Windows ist das üblich. Auf
|
||||||
|
/// Linux liegt eine Anwendung typischerweise unter <c>/opt</c> oder <c>/usr/local</c>, und dort
|
||||||
|
/// hat der Dienstbenutzer <b>keinen Schreibzugriff</b>. Der Dienst wäre beim ersten Logeintrag
|
||||||
|
/// gescheitert.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Auflösung, in dieser Reihenfolge:</b></para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Umgebungsvariable (<c>IBKRTRADER_CONFIG_DIR</c>, <c>_DATA_DIR</c>, <c>_LOG_DIR</c>) –
|
||||||
|
/// hat immer Vorrang, damit ein Betreiber die Ablage frei bestimmen kann.</item>
|
||||||
|
/// <item>Das Verzeichnis der Binärdatei, <b>wenn dort geschrieben werden darf</b>. Das hält das
|
||||||
|
/// bisherige Verhalten unter Windows und beim Entwickeln unter Linux unverändert.</item>
|
||||||
|
/// <item>Sonst plattformabhängig: unter Linux/macOS die FHS-Konvention
|
||||||
|
/// (<c>/etc/ibkrtrader</c>, <c>/var/lib/ibkrtrader</c>, <c>/var/log/ibkrtrader</c>), unter
|
||||||
|
/// Windows <c>%ProgramData%\IBKRTrader</c>. <b>Der FHS-Pfad darf hier nicht als
|
||||||
|
/// plattformübergreifender Rückfall dienen</b> – .NET löst <c>/etc/ibkrtrader</c> unter
|
||||||
|
/// Windows zu <c>C:\etc\ibkrtrader</c> auf. Genau dieser Fall tritt ein, sobald eine
|
||||||
|
/// Installation nach <c>Program Files</c> zeigt, wo der Dienst- bzw. Nutzerkonto-Kontext
|
||||||
|
/// keinen Schreibzugriff neben der Binärdatei hat.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>Die Prüfung läuft einmal beim ersten Zugriff; die Verzeichnisse werden dabei angelegt.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class AppPaths
|
||||||
|
{
|
||||||
|
private const string AppFolder = "ibkrtrader";
|
||||||
|
|
||||||
|
private static readonly Lazy<string> _config = new(() => Resolve("IBKRTRADER_CONFIG_DIR", $"/etc/{AppFolder}"));
|
||||||
|
private static readonly Lazy<string> _data = new(() => Resolve("IBKRTRADER_DATA_DIR", $"/var/lib/{AppFolder}", windowsSubDir: "Data"));
|
||||||
|
private static readonly Lazy<string> _logs = new(() => Resolve("IBKRTRADER_LOG_DIR", $"/var/log/{AppFolder}", "Logs", windowsSubDir: "Logs"));
|
||||||
|
|
||||||
|
/// <summary>Konfiguration und Schlüsselmaterial: <c>settings.json</c>, <c>master.key</c>, <c>openrouter.key</c>.</summary>
|
||||||
|
public static string Config => _config.Value;
|
||||||
|
|
||||||
|
/// <summary>Veränderliche Daten: <c>Backups/</c>.</summary>
|
||||||
|
public static string Data => _data.Value;
|
||||||
|
|
||||||
|
/// <summary>Logdateien (Textlog je Modul und JSONL).</summary>
|
||||||
|
public static string Logs => _logs.Value;
|
||||||
|
|
||||||
|
/// <summary>Vollständiger Pfad einer Konfigurationsdatei.</summary>
|
||||||
|
public static string ConfigFile(string fileName) => Path.Combine(Config, fileName);
|
||||||
|
|
||||||
|
/// <summary>Vollständiger Pfad unterhalb des Datenverzeichnisses.</summary>
|
||||||
|
public static string DataPath(string relative) => Path.Combine(Data, relative);
|
||||||
|
|
||||||
|
/// <summary>Kurzfassung für den Startlog – damit im Betrieb sichtbar ist, wohin geschrieben wird.</summary>
|
||||||
|
public static string Describe() => $"config={Config}, data={Data}, logs={Logs}";
|
||||||
|
|
||||||
|
/// <param name="envVar">Umgebungsvariable, die alles überstimmt.</param>
|
||||||
|
/// <param name="fhsFallback">FHS-Pfad unter Linux/macOS, wenn neben der Binärdatei nicht geschrieben werden darf.</param>
|
||||||
|
/// <param name="localSubDir">
|
||||||
|
/// Unterverzeichnis im Binärverzeichnis. Für Logs ist das <c>Logs/</c>; Konfiguration und Daten
|
||||||
|
/// lagen bisher direkt daneben und bleiben dort, damit bestehende Installationen unverändert
|
||||||
|
/// weiterlaufen.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="windowsSubDir">
|
||||||
|
/// Unterverzeichnis unter <c>%ProgramData%\IBKRTrader</c>, wenn unter Windows neben der
|
||||||
|
/// Binärdatei nicht geschrieben werden darf (z. B. Installation nach <c>Program Files</c>).
|
||||||
|
/// <c>null</c> = direkt in <c>%ProgramData%\IBKRTrader</c>, wie bei der Konfiguration.
|
||||||
|
/// </param>
|
||||||
|
private static string Resolve(string envVar, string fhsFallback, string? localSubDir = null, string? windowsSubDir = null)
|
||||||
|
{
|
||||||
|
var fromEnv = Environment.GetEnvironmentVariable(envVar);
|
||||||
|
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||||
|
return Ensure(fromEnv.Trim());
|
||||||
|
|
||||||
|
var local = localSubDir is null
|
||||||
|
? AppContext.BaseDirectory
|
||||||
|
: Path.Combine(AppContext.BaseDirectory, localSubDir);
|
||||||
|
|
||||||
|
if (IsWritable(AppContext.BaseDirectory))
|
||||||
|
return Ensure(local);
|
||||||
|
|
||||||
|
// Binärverzeichnis nicht beschreibbar - der FHS-Pfad taugt nur unter Linux/macOS als
|
||||||
|
// Rückfall. Unter Windows löst .NET "/etc/ibkrtrader" zu "C:\etc\ibkrtrader" auf; der
|
||||||
|
// richtige Rückfall dort ist das maschinenweite Anwendungsdatenverzeichnis.
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||||||
|
var winBase = Path.Combine(programData, "IBKRTrader");
|
||||||
|
return Ensure(windowsSubDir is null ? winBase : Path.Combine(winBase, windowsSubDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ensure(fhsFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft den Schreibzugriff, indem tatsächlich geschrieben wird. Eine Rechteprüfung über
|
||||||
|
/// Attribute trägt nicht: unter Linux entscheiden Besitzer, Gruppe und Modus, unter Windows
|
||||||
|
/// die ACL – ein Schreibversuch ist die einzige verlässliche Antwort.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsWritable(string dir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var probe = Path.Combine(dir, $".write-probe-{Guid.NewGuid():N}");
|
||||||
|
using (File.Create(probe, 1, FileOptions.DeleteOnClose)) { }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException) { return false; }
|
||||||
|
catch (IOException) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Ensure(string dir)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,26 +119,6 @@ public class IBKRGatewayService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initialisiert die Brokerage-Session über den Gateway.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<bool> InitBrokerageSessionAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await RateLimitAsync(ct);
|
|
||||||
var body = new StringContent("{\"publish\":true,\"compete\":true}",
|
|
||||||
System.Text.Encoding.UTF8, "application/json");
|
|
||||||
var response = await _http.PostAsync("iserver/auth/ssodh/init", body, ct);
|
|
||||||
return response.IsSuccessStatusCode;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.Warn("IBKR", $"Brokerage-Session-Init fehlgeschlagen: {ex.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Contract Search ─────────────────────────────────────────────────────
|
// ─── Contract Search ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -198,33 +178,6 @@ public class IBKRGatewayService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sucht Aktien-Contracts nach Symbolen.
|
|
||||||
/// GET /trsrv/stocks?symbols=AAPL,MSFT
|
|
||||||
/// </summary>
|
|
||||||
public async Task<Dictionary<string, List<IBKRStockContract>>?> SearchStocksBySymbolAsync(
|
|
||||||
string symbols, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await RateLimitAsync(ct);
|
|
||||||
var response = await _http.GetAsync($"trsrv/stocks?symbols={Uri.EscapeDataString(symbols)}", ct);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
_logger.Warn("IBKR", $"Stock-Suche fehlgeschlagen: HTTP {(int)response.StatusCode}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.Content.ReadFromJsonAsync<Dictionary<string, List<IBKRStockContract>>>(JsonOpts, ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.Error("IBKR", $"Stock-Suche fehlgeschlagen: {ex.Message}", ex);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Historical Market Data ──────────────────────────────────────────────
|
// ─── Historical Market Data ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -244,7 +197,7 @@ public class IBKRGatewayService
|
|||||||
var url = $"iserver/marketdata/history?conid={conid}" +
|
var url = $"iserver/marketdata/history?conid={conid}" +
|
||||||
$"&period={Uri.EscapeDataString(period)}" +
|
$"&period={Uri.EscapeDataString(period)}" +
|
||||||
$"&bar={Uri.EscapeDataString(bar)}" +
|
$"&bar={Uri.EscapeDataString(bar)}" +
|
||||||
$"&outsideRth={outsideRth.ToString().ToLower()}";
|
$"&outsideRth={(outsideRth ? "true" : "false")}";
|
||||||
|
|
||||||
var response = await _http.GetAsync(url, ct);
|
var response = await _http.GetAsync(url, ct);
|
||||||
|
|
||||||
@@ -263,12 +216,4 @@ public class IBKRGatewayService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Disconnect ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public Task DisconnectAsync()
|
|
||||||
{
|
|
||||||
IsConnected = false;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<object> 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,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,
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
using System.Windows.Forms;
|
|
||||||
|
|
||||||
namespace IBKRTrader.Core.Modularity;
|
namespace IBKRTrader.Core.Modularity;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die eigentliche UI ist ein
|
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die Shell zeigt je View höchstens
|
||||||
/// <see cref="Form"/>, das über <see cref="CreateForm"/> erzeugt wird (mit DI-Abhängigkeiten).
|
/// eine Instanz und holt ein offenes Fenster wieder nach vorne.
|
||||||
/// Die Shell zeigt je View höchstens eine Instanz und holt ein offenes Fenster wieder nach vorne.
|
///
|
||||||
|
/// <para><b>Bewusst toolkit-neutral:</b> <see cref="CreateView"/> liefert ein <see cref="object"/>,
|
||||||
|
/// keinen konkreten Fenstertyp, und <see cref="IconKey"/> ist ein Schlüssel statt eines Bildes.
|
||||||
|
/// Dadurch trägt der Core keine UI-Abhängigkeit und bleibt plattformneutral – Voraussetzung für den
|
||||||
|
/// kopflosen Linux-Betrieb. Insbesondere hängt hier kein <c>System.Drawing.Image</c> mehr:
|
||||||
|
/// <c>System.Drawing.Common</c> ist seit .NET 7 Windows-only und wirft auf Linux. Die jeweilige
|
||||||
|
/// Shell kennt ihr Toolkit und castet – die Avalonia-Shell auf <c>Window</c>.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ModuleView
|
public sealed class ModuleView
|
||||||
{
|
{
|
||||||
/// <summary>Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View).</summary>
|
/// <summary>Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View).</summary>
|
||||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
/// <summary>Titel (Fallback-Fenstertitel, falls das Form keinen eigenen setzt).</summary>
|
/// <summary>Titel (Fallback-Fenstertitel, falls das Fenster keinen eigenen setzt).</summary>
|
||||||
public string Title { get; init; } = "Fenster";
|
public string Title { get; init; } = "Fenster";
|
||||||
|
|
||||||
/// <summary>Optionale Gruppierung (z. B. "Core", "CongressTrading").</summary>
|
/// <summary>Optionale Gruppierung (z. B. "Core", "CongressTrading").</summary>
|
||||||
@@ -21,17 +25,24 @@ public sealed class ModuleView
|
|||||||
/// <summary>Optionale Sortierreihenfolge in Menü/Buttons.</summary>
|
/// <summary>Optionale Sortierreihenfolge in Menü/Buttons.</summary>
|
||||||
public int Order { get; init; } = 0;
|
public int Order { get; init; } = 0;
|
||||||
|
|
||||||
/// <summary>Optionales Icon für Menü/Buttons.</summary>
|
/// <summary>
|
||||||
public System.Drawing.Image? Icon { get; set; }
|
/// Logischer Schlüssel des Symbols für Menü/Buttons (z. B. "dashboard", "logs"). Die Shell löst
|
||||||
|
/// ihn gegen ihre eigenen Bildressourcen auf. Settable, damit die Shell den von Modulen
|
||||||
|
/// registrierten Views zentral ein Symbol zuweisen kann – Module kennen die Shell-Ressourcen nicht.
|
||||||
|
/// </summary>
|
||||||
|
public string? IconKey { get; set; }
|
||||||
|
|
||||||
/// <summary>Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung).</summary>
|
/// <summary>
|
||||||
public Func<Form> CreateForm { get; init; } = () => new Form();
|
/// Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung). Rückgabetyp ist
|
||||||
|
/// <see cref="object"/> – siehe Klassen-Doku zur Toolkit-Neutralität.
|
||||||
|
/// </summary>
|
||||||
|
public Func<object> CreateView { get; init; } = () => new object();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wird der Shell beim Start übergeben; Core und Module registrieren hier ihre Ansichten.
|
/// Wird der Shell beim Start übergeben; Core und Module registrieren hier ihre Ansichten.
|
||||||
/// Über die Navigations-Mitglieder kann JEDES Fenster (auch Modul-Fenster, die nur den Core kennen)
|
/// Über die Navigations-Mitglieder kann JEDES Fenster (auch Modul-Fenster, die nur den Core kennen)
|
||||||
/// das gemeinsame „Fenster"-Menü bauen (siehe <see cref="WindowMenu"/>).
|
/// das gemeinsame „Fenster"-Menü bauen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IModuleUiHost
|
public interface IModuleUiHost
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
using System.Drawing;
|
|
||||||
using System.Windows.Forms;
|
|
||||||
|
|
||||||
namespace IBKRTrader.Core.Modularity;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Baut das gemeinsame Fenster-Menü, das auf JEDEM Fenster erscheint und das Wechseln zwischen allen
|
|
||||||
/// Fenstern (Launcher + Core + Module) erlaubt. Da es nur den Core-Contract <see cref="IModuleUiHost"/>
|
|
||||||
/// nutzt, funktioniert es auch aus Modul-Fenstern (die die App nicht kennen).
|
|
||||||
/// </summary>
|
|
||||||
public static class WindowMenu
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Verdrahtet einen <see cref="MenuStrip"/> mit der Fensterliste: füllt ihn sofort und baut ihn bei
|
|
||||||
/// jeder Offen-Status-Änderung neu auf. Die Registrierung wird beim Entsorgen sauber gelöst.
|
|
||||||
/// </summary>
|
|
||||||
public static void Wire(MenuStrip menu, IModuleUiHost host, string? currentViewId)
|
|
||||||
{
|
|
||||||
void Refresh()
|
|
||||||
{
|
|
||||||
if (menu.IsDisposed) return;
|
|
||||||
if (menu.IsHandleCreated && menu.InvokeRequired)
|
|
||||||
{
|
|
||||||
try { menu.BeginInvoke((Action)(() => Populate(menu, host, currentViewId))); }
|
|
||||||
catch { /* Fenster wird gerade geschlossen */ }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Populate(menu, host, currentViewId);
|
|
||||||
}
|
|
||||||
|
|
||||||
Populate(menu, host, currentViewId);
|
|
||||||
host.OpenStateChanged += Refresh;
|
|
||||||
menu.Disposed += (_, _) => host.OpenStateChanged -= Refresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Baut die Menüleiste komplett neu auf (Launcher, alle Views nebeneinander, Aktion rechts).</summary>
|
|
||||||
public static void Populate(MenuStrip menu, IModuleUiHost host, string? currentViewId)
|
|
||||||
{
|
|
||||||
menu.Items.Clear();
|
|
||||||
|
|
||||||
var launcher = new ToolStripMenuItem("Launcher") { Checked = currentViewId == null };
|
|
||||||
if (currentViewId == null) launcher.Font = new Font(launcher.Font, FontStyle.Bold);
|
|
||||||
launcher.Click += (_, _) => host.ActivateMain();
|
|
||||||
menu.Items.Add(launcher);
|
|
||||||
|
|
||||||
foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
|
||||||
{
|
|
||||||
bool isCurrent = view.Id == currentViewId;
|
|
||||||
var item = new ToolStripMenuItem(view.Title)
|
|
||||||
{
|
|
||||||
Image = view.Icon,
|
|
||||||
ImageScaling = ToolStripItemImageScaling.SizeToFit,
|
|
||||||
DisplayStyle = view.Icon != null
|
|
||||||
? ToolStripItemDisplayStyle.ImageAndText
|
|
||||||
: ToolStripItemDisplayStyle.Text,
|
|
||||||
Checked = isCurrent || host.IsOpen(view.Id)
|
|
||||||
};
|
|
||||||
if (isCurrent) item.Font = new Font(item.Font, FontStyle.Bold);
|
|
||||||
string id = view.Id;
|
|
||||||
item.Click += (_, _) => host.OpenView(id);
|
|
||||||
menu.Items.Add(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kontextabhängige rechte Aktion: nur der Launcher darf die App beenden; jedes andere Fenster
|
|
||||||
// bietet nur „Fenster schließen" (kein App-Shutdown, Module laufen weiter).
|
|
||||||
if (currentViewId == null)
|
|
||||||
{
|
|
||||||
var exit = new ToolStripMenuItem("Beenden") { Alignment = ToolStripItemAlignment.Right };
|
|
||||||
exit.Click += (_, _) => host.RequestShutdown();
|
|
||||||
menu.Items.Add(exit);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var close = new ToolStripMenuItem("Fenster schließen") { Alignment = ToolStripItemAlignment.Right };
|
|
||||||
close.Click += (_, _) => menu.FindForm()?.Close();
|
|
||||||
menu.Items.Add(close);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -47,7 +47,7 @@ public enum OrderEventType
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Eine Zeile im Entscheidungsjournal (core_decision_journal): JEDE Handelsentscheidung –
|
/// Eine Zeile im Entscheidungsjournal (core_decision_journal): JEDE Handelsentscheidung –
|
||||||
/// ausgeführt, abgelehnt oder übersprungen – strukturiert und abfragbar. Grundlage für
|
/// ausgeführt, abgelehnt oder übersprungen – strukturiert und abfragbar. Grundlage für
|
||||||
/// Supervisor-Analysen („warum (nicht) gehandelt?"). Siehe docs/konzepte/KONZEPT-Modul-Supervisor.md.
|
/// Supervisor-Analysen („warum (nicht) gehandelt?"). Siehe docs/archiv/KONZEPT-Modul-Supervisor.md.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CoreDecisionRecord
|
public class CoreDecisionRecord
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,10 +92,6 @@ public static class SecretProtection
|
|||||||
return Encoding.UTF8.GetString(pt);
|
return Encoding.UTF8.GetString(pt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True, wenn der Wert bereits im verschlüsselten Format vorliegt.</summary>
|
|
||||||
public static bool IsEncrypted(string? value) =>
|
|
||||||
!string.IsNullOrEmpty(value) && value!.StartsWith(Prefix, StringComparison.Ordinal);
|
|
||||||
|
|
||||||
private static byte[] DeriveKey(string raw)
|
private static byte[] DeriveKey(string raw)
|
||||||
{
|
{
|
||||||
// 32-Byte-Key als Base64?
|
// 32-Byte-Key als Base64?
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public class DatabaseSettings
|
|||||||
[Category("Database")]
|
[Category("Database")]
|
||||||
[DisplayName("Host")]
|
[DisplayName("Host")]
|
||||||
[Description("MySQL-Serveradresse (Hostname oder IP)")]
|
[Description("MySQL-Serveradresse (Hostname oder IP)")]
|
||||||
public string Host { get; set; } = "larx.your-database.de";
|
public string Host { get; set; } = "your-db-host";
|
||||||
|
|
||||||
[Category("Database")]
|
[Category("Database")]
|
||||||
[DisplayName("Port")]
|
[DisplayName("Port")]
|
||||||
@@ -21,18 +21,18 @@ public class DatabaseSettings
|
|||||||
[Category("Database")]
|
[Category("Database")]
|
||||||
[DisplayName("Database")]
|
[DisplayName("Database")]
|
||||||
[Description("Name der MySQL-Datenbank")]
|
[Description("Name der MySQL-Datenbank")]
|
||||||
public string Database { get; set; } = "bergisnu_db0";
|
public string Database { get; set; } = "your-db-name";
|
||||||
|
|
||||||
[Category("Database")]
|
[Category("Database")]
|
||||||
[DisplayName("User")]
|
[DisplayName("User")]
|
||||||
[Description("MySQL-Benutzername")]
|
[Description("MySQL-Benutzername")]
|
||||||
public string User { get; set; } = "bergisnu_0";
|
public string User { get; set; } = "your-db-user";
|
||||||
|
|
||||||
[Category("Database")]
|
[Category("Database")]
|
||||||
[DisplayName("Password")]
|
[DisplayName("Password")]
|
||||||
[Description("MySQL-Passwort")]
|
[Description("MySQL-Passwort")]
|
||||||
[PasswordPropertyText(true)]
|
[PasswordPropertyText(true)]
|
||||||
public string Password { get; set; } = "o5/A49+f}:Da";
|
public string Password { get; set; } = "CHANGE_ME";
|
||||||
|
|
||||||
public string BuildConnectionString() =>
|
public string BuildConnectionString() =>
|
||||||
$"Server={Host};Port={Port};Database={Database};" +
|
$"Server={Host};Port={Port};Database={Database};" +
|
||||||
@@ -211,6 +211,15 @@ public class TradingSettings
|
|||||||
[Description("Handelsmodus: Paper (Test-Account, Port 4002) oder Live (Port 4001)")]
|
[Description("Handelsmodus: Paper (Test-Account, Port 4002) oder Live (Port 4001)")]
|
||||||
public string Mode { get; set; } = "Paper";
|
public string Mode { get; set; } = "Paper";
|
||||||
|
|
||||||
|
[Category("Trading")]
|
||||||
|
[DisplayName("Betriebszeitzone")]
|
||||||
|
[Description("Zeitzone dieser Instanz in IANA-Schreibweise, z. B. \"Europe/Berlin\" (EU) oder " +
|
||||||
|
"\"America/New_York\" (US). Gilt für Anzeige, Logdatei-Tagesgrenzen, Berichtszeiten " +
|
||||||
|
"und Buchungsperioden; gespeichert wird immer UTC. VOR den ersten Trades festlegen " +
|
||||||
|
"und danach nicht mehr ändern – ein Wechsel verschiebt rückwirkend alle Tagesgrenzen. " +
|
||||||
|
"Leer = Zeitzone des Systems (nicht empfohlen). Wirkt erst nach einem Neustart.")]
|
||||||
|
public string ApplicationTimeZoneId { get; set; } = "Europe/Berlin";
|
||||||
|
|
||||||
[Category("Trading")]
|
[Category("Trading")]
|
||||||
[DisplayName("Trading aktiv")]
|
[DisplayName("Trading aktiv")]
|
||||||
[Description("Globaler Hauptschalter. Nur wenn aktiv werden Orders ausgeführt.")]
|
[Description("Globaler Hauptschalter. Nur wenn aktiv werden Orders ausgeführt.")]
|
||||||
@@ -290,6 +299,61 @@ public class SupervisorSettings
|
|||||||
public override string ToString() => Model;
|
public override string ToString() => Model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Deploymentcenter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||||
|
public class DeploymentcenterSettings
|
||||||
|
{
|
||||||
|
[Category("Deploymentcenter")]
|
||||||
|
[DisplayName("API Token")]
|
||||||
|
[Description("Authorization: Bearer <Token>. Ohne Token: keine Heartbeats, keine Fehlermeldungen.")]
|
||||||
|
[PasswordPropertyText(true)]
|
||||||
|
public string ApiToken { get; set; } = "";
|
||||||
|
|
||||||
|
[Category("Deploymentcenter")]
|
||||||
|
[DisplayName("Heartbeat aktiv")]
|
||||||
|
[Description("Dead-Man's-Switch an/aus")]
|
||||||
|
public bool HeartbeatEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
[Category("Deploymentcenter")]
|
||||||
|
[DisplayName("Monitor Source")]
|
||||||
|
[Description("Monitor-Name im Dashboard. Leer = Rechnername (Auto-Registrierung beim ersten Ping)")]
|
||||||
|
public string MonitorSource { get; set; } = "";
|
||||||
|
|
||||||
|
[Category("Deploymentcenter")]
|
||||||
|
[DisplayName("Monitor Instance")]
|
||||||
|
[Description("Für mehrere Instanzen desselben Dienstes (z. B. EU/US)")]
|
||||||
|
public string MonitorInstance { get; set; } = "default";
|
||||||
|
|
||||||
|
[Category("Deploymentcenter")]
|
||||||
|
[DisplayName("Heartbeat-Intervall (Sekunden)")]
|
||||||
|
[Description("warning nach 2×, down nach 4× dieses Intervalls")]
|
||||||
|
public int HeartbeatIntervalSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
[Category("Deploymentcenter")]
|
||||||
|
[DisplayName("Fehler melden")]
|
||||||
|
[Description("Error/Fatal an den Fehler-Stream (/api/errors/v1/report)")]
|
||||||
|
public bool ReportErrors { get; set; } = true;
|
||||||
|
|
||||||
|
[Category("Deploymentcenter")]
|
||||||
|
[DisplayName("Update-Prüfung beim Start")]
|
||||||
|
[Description("Prüft nur, installiert nichts von selbst")]
|
||||||
|
public bool CheckUpdatesOnStart { get; set; } = true;
|
||||||
|
|
||||||
|
[Category("Deploymentcenter")]
|
||||||
|
[DisplayName("Update-Kanal")]
|
||||||
|
[Description("prod, beta oder dev")]
|
||||||
|
public string UpdateChannel { get; set; } = "prod";
|
||||||
|
|
||||||
|
[Category("Deploymentcenter")]
|
||||||
|
[DisplayName("Lizenzschlüssel")]
|
||||||
|
[Description("Nur Rückfall, falls noch kein Schlüssel im verschlüsselten Cache liegt")]
|
||||||
|
[PasswordPropertyText(true)]
|
||||||
|
public string LicenseKey { get; set; } = "";
|
||||||
|
|
||||||
|
public override string ToString() => HeartbeatEnabled ? "Aktiv" : "Deaktiviert";
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Root ────────────────────────────────────────────────────────────────────
|
// ─── Root ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public class AppSettings
|
public class AppSettings
|
||||||
@@ -338,4 +402,9 @@ public class AppSettings
|
|||||||
[DisplayName("Supervisor")]
|
[DisplayName("Supervisor")]
|
||||||
[Description("KI-Analyse/Forensik (OpenRouter, read-only)")]
|
[Description("KI-Analyse/Forensik (OpenRouter, read-only)")]
|
||||||
public SupervisorSettings Supervisor { get; set; } = new();
|
public SupervisorSettings Supervisor { get; set; } = new();
|
||||||
|
|
||||||
|
[Category("Deploymentcenter")]
|
||||||
|
[DisplayName("Deploymentcenter")]
|
||||||
|
[Description("Lizenz, Watchdog, UpdateService, Fehler-Stream (https://dc.mhdf.de)")]
|
||||||
|
public DeploymentcenterSettings Deploymentcenter { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using IBKRTrader.Core.Configuration;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ namespace IBKRTrader.Core.Settings;
|
|||||||
public class SettingsService
|
public class SettingsService
|
||||||
{
|
{
|
||||||
private static readonly string SettingsPath =
|
private static readonly string SettingsPath =
|
||||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json");
|
AppPaths.ConfigFile("settings.json");
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||