Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c176b05ea1 | ||
|
|
d86a083438 | ||
|
|
3513a0b2d9 | ||
|
|
afae3c9c4a | ||
|
|
9c7e19149c | ||
|
|
8b9b993d1d | ||
|
|
d8273c3a1e | ||
|
|
123f38ab6f | ||
|
|
18b1059fa9 |
@@ -17,24 +17,43 @@ Keine gegenseitigen Blockierungen – alles thread-sicher und performant
|
||||
|
||||
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
|
||||
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)
|
||||
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,
|
||||
alles Externe (DB/IBKR/Scraper) gemockt. DoD jeder Phase: `dotnet test` grün + Build sauber.
|
||||
|
||||
UI-Grundmodell (Launcher-Prinzip nach Polytrader):
|
||||
|
||||
LauncherForm = Basis-Fenster. Enthält: Core-Status/Steuerung ("Trading aktivieren", Paper/Live),
|
||||
Workers/Services (dgv_workerlist), Logs (rtb_logs), Settings (PropertyGrid) und eine Modul-Liste.
|
||||
Jedes Modul wird als EIGENSTÄNDIGES Fenster aus dem Launcher geöffnet (nicht als Tab).
|
||||
Der Launcher trackt offene Fenster (Key → Form) und fokussiert bei erneutem Öffnen.
|
||||
dgv_workerlist Spalten: Active | Type | Module | Workername | Last Runtime | Next Runtime | Run Every | Info
|
||||
LauncherWindow = Basis-Fenster mit einer Schaltfläche je registrierter Ansicht und dem
|
||||
gemeinsamen Fenster-Menü. Die Inhalte (Dashboard, Workers, Logs, Settings, Modul-Fenster) sind
|
||||
eigenständige Fenster, keine Tabs; je Ansicht höchstens eines, erneutes Öffnen fokussiert.
|
||||
Layout deklarativ in .axaml, nicht zur Laufzeit im Code. Kompilierte Bindings sind aktiv, jeder
|
||||
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)
|
||||
|
||||
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):
|
||||
|
||||
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
|
||||
@@ -1,62 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
|
||||
<AssemblyName>IBKRTrader.App</AssemblyName>
|
||||
<RootNamespace>IBKRTrader</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Core-, Modul- und Testprojekte liegen unter src/ bzw. tests/ und werden separat kompiliert. -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="src\**" />
|
||||
<None Remove="src\**" />
|
||||
<EmbeddedResource Remove="src\**" />
|
||||
<Compile Remove="tests\**" />
|
||||
<None Remove="tests\**" />
|
||||
<EmbeddedResource Remove="tests\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Icon-Ressourcen (Button-Icons, aus PolytraderSharp übernommen). -->
|
||||
<ItemGroup>
|
||||
<Compile Update="Properties\Resources.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="src\IBKRTrader.Core\IBKRTrader.Core.csproj" />
|
||||
<ProjectReference Include="src\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
|
||||
<ProjectReference Include="src\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
|
||||
<ProjectReference Include="src\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="settings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
<Solution>
|
||||
<Project Path="IBKRTrader.App.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.Accounting/IBKRTrader.Modules.Accounting.csproj" />
|
||||
<Project Path="src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj" />
|
||||
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.UI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher – das Basis-Fenster (Shell). Zeigt je registrierter View einen Button, führt beim Start
|
||||
/// Migrationen aus, startet Module und die WorkerEngine, und trägt das gemeinsame Fenster-Menü.
|
||||
/// Die inhaltlichen Ansichten (Logs, Settings, Workers, Module) sind eigenständige Fenster.
|
||||
/// </summary>
|
||||
public sealed class LauncherForm : Form
|
||||
{
|
||||
private readonly ShellUiHost _uiHost;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly LoggingService _logger;
|
||||
private readonly WorkerEngine _workerEngine;
|
||||
private readonly IReadOnlyList<IModule> _modules;
|
||||
|
||||
private readonly Dictionary<string, ToolStripButton> _viewButtons = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ToolStripStatusLabel _status = new("Start...");
|
||||
|
||||
public LauncherForm(ShellUiHost uiHost, IServiceProvider services)
|
||||
{
|
||||
_uiHost = uiHost;
|
||||
_services = services;
|
||||
_logger = services.GetRequiredService<LoggingService>();
|
||||
_workerEngine = services.GetRequiredService<WorkerEngine>();
|
||||
_modules = services.GetServices<IModule>().ToList();
|
||||
|
||||
Text = "IBKRTrader — Launcher";
|
||||
Width = 720;
|
||||
Height = 540;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
|
||||
_uiHost.SetMainWindow(this);
|
||||
|
||||
BuildUi();
|
||||
_uiHost.OpenStateChanged += UpdateButtonStates;
|
||||
}
|
||||
|
||||
private void BuildUi()
|
||||
{
|
||||
var menu = new MenuStrip { Dock = DockStyle.Top, ImageScalingSize = new Size(24, 24) };
|
||||
WindowMenu.Wire(menu, _uiHost, null);
|
||||
|
||||
// Fenster-Buttons in einem ToolStrip (Icon über Text) – wie im PolytraderSharp-Launcher.
|
||||
var toolstrip = new ToolStrip
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
GripStyle = ToolStripGripStyle.Hidden,
|
||||
ImageScalingSize = new Size(32, 32),
|
||||
AutoSize = true,
|
||||
Padding = new Padding(4)
|
||||
};
|
||||
|
||||
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||||
{
|
||||
var id = view.Id;
|
||||
var btn = new ToolStripButton(view.Title, view.Icon)
|
||||
{
|
||||
DisplayStyle = ToolStripItemDisplayStyle.ImageAndText,
|
||||
ImageScaling = ToolStripItemImageScaling.None,
|
||||
TextImageRelation = TextImageRelation.ImageAboveText,
|
||||
AutoSize = true,
|
||||
Padding = new Padding(6, 2, 6, 2)
|
||||
};
|
||||
btn.Click += (_, _) => _uiHost.OpenView(id);
|
||||
_viewButtons[id] = btn;
|
||||
toolstrip.Items.Add(btn);
|
||||
}
|
||||
|
||||
var content = new Panel { Dock = DockStyle.Fill, BackColor = SystemColors.ControlLightLight };
|
||||
content.Controls.Add(new Label
|
||||
{
|
||||
Text = "IBKRTrader — Launcher\nFenster über die Leiste oben öffnen.",
|
||||
Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleCenter,
|
||||
ForeColor = SystemColors.GrayText, Font = new Font(Font.FontFamily, 11f)
|
||||
});
|
||||
|
||||
var statusStrip = new StatusStrip();
|
||||
statusStrip.Items.Add(_status);
|
||||
|
||||
// Dock-Stacking: zuletzt hinzugefügtes Top-Control liegt oben → Menü über ToolStrip.
|
||||
Controls.Add(content);
|
||||
Controls.Add(statusStrip);
|
||||
Controls.Add(toolstrip);
|
||||
Controls.Add(menu);
|
||||
MainMenuStrip = menu;
|
||||
}
|
||||
|
||||
protected override void OnLoad(EventArgs e)
|
||||
{
|
||||
base.OnLoad(e);
|
||||
_ = StartupAsync();
|
||||
}
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
// Auch das Schließen-X läuft über die Sicherheitsabfrage.
|
||||
if (!_uiHost.ShutdownConfirmed)
|
||||
{
|
||||
e.Cancel = true;
|
||||
BeginInvoke((Action)(() => _uiHost.RequestShutdown()));
|
||||
return;
|
||||
}
|
||||
|
||||
_uiHost.CloseAllViews();
|
||||
try
|
||||
{
|
||||
foreach (var module in _modules)
|
||||
module.StopAsync(default).GetAwaiter().GetResult();
|
||||
}
|
||||
catch { /* Shutdown darf nicht am Modul scheitern */ }
|
||||
// Worker/Services stoppt der Host in Program.Main via AppHost.StopAsync() nach Application.Run.
|
||||
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
private async Task StartupAsync()
|
||||
{
|
||||
_logger.Info("Core", "=== IBKRTrader startet ===");
|
||||
_logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}");
|
||||
|
||||
// Log-Level aus Settings.
|
||||
var levelStr = _services.GetRequiredService<SettingsService>().Settings.Logging.Level;
|
||||
if (Enum.TryParse<AppLogLevel>(levelStr, true, out var level))
|
||||
_logger.SetMinLevel(level);
|
||||
|
||||
// Das gesamte Schema (core_ + ct_) läuft über EF-Migrationen, extern via
|
||||
// `dotnet ef database update` angewendet – keine Laufzeit-Migration mehr.
|
||||
|
||||
// Worker/Services laufen bereits (Generic Host, AppHost.Start()). Hier nur noch Modul-Start.
|
||||
foreach (var module in _modules)
|
||||
{
|
||||
try { await module.StartAsync(default); }
|
||||
catch (Exception ex) { _logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
|
||||
}
|
||||
|
||||
_logger.Info("Core", "IBKRTrader bereit.");
|
||||
SetStatus("Bereit");
|
||||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
private void UpdateButtonStates()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
if (InvokeRequired) { BeginInvoke((Action)UpdateButtonStates); return; }
|
||||
foreach (var (id, btn) in _viewButtons)
|
||||
btn.Checked = _uiHost.IsOpen(id);
|
||||
}
|
||||
|
||||
private void SetStatus(string text)
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
if (InvokeRequired) { BeginInvoke((Action)(() => SetStatus(text))); return; }
|
||||
_status.Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -20,8 +20,25 @@
|
||||
<package pattern="Newtonsoft.Json" />
|
||||
<!-- Offizielle TWS-C#-API (NuGet-Mirror) für den IBKR-Broker-Adapter -->
|
||||
<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.*" />
|
||||
<!-- Transitiv über EntityFrameworkCore.Design (Migrations-Codegenerierung). -->
|
||||
<package pattern="Microsoft.CodeAnalysis.*" />
|
||||
<package pattern="Pomelo.*" />
|
||||
<package pattern="Humanizer.*" />
|
||||
<package pattern="Mono.TextTemplating" />
|
||||
|
||||
-321
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
-303
@@ -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
|
||||
PolytraderSharp (nur IBKR statt Polymarket).
|
||||
|
||||
**Läuft auf Windows und Linux** – wahlweise mit Oberfläche (Avalonia) oder kopflos als Dienst.
|
||||
|
||||
## 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.Modules.* je Modul ein eigenes Projekt (referenziert nur Core)
|
||||
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`.
|
||||
- **Module** über `IModule` (RegisterServices/RegisterUi/Start/Stop); UI über `IModuleUiHost`/`ModuleView`.
|
||||
- **Persistenz**: EF Core (Pomelo/MariaDB), Migrationen **extern** angewendet (nicht zur Laufzeit).
|
||||
@@ -25,10 +32,21 @@ tests/IBKRTrader.Tests xUnit (Unit + EF-InMemory)
|
||||
```bash
|
||||
dotnet build 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
|
||||
- `appsettings.Local.json` (gitignored) hält den DB-Connection-String (`Database:MySqlConnectionString`).
|
||||
- `settings.json` (gitignored) – App-Settings (IBKR-Ports, Logging, Worker, Trading).
|
||||
|
||||
@@ -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/konzepte/KONZEPT-Linux-Portierung.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
NotifyAccess=all
|
||||
|
||||
User=ibkrtrader
|
||||
Group=ibkrtrader
|
||||
|
||||
WorkingDirectory=/opt/ibkrtrader
|
||||
ExecStart=/opt/ibkrtrader/IBKRTrader.Daemon
|
||||
|
||||
# Ablageorte. Ohne diese Variablen weicht die Anwendung selbst auf die FHS-Pfade aus, sobald
|
||||
# /opt/ibkrtrader nicht beschreibbar ist – ausdrücklich gesetzt ist es aber nachvollziehbarer.
|
||||
Environment=IBKRTRADER_CONFIG_DIR=/etc/ibkrtrader
|
||||
Environment=IBKRTRADER_DATA_DIR=/var/lib/ibkrtrader
|
||||
Environment=IBKRTRADER_LOG_DIR=/var/log/ibkrtrader
|
||||
|
||||
# Der Master-Key gehört NICHT in diese Datei (sie ist für alle lesbar). Entweder als Datei
|
||||
# /etc/ibkrtrader/master.key mit chmod 600, oder über eine EnvironmentFile mit 600:
|
||||
# EnvironmentFile=/etc/ibkrtrader/secrets.env
|
||||
Environment=DOTNET_EnableDiagnostics=0
|
||||
|
||||
# Geordnetes Herunterfahren: SIGTERM, dann Zeit für offene Broker-Anfragen und den Modul-Stopp.
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=60
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
|
||||
# Absicherung. Der Dienst braucht nur seine drei Verzeichnisse beschreibbar.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/ibkrtrader /var/log/ibkrtrader
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+40
-9
@@ -12,14 +12,25 @@ nur dass statt Polymarket über IBKR gehandelt wird.
|
||||
|
||||
## 1. Ziel-Architektur (nach PolytraderSharp)
|
||||
|
||||
> **Stand seit der Linux-Portierung (2026-08-07):** Alle Projekte sind `net10.0` ohne
|
||||
> Plattformbindung. Die WinForms-Shell ist entfernt; der letzte Stand liegt im Tag
|
||||
> `winforms-final`. Einstiegspunkte sind jetzt `IBKRTrader.App` (mit Oberfläche) und
|
||||
> `IBKRTrader.Daemon` (kopflos, systemd); beide bauen ihren Host über `IBKRTrader.Hosting`.
|
||||
> Analyse und Vorgehen: [konzepte/KONZEPT-Linux-Portierung.md](konzepte/KONZEPT-Linux-Portierung.md).
|
||||
|
||||
```
|
||||
IBKRTrader.App (WinExe, Root) – Generic Host + Shell (Launcher) + Core-Views
|
||||
│ Program.cs: Host.CreateDefaultBuilder, IConfiguration, Module laden, ShellUiHost, Application.Run
|
||||
│ Ui/: LauncherForm, ShellUiHost, Views/ (Dashboard, Terminal, Settings, Jobs)
|
||||
src/IBKRTrader.App (WinExe, net10.0) – Oberfläche: Launcher, Shell, Core-Views, Modul-Fenster
|
||||
│ Program.cs: Host bauen (Hosting), Startprüfungen, dann Avalonia; --smoke-ui ohne Anzeigegerät
|
||||
│ 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)
|
||||
│ │ 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
|
||||
│ ├── DependencyInjection/ AddCorePersistence(...)
|
||||
│ ├── Persistence/Ef/ CoreDbContext + Entities + EF-Repositories (hinter Interfaces)
|
||||
@@ -32,7 +43,7 @@ IBKRTrader.App (WinExe, Root) – Generic Host + Shell (Launcher) + C
|
||||
│ ├── CongressTradingModule : IModule
|
||||
│ ├── Persistence/Ef/ eigener DbContext (ct_) + Repos
|
||||
│ ├── 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)
|
||||
```
|
||||
@@ -45,9 +56,10 @@ IBKRTrader.App (WinExe, Root) – Generic Host + Shell (Launcher) + C
|
||||
- **Config**: `appsettings.json` + `appsettings.Local.json` (gitignored, hält Connection-String/Secrets).
|
||||
- **Modul-Vertrag** `IModule`: `Name`, `DbPrefix`, `RegisterServices(services, config)`,
|
||||
`RegisterUi(host, sp)`, `StartAsync/StopAsync`, `GetActivationBlocker(config)`.
|
||||
- **UI = Shell + Views**: Core und Module registrieren `ModuleView`s beim `IModuleUiHost`.
|
||||
Der Launcher öffnet je View ein Fenster (Einzelinstanz, Re-Open fokussiert). Gemeinsames
|
||||
„Fenster"-Menü (`WindowMenu`) auf jedem Form. Views sind designbare Forms mit `Initialize(sp)`.
|
||||
- **UI = Shell + Views**: Der Core stellt den toolkit-neutralen Contract (`ModuleView`,
|
||||
`IModuleUiHost`), die Shell setzt ihn in Avalonia um. Der Launcher öffnet je View ein Fenster
|
||||
(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.
|
||||
- **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.
|
||||
@@ -132,6 +144,25 @@ Pin `new MariaDbServerVersion(new Version(11, 8, 6))`. Verbindung aus `appsettin
|
||||
- [ ] **Offen:** `PlaceOrderAsync` mit echter Ausführung verifizieren (Fill → Buchung); asynchrone Fill-Verfolgung (Orders ohne sofortige Ausführung)
|
||||
- [ ] IBKR-Account-Credentials mit `EncryptedStringConverter` speichern
|
||||
|
||||
### L0–L5 – Linux-Portierung: Avalonia statt WinForms ✅ (2026-08-07)
|
||||
Analyse und Begründung: [konzepte/KONZEPT-Linux-Portierung.md](konzepte/KONZEPT-Linux-Portierung.md).
|
||||
Rückfallpunkt für den letzten WinForms-Stand: Tag `winforms-final`.
|
||||
|
||||
- [x] **L0** `NuGet.config` repariert – drei Pakete hatten kein `packageSourceMapping`-Muster; ein frischer Klon konnte nicht wiederherstellen (auf dem Entwicklungsrechner unsichtbar, weil gecacht)
|
||||
- [x] **L1a** Core und Module von WinForms entkoppelt: `net10.0` statt `net10.0-windows`. UI-Contract toolkit-neutral (`Func<object> CreateView`, `IconKey` statt `System.Drawing.Image` – letzteres ist seit .NET 7 Windows-only). `LoggingService` meldet über `EntryWritten` statt eine `RichTextBox` zu halten
|
||||
- [x] **L1b** **Betriebszeitzone** (`AppTimeZone`, `Trading.ApplicationTimeZoneId`): EU- und US-Instanzen sauber getrennt, Persistenz bleibt UTC. `ParseExecutionTime` verwirft die von TWS gemeldete Börsenzeitzone nicht mehr, sondern rechnet gegen sie nach UTC. Kultur-Fixes (PDF-Beträge fest `de-DE`, Scraper-Datum `TryParseExact`), `BackupWorker` plattformunabhängig, DB-Passwort über `MYSQL_PWD` statt Kommandozeile
|
||||
- [x] **L2** Kopfloser Dienst `IBKRTrader.Daemon` (systemd, `--check`, `--db-version`) + `IBKRTrader.Hosting` als geteilte Host-Zusammenstellung. `AppPaths` (Umgebungsvariable → Binärverzeichnis wenn beschreibbar → FHS)
|
||||
- [x] **L3** Avalonia-Shell (11.3.19, DataGrid 11.3.13) + Core-Ansichten. `PropertyGrid` ersetzt durch eine aus den Attributen erzeugte Einstellungsmaske (11 Abschnitte, 41 Felder)
|
||||
- [x] **L4** Die drei Modul-Fenster portiert; CI-Matrix ubuntu + windows
|
||||
- [x] **L5** WinForms vollständig entfernt – `IBKRTrader.App`, `LauncherForm`, `UI/`, `Properties/Resources.*`
|
||||
|
||||
**Ergebnis:** Alle Projekte `net10.0` ohne Plattformbindung. `publish -r linux-x64` liefert Daemon (11 MB)
|
||||
und Oberfläche (32 MB) ohne eine einzige Windows-Abhängigkeit. Der Smoke-UI-Lauf braucht kein
|
||||
Anzeigegerät mehr und ist damit erstmals Teil der CI.
|
||||
|
||||
**Offen:** IB Gateway kopflos betreiben (IBC + Xvfb) – eigene Baustelle, unabhängig vom Code;
|
||||
LiveCharts2 kommt mit den neuen Modulen (Avalonia deshalb auf der 11er-Linie gepinnt).
|
||||
|
||||
---
|
||||
|
||||
## Kurskorrektur abgeschlossen (R1–R7)
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 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;
|
||||
|
||||
@@ -56,7 +56,7 @@ Registrierung in `Program.cs`. Referenziert nur den Core. Eigener `AccountingDbC
|
||||
`PdfExporter` (PDFsharp/MigraDoc, MIT).
|
||||
- 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),
|
||||
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).
|
||||
|
||||
@@ -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,56 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Plattformneutrale Oberfläche: dieselbe Anwendung läuft unter Windows und Linux.
|
||||
WinExe unterdrückt nur das Konsolenfenster unter Windows – mit WinForms hat das nichts
|
||||
zu tun. Der letzte Stand vor der Portierung liegt im Git-Tag winforms-final. -->
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>IBKRTrader.App</AssemblyName>
|
||||
<RootNamespace>IBKRTrader.App</RootNamespace>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
<!-- Layout wird deklarativ in .axaml gebaut, nicht zur Laufzeit im Code. Kompilierte Bindings
|
||||
verlangen ein x:DataType je Datenkontext – dafür fallen Tippfehler in Bindings beim
|
||||
Kompilieren auf statt erst zur Laufzeit. -->
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<!-- AVLN3001: Die Fenster haben bewusst KEINEN parameterlosen Konstruktor – sie bekommen ihre
|
||||
Dienste per DI. Damit sind sie für den XAML-Previewer nicht ladbar, was wir hinnehmen:
|
||||
ein parameterloser Konstruktor würde ein Fenster ohne seine Dienste konstruierbar machen
|
||||
und genau den Fehler verdecken, den die Konstruktionsprüfung finden soll. -->
|
||||
<NoWarn>$(NoWarn);AVLN3001</NoWarn>
|
||||
<!-- Wie beim Daemon: ICU ist Pflicht. Ohne sie kippt die feste de-DE-Formatierung des
|
||||
PDF-Exports auf invariant und Windows-Zeitzonen-IDs lassen sich nicht mehr auflösen. -->
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- BEWUSST die 11er-Linie, nicht 12.x. LiveCharts2 (2.0.5, aktuellste Fassung) ist gegen
|
||||
Avalonia 11 gebaut und bricht unter 12: Avalonia.Input.Gestures.PinchEvent gibt es dort
|
||||
nicht mehr. Diagramme kommen mit den neuen Modulen – erst wenn LiveCharts2 Avalonia 12
|
||||
unterstützt, darf hier angehoben werden. Erfahrung aus PolytraderSharp. -->
|
||||
<PackageReference Include="Avalonia" Version="11.3.19" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.19" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.19" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.19" />
|
||||
<!-- DataGrid folgt einer eigenen Versionsreihe und endet in der 11er-Linie bei 11.3.13. -->
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\IBKRTrader.Hosting\IBKRTrader.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Die Symbole der bisherigen Oberfläche unverändert weiterverwenden (siehe Shell/ViewIcons.cs). -->
|
||||
<AvaloniaResource Include="..\..\Resources\*.png" Link="Assets\%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,81 @@
|
||||
using Avalonia;
|
||||
using IBKRTrader.App.Shell;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace IBKRTrader.App;
|
||||
|
||||
/// <summary>
|
||||
/// Einstiegspunkt der plattformneutralen Oberfläche.
|
||||
///
|
||||
/// <para>Bewusst zweigeteilt: <see cref="AppHostBuilder"/> stellt den Host mit Persistenz,
|
||||
/// Diensten und Modulen zusammen – ohne jeden Bezug zur Oberfläche. Erst danach wird Avalonia
|
||||
/// daran gehängt. Derselbe Host trägt den kopflosen Linux-Dienst
|
||||
/// (<c>IBKRTrader.Daemon</c>).</para>
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
// Konstruktionsprüfung aller Fenster ohne Message-Loop und ohne laufende Dienste.
|
||||
if (HasFlag(args, "--smoke-ui")) return SmokeUi.Run();
|
||||
|
||||
var modules = AppHostBuilder.CreateModules();
|
||||
using var host = AppHostBuilder.Build(modules, ShellServices.Register);
|
||||
|
||||
AppHostBuilder.RunStartupChecks(host.Services);
|
||||
|
||||
var logger = host.Services.GetRequiredService<LoggingService>();
|
||||
logger.Info("Core", "=== IBKRTrader startet ===");
|
||||
logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}");
|
||||
|
||||
host.Start();
|
||||
StartModules(modules, logger);
|
||||
|
||||
try
|
||||
{
|
||||
App.Services = host.Services;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Geordnetes Herunterfahren – erst nachdem die Oberfläche beendet ist.
|
||||
StopModules(modules, logger);
|
||||
host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Von Avalonia erwartete Fabrikmethode (auch vom XAML-Previewer genutzt).</summary>
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
|
||||
private static void StartModules(IReadOnlyList<IModule> modules, LoggingService logger)
|
||||
{
|
||||
foreach (var module in modules)
|
||||
{
|
||||
try { module.StartAsync(default).GetAwaiter().GetResult(); }
|
||||
catch (Exception ex) { logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
|
||||
}
|
||||
logger.Info("Core", "IBKRTrader bereit.");
|
||||
}
|
||||
|
||||
private static void StopModules(IReadOnlyList<IModule> modules, LoggingService logger)
|
||||
{
|
||||
foreach (var module in modules)
|
||||
{
|
||||
try { module.StopAsync(default).GetAwaiter().GetResult(); }
|
||||
catch (Exception ex) { logger.Warn(module.Name, $"{module.Name}: Stopp fehlgeschlagen: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasFlag(string[] args, string flag) =>
|
||||
args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace IBKRTrader.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Die Verzeichnisse, in die die Anwendung schreibt.
|
||||
///
|
||||
/// <para><b>Warum das nötig wurde:</b> Bisher lag alles neben der Binärdatei – <c>Logs/</c>,
|
||||
/// <c>Backups/</c>, <c>settings.json</c>, <c>master.key</c>. Unter Windows ist das üblich. Auf
|
||||
/// Linux liegt eine Anwendung typischerweise unter <c>/opt</c> oder <c>/usr/local</c>, und dort
|
||||
/// hat der Dienstbenutzer <b>keinen Schreibzugriff</b>. Der Dienst wäre beim ersten Logeintrag
|
||||
/// gescheitert.</para>
|
||||
///
|
||||
/// <para><b>Auflösung, in dieser Reihenfolge:</b></para>
|
||||
/// <list type="number">
|
||||
/// <item>Umgebungsvariable (<c>IBKRTRADER_CONFIG_DIR</c>, <c>_DATA_DIR</c>, <c>_LOG_DIR</c>) –
|
||||
/// hat immer Vorrang, damit ein Betreiber die Ablage frei bestimmen kann.</item>
|
||||
/// <item>Das Verzeichnis der Binärdatei, <b>wenn dort geschrieben werden darf</b>. Das hält das
|
||||
/// bisherige Verhalten unter Windows und beim Entwickeln unter Linux unverändert.</item>
|
||||
/// <item>Sonst die FHS-Konvention: <c>/etc/ibkrtrader</c>, <c>/var/lib/ibkrtrader</c>,
|
||||
/// <c>/var/log/ibkrtrader</c>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Die Prüfung läuft einmal beim ersten Zugriff; die Verzeichnisse werden dabei angelegt.</para>
|
||||
/// </summary>
|
||||
public static class AppPaths
|
||||
{
|
||||
private const string AppFolder = "ibkrtrader";
|
||||
|
||||
private static readonly Lazy<string> _config = new(() => Resolve("IBKRTRADER_CONFIG_DIR", $"/etc/{AppFolder}"));
|
||||
private static readonly Lazy<string> _data = new(() => Resolve("IBKRTRADER_DATA_DIR", $"/var/lib/{AppFolder}"));
|
||||
private static readonly Lazy<string> _logs = new(() => Resolve("IBKRTRADER_LOG_DIR", $"/var/log/{AppFolder}", "Logs"));
|
||||
|
||||
/// <summary>Konfiguration und Schlüsselmaterial: <c>settings.json</c>, <c>master.key</c>, <c>openrouter.key</c>.</summary>
|
||||
public static string Config => _config.Value;
|
||||
|
||||
/// <summary>Veränderliche Daten: <c>Backups/</c>.</summary>
|
||||
public static string Data => _data.Value;
|
||||
|
||||
/// <summary>Logdateien (Textlog je Modul und JSONL).</summary>
|
||||
public static string Logs => _logs.Value;
|
||||
|
||||
/// <summary>Vollständiger Pfad einer Konfigurationsdatei.</summary>
|
||||
public static string ConfigFile(string fileName) => Path.Combine(Config, fileName);
|
||||
|
||||
/// <summary>Vollständiger Pfad unterhalb des Datenverzeichnisses.</summary>
|
||||
public static string DataPath(string relative) => Path.Combine(Data, relative);
|
||||
|
||||
/// <summary>Kurzfassung für den Startlog – damit im Betrieb sichtbar ist, wohin geschrieben wird.</summary>
|
||||
public static string Describe() => $"config={Config}, data={Data}, logs={Logs}";
|
||||
|
||||
/// <param name="envVar">Umgebungsvariable, die alles überstimmt.</param>
|
||||
/// <param name="fhsFallback">FHS-Pfad, wenn neben der Binärdatei nicht geschrieben werden darf.</param>
|
||||
/// <param name="localSubDir">
|
||||
/// Unterverzeichnis im Binärverzeichnis. Für Logs ist das <c>Logs/</c>; Konfiguration und Daten
|
||||
/// lagen bisher direkt daneben und bleiben dort, damit bestehende Installationen unverändert
|
||||
/// weiterlaufen.
|
||||
/// </param>
|
||||
private static string Resolve(string envVar, string fhsFallback, string? localSubDir = null)
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable(envVar);
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||
return Ensure(fromEnv.Trim());
|
||||
|
||||
var local = localSubDir is null
|
||||
? AppContext.BaseDirectory
|
||||
: Path.Combine(AppContext.BaseDirectory, localSubDir);
|
||||
|
||||
if (IsWritable(AppContext.BaseDirectory))
|
||||
return Ensure(local);
|
||||
|
||||
return Ensure(fhsFallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft den Schreibzugriff, indem tatsächlich geschrieben wird. Eine Rechteprüfung über
|
||||
/// Attribute trägt nicht: unter Linux entscheiden Besitzer, Gruppe und Modus, unter Windows
|
||||
/// die ACL – ein Schreibversuch ist die einzige verlässliche Antwort.
|
||||
/// </summary>
|
||||
private static bool IsWritable(string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
var probe = Path.Combine(dir, $".write-probe-{Guid.NewGuid():N}");
|
||||
using (File.Create(probe, 1, FileOptions.DeleteOnClose)) { }
|
||||
return true;
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return false; }
|
||||
catch (IOException) { return false; }
|
||||
}
|
||||
|
||||
private static string Ensure(string dir)
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
@@ -244,7 +244,7 @@ public class IBKRGatewayService
|
||||
var url = $"iserver/marketdata/history?conid={conid}" +
|
||||
$"&period={Uri.EscapeDataString(period)}" +
|
||||
$"&bar={Uri.EscapeDataString(bar)}" +
|
||||
$"&outsideRth={outsideRth.ToString().ToLower()}";
|
||||
$"&outsideRth={(outsideRth ? "true" : "false")}";
|
||||
|
||||
var response = await _http.GetAsync(url, ct);
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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(
|
||||
DateTime Timestamp,
|
||||
AppLogLevel Level,
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
using IBKRTrader.Core.Configuration;
|
||||
using IBKRTrader.Core.Time;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace IBKRTrader.Core.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-sicherer Logging-Service.
|
||||
/// – Schreibt farbig in die RichTextBox (UI-Thread-safe via BeginInvoke)
|
||||
/// – Schreibt in Logs\{Module}\{Level}-dd-MM-yy.txt
|
||||
/// – Schreibt in Logs\{Module}\{Level}-dd-MM-yy.txt sowie strukturiert nach Logs\{Datum}.jsonl
|
||||
/// – 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>
|
||||
public class LoggingService
|
||||
{
|
||||
private RichTextBox? _rtb;
|
||||
private AppLogLevel _minLevel = AppLogLevel.Info;
|
||||
private readonly object _fileLock = new();
|
||||
private readonly object _jsonlLock = new();
|
||||
|
||||
private static readonly string LogBaseDir =
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
|
||||
AppPaths.Logs;
|
||||
|
||||
// ─── 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;
|
||||
|
||||
@@ -48,10 +60,27 @@ public class LoggingService
|
||||
public void Write(AppLogLevel level, string module, string message, Exception? ex = null, string? cid = null)
|
||||
{
|
||||
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);
|
||||
WriteToJsonl(entry, cid);
|
||||
WriteToRtb(entry);
|
||||
WriteToJsonl(entry, utc, cid);
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
@@ -66,10 +95,10 @@ public class LoggingService
|
||||
var file = Path.Combine(dir, $"{e.Level}-{e.Timestamp:dd-MM-yy}.txt");
|
||||
var line = $"[{e.Timestamp:HH:mm:ss}] {e.Message}";
|
||||
if (e.Exception != null)
|
||||
line += $"\r\n {e.Exception}";
|
||||
line += $"{Environment.NewLine} {e.Exception}";
|
||||
|
||||
lock (_fileLock)
|
||||
File.AppendAllText(file, line + "\r\n");
|
||||
File.AppendAllText(file, line + Environment.NewLine);
|
||||
}
|
||||
catch { /* Logging darf niemals abstürzen */ }
|
||||
}
|
||||
@@ -79,15 +108,16 @@ public class LoggingService
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Dateiname nach Ortszeit (Tagesgrenze), <c>ts</c>-Feld in UTC (maschinenlesbar).
|
||||
/// </summary>
|
||||
private void WriteToJsonl(LogEntry e, string? cid)
|
||||
private void WriteToJsonl(LogEntry e, DateTime utc, string? cid)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(LogBaseDir);
|
||||
var file = Path.Combine(LogBaseDir, $"{e.Timestamp:yyyy-MM-dd}.jsonl");
|
||||
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)
|
||||
File.AppendAllText(file, json + "\n");
|
||||
@@ -95,45 +125,18 @@ public class LoggingService
|
||||
catch { /* Logging darf niemals abstürzen */ }
|
||||
}
|
||||
|
||||
// ─── RichTextBox ──────────────────────────────────────────────────────────
|
||||
// ─── Anzeigeformat ────────────────────────────────────────────────────────
|
||||
|
||||
private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150);
|
||||
private static readonly Color ColorWarn = Color.FromArgb(255, 190, 60);
|
||||
private static readonly Color ColorError = Color.FromArgb(255, 80, 80);
|
||||
|
||||
private void WriteToRtb(LogEntry e)
|
||||
/// <summary>
|
||||
/// Einzeilige Darstellung für Log-Ansichten. Liegt hier, damit jede Oberfläche dieselbe Zeile
|
||||
/// zeigt. <c>ToUpperInvariant</c> ist Absicht: <c>ToUpper()</c> würde unter tr-TR aus "info"
|
||||
/// ein "İNFO" machen.
|
||||
/// </summary>
|
||||
public static string Format(LogEntry e)
|
||||
{
|
||||
if (_rtb == null) return;
|
||||
try
|
||||
{
|
||||
var color = e.Level switch
|
||||
{
|
||||
AppLogLevel.Warn => ColorWarn,
|
||||
AppLogLevel.Error => ColorError,
|
||||
_ => ColorInfo
|
||||
};
|
||||
var text = $"[{e.Timestamp:HH:mm:ss}] [{e.Level.ToString().ToUpper(),-5}] [{e.Module}] {e.Message}";
|
||||
if (e.Exception != null)
|
||||
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();
|
||||
var text = $"[{e.Timestamp:HH:mm:ss}] [{e.Level.ToString().ToUpperInvariant(),-5}] [{e.Module}] {e.Message}";
|
||||
if (e.Exception != null)
|
||||
text += $"{Environment.NewLine} {e.Exception.Message}";
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace IBKRTrader.Core.Modularity;
|
||||
|
||||
/// <summary>
|
||||
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die eigentliche UI ist ein
|
||||
/// <see cref="Form"/>, das über <see cref="CreateForm"/> erzeugt wird (mit DI-Abhängigkeiten).
|
||||
/// Die Shell zeigt je View höchstens eine Instanz und holt ein offenes Fenster wieder nach vorne.
|
||||
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. 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>
|
||||
public sealed class ModuleView
|
||||
{
|
||||
/// <summary>Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View).</summary>
|
||||
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";
|
||||
|
||||
/// <summary>Optionale Gruppierung (z. B. "Core", "CongressTrading").</summary>
|
||||
@@ -21,17 +25,24 @@ public sealed class ModuleView
|
||||
/// <summary>Optionale Sortierreihenfolge in Menü/Buttons.</summary>
|
||||
public int Order { get; init; } = 0;
|
||||
|
||||
/// <summary>Optionales Icon für Menü/Buttons.</summary>
|
||||
public System.Drawing.Image? Icon { get; set; }
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public Func<Form> CreateForm { get; init; } = () => new Form();
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// 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)
|
||||
/// das gemeinsame „Fenster"-Menü bauen (siehe <see cref="WindowMenu"/>).
|
||||
/// das gemeinsame „Fenster"-Menü bauen.
|
||||
/// </summary>
|
||||
public interface IModuleUiHost
|
||||
{
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace IBKRTrader.Core.Modularity;
|
||||
|
||||
/// <summary>
|
||||
/// Baut das gemeinsame Fenster-Menü, das auf JEDEM Fenster erscheint und das Wechseln zwischen allen
|
||||
/// Fenstern (Launcher + Core + Module) erlaubt. Da es nur den Core-Contract <see cref="IModuleUiHost"/>
|
||||
/// nutzt, funktioniert es auch aus Modul-Fenstern (die die App nicht kennen).
|
||||
/// </summary>
|
||||
public static class WindowMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Verdrahtet einen <see cref="MenuStrip"/> mit der Fensterliste: füllt ihn sofort und baut ihn bei
|
||||
/// jeder Offen-Status-Änderung neu auf. Die Registrierung wird beim Entsorgen sauber gelöst.
|
||||
/// </summary>
|
||||
public static void Wire(MenuStrip menu, IModuleUiHost host, string? currentViewId)
|
||||
{
|
||||
void Refresh()
|
||||
{
|
||||
if (menu.IsDisposed) return;
|
||||
if (menu.IsHandleCreated && menu.InvokeRequired)
|
||||
{
|
||||
try { menu.BeginInvoke((Action)(() => Populate(menu, host, currentViewId))); }
|
||||
catch { /* Fenster wird gerade geschlossen */ }
|
||||
return;
|
||||
}
|
||||
Populate(menu, host, currentViewId);
|
||||
}
|
||||
|
||||
Populate(menu, host, currentViewId);
|
||||
host.OpenStateChanged += Refresh;
|
||||
menu.Disposed += (_, _) => host.OpenStateChanged -= Refresh;
|
||||
}
|
||||
|
||||
/// <summary>Baut die Menüleiste komplett neu auf (Launcher, alle Views nebeneinander, Aktion rechts).</summary>
|
||||
public static void Populate(MenuStrip menu, IModuleUiHost host, string? currentViewId)
|
||||
{
|
||||
menu.Items.Clear();
|
||||
|
||||
var launcher = new ToolStripMenuItem("Launcher") { Checked = currentViewId == null };
|
||||
if (currentViewId == null) launcher.Font = new Font(launcher.Font, FontStyle.Bold);
|
||||
launcher.Click += (_, _) => host.ActivateMain();
|
||||
menu.Items.Add(launcher);
|
||||
|
||||
foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||||
{
|
||||
bool isCurrent = view.Id == currentViewId;
|
||||
var item = new ToolStripMenuItem(view.Title)
|
||||
{
|
||||
Image = view.Icon,
|
||||
ImageScaling = ToolStripItemImageScaling.SizeToFit,
|
||||
DisplayStyle = view.Icon != null
|
||||
? ToolStripItemDisplayStyle.ImageAndText
|
||||
: ToolStripItemDisplayStyle.Text,
|
||||
Checked = isCurrent || host.IsOpen(view.Id)
|
||||
};
|
||||
if (isCurrent) item.Font = new Font(item.Font, FontStyle.Bold);
|
||||
string id = view.Id;
|
||||
item.Click += (_, _) => host.OpenView(id);
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
|
||||
// Kontextabhängige rechte Aktion: nur der Launcher darf die App beenden; jedes andere Fenster
|
||||
// bietet nur „Fenster schließen" (kein App-Shutdown, Module laufen weiter).
|
||||
if (currentViewId == null)
|
||||
{
|
||||
var exit = new ToolStripMenuItem("Beenden") { Alignment = ToolStripItemAlignment.Right };
|
||||
exit.Click += (_, _) => host.RequestShutdown();
|
||||
menu.Items.Add(exit);
|
||||
}
|
||||
else
|
||||
{
|
||||
var close = new ToolStripMenuItem("Fenster schließen") { Alignment = ToolStripItemAlignment.Right };
|
||||
close.Click += (_, _) => menu.FindForm()?.Close();
|
||||
menu.Items.Add(close);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,15 @@ public class TradingSettings
|
||||
[Description("Handelsmodus: Paper (Test-Account, Port 4002) oder Live (Port 4001)")]
|
||||
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")]
|
||||
[DisplayName("Trading aktiv")]
|
||||
[Description("Globaler Hauptschalter. Nur wenn aktiv werden Orders ausgeführt.")]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using IBKRTrader.Core.Configuration;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -10,7 +11,7 @@ namespace IBKRTrader.Core.Settings;
|
||||
public class SettingsService
|
||||
{
|
||||
private static readonly string SettingsPath =
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json");
|
||||
AppPaths.ConfigFile("settings.json");
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using IBApi;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Time;
|
||||
|
||||
namespace IBKRTrader.Core.Trading.Ibkr;
|
||||
|
||||
@@ -458,7 +459,7 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
slot.Items.Add(new BrokerExecution
|
||||
{
|
||||
ExecId = execution.ExecId,
|
||||
Time = IbkrMapping.ParseExecutionTime(execution.Time) ?? DateTime.MinValue,
|
||||
Time = IbkrMapping.ParseExecutionTime(execution.Time, AppTimeZone.Current) ?? DateTime.MinValue,
|
||||
Symbol = contract.Symbol,
|
||||
SecType = contract.SecType,
|
||||
Side = IbkrMapping.ParseSide(execution.Side),
|
||||
|
||||
@@ -94,21 +94,64 @@ internal static class IbkrMapping
|
||||
side.Trim().ToUpperInvariant() is "SLD" or "SELL" ? TradeSide.Sell : TradeSide.Buy;
|
||||
|
||||
/// <summary>
|
||||
/// Zeitstempel einer Ausführung. TWS liefert je nach Aufruf "yyyyMMdd HH:mm:ss" (mit doppeltem
|
||||
/// Leerzeichen) oder zusätzlich eine Zeitzone ("20260804 17:52:56 Europe/Berlin"). Die Zeitzone
|
||||
/// wird verworfen – der Wert bleibt Ortszeit der Börse, wie ihn TWS meldet.
|
||||
/// Zeitstempel einer Ausführung, <b>immer als UTC</b> (<see cref="DateTimeKind.Utc"/>).
|
||||
///
|
||||
/// <para>TWS liefert je nach Aufruf "20260804 17:39:18" (doppeltes Leerzeichen, ohne Zone) oder
|
||||
/// "20260804 17:52:56 Europe/Berlin" (mit IANA-Zone) – beide Formen sind gegen das Paper-Gateway
|
||||
/// gemessen. Ist eine Zone angegeben, wird gegen sie nach UTC gerechnet; sonst gilt
|
||||
/// <paramref name="fallbackZone"/>, also die Betriebszeitzone der Instanz.</para>
|
||||
///
|
||||
/// <para><b>Warum nicht mehr „Zone verwerfen":</b> wir betreiben Instanzen in EU und US. Würde
|
||||
/// die gemeldete Zone weggeworfen, bekäme eine Ausführung an der NYSE denselben nackten
|
||||
/// Zeitwert wie eine an der Eurex – und läge in den Büchern um Stunden daneben, ohne dass
|
||||
/// irgendwo ein Fehler auftaucht.</para>
|
||||
/// </summary>
|
||||
public static DateTime? ParseExecutionTime(string? raw)
|
||||
/// <param name="fallbackZone">
|
||||
/// Zeitzone für Meldungen ohne Zonenangabe – die Betriebszeitzone (<c>AppTimeZone.Current</c>).
|
||||
/// </param>
|
||||
public static DateTime? ParseExecutionTime(string? raw, TimeZoneInfo fallbackZone)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
|
||||
var parts = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
// Datum und Uhrzeit trennen TWS je nach Aufruf per Leerzeichen oder Bindestrich
|
||||
// ("20260804-17:52:56" ist das Format, das auch der Anfragefilter nutzt).
|
||||
var parts = raw.Replace('-', ' ')
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2) return null;
|
||||
|
||||
return DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
if (!DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var local))
|
||||
return null;
|
||||
|
||||
// Dritter Teil, falls vorhanden, ist die Zeitzone der Börse.
|
||||
var zone = parts.Length >= 3 ? ResolveZone(parts[2]) ?? fallbackZone : fallbackZone;
|
||||
|
||||
local = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
|
||||
|
||||
// Bei der Zeitumstellung kann die Ortszeit ungültig (Vorstellen) oder doppelt (Zurückstellen)
|
||||
// sein. ConvertTimeToUtc würde bei ungültigen Werten werfen – eine Ausführung darf daran
|
||||
// nicht verlorengehen, deshalb der ausdrückliche Versatz.
|
||||
if (zone.IsInvalidTime(local))
|
||||
return DateTime.SpecifyKind(local - zone.BaseUtcOffset, DateTimeKind.Utc);
|
||||
|
||||
return TimeZoneInfo.ConvertTimeToUtc(local, zone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst die von TWS gemeldete Zonenangabe auf. TWS liefert IANA-Schreibweise
|
||||
/// ("Europe/Berlin", "US/Eastern"); unter Windows braucht es dafür die Umrechnung.
|
||||
/// Unbekannte Angabe = <c>null</c>, damit der Aufrufer auf die Betriebszeitzone ausweichen kann.
|
||||
/// </summary>
|
||||
private static TimeZoneInfo? ResolveZone(string id)
|
||||
{
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
|
||||
catch (TimeZoneNotFoundException) { }
|
||||
catch (InvalidTimeZoneException) { }
|
||||
|
||||
if (TimeZoneInfo.TryConvertIanaIdToWindowsId(id, out var windowsId))
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(windowsId!); } catch { /* unbekannt */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -111,6 +111,11 @@ public sealed record BrokerExecution
|
||||
{
|
||||
/// <summary>Eindeutige IBKR-Ausführungs-ID – geeignet als Idempotenzschlüssel beim Import.</summary>
|
||||
public required string ExecId { get; init; }
|
||||
/// <summary>
|
||||
/// Ausführungszeit in <b>UTC</b>. TWS meldet Börsen-Ortszeit (teils mit Zonenangabe); die
|
||||
/// Umrechnung erfolgt in <c>IbkrMapping.ParseExecutionTime</c>. UTC ist Pflicht, weil wir
|
||||
/// Instanzen in EU und US betreiben und die Daten vergleichbar bleiben müssen.
|
||||
/// </summary>
|
||||
public required DateTime Time { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public required string SecType { get; init; }
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using IBKRTrader.Core.Configuration;
|
||||
using System.Globalization;
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Core.Persistence.Ef;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using IBKRTrader.Core.Logging;
|
||||
@@ -7,9 +10,9 @@ namespace IBKRTrader.Core.Workers.BuiltIn;
|
||||
|
||||
/// <summary>
|
||||
/// BackupWorker (Core) – läuft alle 30 Minuten (konfigurierbar).
|
||||
/// 1. Ruft mysqldump.exe auf → SQL-Dump in Backups\DB\
|
||||
/// 2. Kopiert Logs\ → Backups\Logs\
|
||||
/// Falls mysqldump nicht gefunden: Warnung und Überspringen.
|
||||
/// 1. Ruft mariadb-dump bzw. mysqldump auf → SQL-Dump nach Backups/DB/
|
||||
/// 2. Kopiert Logs/ → Backups/Logs/
|
||||
/// Wird das Werkzeug nicht gefunden: Warnung und Überspringen, nie ein harter Fehler.
|
||||
/// </summary>
|
||||
public class BackupWorker : WorkerBase
|
||||
{
|
||||
@@ -31,8 +34,8 @@ public class BackupWorker : WorkerBase
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm");
|
||||
var backupRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Backups");
|
||||
var timestamp = AppTimeZone.Now.ToString("yyyy-MM-dd_HH-mm");
|
||||
var backupRoot = AppPaths.DataPath("Backups");
|
||||
var dbDir = Path.Combine(backupRoot, "DB");
|
||||
var logsDir = Path.Combine(backupRoot, "Logs");
|
||||
|
||||
@@ -52,39 +55,50 @@ public class BackupWorker : WorkerBase
|
||||
|
||||
private async Task RunMysqlDumpAsync(string dbDir, string timestamp, CancellationToken ct)
|
||||
{
|
||||
var dump = FindMysqldump();
|
||||
var dump = FindDumpTool();
|
||||
if (dump == null)
|
||||
{
|
||||
Logger.Warn(Module, "mysqldump.exe nicht gefunden – DB-Backup übersprungen.");
|
||||
Logger.Warn(Module, $"{string.Join(" / ", ToolNames())} nicht gefunden – DB-Backup übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
var db = _settings.Settings.Database;
|
||||
var dumpFile = Path.Combine(dbDir, $"{db.Database}_{timestamp}.sql");
|
||||
|
||||
var args = $"--host={db.Host} --port={db.Port} " +
|
||||
$"--user={db.User} --password={db.Password} " +
|
||||
$"--single-transaction --routines --triggers " +
|
||||
$"{db.Database} --result-file=\"{dumpFile}\"";
|
||||
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = dump,
|
||||
Arguments = args,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
// Argumente einzeln statt als eine Zeichenkette: das erspart das Quoting von Pfaden mit
|
||||
// Leerzeichen und ist auf beiden Plattformen dasselbe.
|
||||
psi.ArgumentList.Add($"--host={db.Host}");
|
||||
psi.ArgumentList.Add($"--port={db.Port.ToString(CultureInfo.InvariantCulture)}");
|
||||
psi.ArgumentList.Add($"--user={db.User}");
|
||||
psi.ArgumentList.Add("--single-transaction");
|
||||
psi.ArgumentList.Add("--routines");
|
||||
psi.ArgumentList.Add("--triggers");
|
||||
psi.ArgumentList.Add(db.Database);
|
||||
psi.ArgumentList.Add($"--result-file={dumpFile}");
|
||||
|
||||
// Passwort über die Umgebung statt über die Kommandozeile. Auf Linux ist
|
||||
// /proc/<pid>/cmdline für JEDEN lokalen Nutzer lesbar – das Passwort hätte dort für die
|
||||
// Dauer des Dumps offen im Prozessbaum gestanden. MYSQL_PWD ist der von MySQL/MariaDB
|
||||
// dafür vorgesehene Weg und wird nur an diesen Kindprozess vererbt.
|
||||
psi.Environment["MYSQL_PWD"] = db.Password;
|
||||
|
||||
using var proc = System.Diagnostics.Process.Start(psi)
|
||||
?? throw new InvalidOperationException("mysqldump konnte nicht gestartet werden.");
|
||||
?? throw new InvalidOperationException($"{Path.GetFileName(dump)} konnte nicht gestartet werden.");
|
||||
|
||||
await proc.WaitForExitAsync(ct);
|
||||
|
||||
if (proc.ExitCode != 0)
|
||||
{
|
||||
var err = await proc.StandardError.ReadToEndAsync(ct);
|
||||
Logger.Warn(Module, $"mysqldump Exitcode {proc.ExitCode}: {err}");
|
||||
Logger.Warn(Module, $"{Path.GetFileName(dump)} Exitcode {proc.ExitCode}: {err}");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -92,27 +106,57 @@ public class BackupWorker : WorkerBase
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindMysqldump()
|
||||
/// <summary>
|
||||
/// Namen des Dump-Werkzeugs. MariaDB hat <c>mysqldump</c> ab 10.5 in <c>mariadb-dump</c>
|
||||
/// umbenannt und pflegt den alten Namen nur noch als Alias – auf neueren Distributionen ist
|
||||
/// oft nur der neue vorhanden. Die Dateiendung gibt es nur unter Windows.
|
||||
/// </summary>
|
||||
private static string[] ToolNames() =>
|
||||
OperatingSystem.IsWindows()
|
||||
? ["mariadb-dump.exe", "mysqldump.exe"]
|
||||
: ["mariadb-dump", "mysqldump"];
|
||||
|
||||
/// <summary>
|
||||
/// Sucht das Dump-Werkzeug: erst die üblichen Installationspfade der jeweiligen Plattform,
|
||||
/// dann PATH. Findet es nichts, wird das Backup übersprungen (nie ein harter Fehler).
|
||||
/// </summary>
|
||||
private static string? FindDumpTool()
|
||||
{
|
||||
// Häufige Installationspfade auf Windows-Server
|
||||
var 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"
|
||||
};
|
||||
var names = ToolNames();
|
||||
|
||||
foreach (var c in candidates)
|
||||
if (File.Exists(c)) return c;
|
||||
// Übliche Installationspfade – plattformabhängig.
|
||||
string[] dirs = OperatingSystem.IsWindows()
|
||||
?
|
||||
[
|
||||
@"C:\Program Files\MariaDB\bin",
|
||||
@"C:\Program Files\MySQL\MySQL Server 8.0\bin",
|
||||
@"C:\Program Files\MySQL\MySQL Server 8.4\bin",
|
||||
@"C:\xampp\mysql\bin",
|
||||
]
|
||||
:
|
||||
[
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/opt/mariadb/bin",
|
||||
"/opt/homebrew/bin",
|
||||
];
|
||||
|
||||
// PATH-Suche
|
||||
foreach (var name in names)
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
var full = Path.Combine(dir, name);
|
||||
if (File.Exists(full)) return full;
|
||||
}
|
||||
|
||||
// PATH-Suche. Das Trennzeichen ist plattformabhängig: ';' unter Windows, ':' unter Linux –
|
||||
// ein fest verdrahtetes ';' hätte auf Linux den gesamten PATH als einen Eintrag gelesen.
|
||||
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in pathVar.Split(';'))
|
||||
{
|
||||
var full = Path.Combine(dir.Trim(), "mysqldump.exe");
|
||||
if (File.Exists(full)) return full;
|
||||
}
|
||||
foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
foreach (var name in names)
|
||||
{
|
||||
var full = Path.Combine(dir.Trim(), name);
|
||||
if (File.Exists(full)) return full;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -123,7 +167,7 @@ public class BackupWorker : WorkerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var srcLogs = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
|
||||
var srcLogs = AppPaths.Logs;
|
||||
if (!Directory.Exists(srcLogs)) return;
|
||||
|
||||
var destDir = Path.Combine(logsBackupDir, timestamp);
|
||||
|
||||
@@ -16,7 +16,7 @@ public interface IWorker
|
||||
/// <summary>Worker = periodisch / Service = dauerhaft.</summary>
|
||||
WorkerType Type { get; }
|
||||
|
||||
/// <summary>Live-Daten für die DataGridView-Zeile.</summary>
|
||||
/// <summary>Live-Daten für die Zeile in der Workers-Ansicht.</summary>
|
||||
WorkerInfo Info { get; }
|
||||
|
||||
/// <summary>Löst einen sofortigen, manuellen Run aus (unabhängig vom Zeitplan).</summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Persistence.Ef;
|
||||
using IBKRTrader.Core.Persistence.Entities;
|
||||
@@ -111,11 +112,12 @@ public abstract class WorkerBase : IWorker, IHostedService
|
||||
|
||||
if (Interval == null) break; // Service: nur einmal
|
||||
|
||||
var next = DateTime.Now.Add(Interval.Value);
|
||||
// Anzeige-/Zeitplanwerte in der Betriebszeitzone, damit die UI dasselbe zeigt wie die Logs.
|
||||
var next = AppTimeZone.Now.Add(Interval.Value);
|
||||
Info.NextRuntime = next;
|
||||
|
||||
// Warte auf Interval ODER manuellen Trigger
|
||||
var remaining = next - DateTime.Now;
|
||||
var remaining = next - AppTimeZone.Now;
|
||||
if (remaining > TimeSpan.Zero)
|
||||
{
|
||||
try
|
||||
@@ -143,7 +145,7 @@ public abstract class WorkerBase : IWorker, IHostedService
|
||||
await ExecuteAsync(ct);
|
||||
await EndRunLogAsync(logId, true);
|
||||
|
||||
Info.LastRuntime = DateTime.Now;
|
||||
Info.LastRuntime = AppTimeZone.Now;
|
||||
Info.Status = WorkerStatus.Idle;
|
||||
Info.Info = $"OK – {Info.LastRuntime:HH:mm:ss}";
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ public class WorkerEngine
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IWorker> _registry = new();
|
||||
|
||||
/// <summary>Live-bindbare Liste für die Workers-Ansicht (DataGridView).</summary>
|
||||
/// <summary>
|
||||
/// Live-bindbare Liste für die Workers-Ansicht. Die Liste selbst ändert sich zur Laufzeit
|
||||
/// nicht – die Worker werden hier einmal registriert; was sich ändert, meldet
|
||||
/// <see cref="WorkerInfo"/> über INotifyPropertyChanged.
|
||||
/// </summary>
|
||||
public BindingList<WorkerInfo> WorkerInfos { get; } = [];
|
||||
|
||||
public WorkerEngine(IEnumerable<IWorker> workers, LoggingService logger)
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Runtime.CompilerServices;
|
||||
namespace IBKRTrader.Core.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel-Objekt für eine Zeile in dgv_workerlist.
|
||||
/// Implementiert INotifyPropertyChanged für automatisches DataGridView-Binding.
|
||||
/// ViewModel-Objekt für eine Zeile der Workers-Ansicht.
|
||||
/// Implementiert INotifyPropertyChanged, damit die Oberfläche Änderungen ohne Zutun übernimmt.
|
||||
/// </summary>
|
||||
public class WorkerInfo : INotifyPropertyChanged
|
||||
{
|
||||
@@ -32,8 +32,7 @@ public class WorkerInfo : INotifyPropertyChanged
|
||||
public string RunEvery { get => _runEvery; set => Set(ref _runEvery, value); }
|
||||
public string Info { get => _info; set => Set(ref _info, value); }
|
||||
|
||||
/// <summary>Interner Status – wird nicht direkt als DGV-Spalte verwendet,
|
||||
/// aber steuert die Info-Spalte.</summary>
|
||||
/// <summary>Interner Status – keine eigene Spalte, steuert aber den Info-Text.</summary>
|
||||
public WorkerStatus Status
|
||||
{
|
||||
get => _status;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using IBKRTrader.Core.Settings;
|
||||
|
||||
namespace IBKRTrader.Daemon;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnose: öffnet die Datenbank aus <c>settings.json</c> und gibt die Serverversion aus.
|
||||
/// Gebraucht für das EF-<c>ServerVersion</c>-Pinning; läuft ohne Host und ohne Oberfläche.
|
||||
/// </summary>
|
||||
internal static class DbVersion
|
||||
{
|
||||
public static int Print()
|
||||
{
|
||||
var settings = new SettingsService();
|
||||
settings.Load();
|
||||
|
||||
try
|
||||
{
|
||||
using var c = new MySqlConnector.MySqlConnection(settings.Settings.Database.BuildConnectionString());
|
||||
c.Open();
|
||||
Console.WriteLine($"ServerVersion: {c.ServerVersion}");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"FEHLER: {ex.GetType().Name}: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Kopfloser Betrieb: Trading-Kern, Worker, Accounting, Supervisor, REST und MCP ohne
|
||||
Oberfläche. Das ist der Einstiegspunkt für den Linux-Dienst (systemd). -->
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>IBKRTrader.Daemon</AssemblyName>
|
||||
<RootNamespace>IBKRTrader.Daemon</RootNamespace>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
<!-- BEWUSST NICHT InvariantGlobalization: das Image braucht ICU (libicu / icu-data-full).
|
||||
Zwei Dinge hängen daran und würden sonst lautlos falsch werden:
|
||||
1. AppTimeZone löst Windows-Zeitzonen-IDs über TimeZoneInfo.TryConvertWindowsIdToIanaId
|
||||
auf. Ohne ICU schlägt das fehl – eine Instanz mit einer Altkonfiguration
|
||||
("W. Europe Standard Time") würde auf die Systemzeitzone zurückfallen, also je nach
|
||||
Container auf UTC. Genau die Verschiebung, die wir in L1b beseitigt haben.
|
||||
2. Der PDF-Export formatiert Beträge fest gegen de-DE. Ohne ICU liefert
|
||||
CultureInfo.GetCultureInfo("de-DE") die invariante Kultur, aus "1.234,56" würde
|
||||
wieder "1,234.56" – in einem Dokument, das als prüfbare Aufstellung gilt. -->
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\IBKRTrader.Hosting\IBKRTrader.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,79 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace IBKRTrader.Daemon;
|
||||
|
||||
/// <summary>
|
||||
/// Kopfloser Einstiegspunkt: Trading-Kern, Worker, Accounting, Supervisor, REST-API und MCP-Light
|
||||
/// laufen ohne Oberfläche. Das ist die Betriebsform auf Linux (systemd).
|
||||
///
|
||||
/// <para>Der Host kommt unverändert aus <see cref="AppHostBuilder"/> – dieselbe Zusammenstellung,
|
||||
/// die auch die Desktop-Shell startet. Hier fehlt lediglich alles, was ein Fenster braucht.</para>
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
// Diagnose: Serverversion ausgeben (für das EF-ServerVersion-Pinning). Kein Host nötig.
|
||||
if (HasFlag(args, "--db-version"))
|
||||
return DbVersion.Print();
|
||||
|
||||
// Trockenlauf: Host bauen, Startprüfungen fahren, Dienste NICHT starten. Für Deployment
|
||||
// und CI – prüft Konfiguration, DB-Verbindungszeichenfolge, Master-Key und Ablageorte,
|
||||
// ohne eine einzige Verbindung zur Börse aufzubauen.
|
||||
var checkOnly = HasFlag(args, "--check");
|
||||
|
||||
var modules = AppHostBuilder.CreateModules();
|
||||
using var host = AppHostBuilder.Build(modules);
|
||||
|
||||
var logger = host.Services.GetRequiredService<LoggingService>();
|
||||
AppHostBuilder.RunStartupChecks(host.Services);
|
||||
|
||||
if (checkOnly)
|
||||
{
|
||||
logger.Info("Core", "Prüflauf (--check) erfolgreich: Host baubar, Startprüfungen bestanden.");
|
||||
Console.WriteLine("=== Prüflauf OK ===");
|
||||
return 0;
|
||||
}
|
||||
|
||||
logger.Info("Core", "=== IBKRTrader (kopflos) startet ===");
|
||||
logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version} | {RuntimeDescription()}");
|
||||
|
||||
await host.StartAsync().ConfigureAwait(false);
|
||||
|
||||
// Module nach dem Host starten – so laufen sie nie gegen einen leeren Zustand an.
|
||||
foreach (var module in modules)
|
||||
{
|
||||
try { await module.StartAsync(default).ConfigureAwait(false); }
|
||||
catch (Exception ex) { logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
|
||||
}
|
||||
|
||||
logger.Info("Core", "IBKRTrader bereit. Beenden mit Strg+C bzw. SIGTERM.");
|
||||
|
||||
// WaitForShutdown behandelt SIGTERM und SIGINT – genau das, was systemd beim Stoppen sendet.
|
||||
await host.WaitForShutdownAsync().ConfigureAwait(false);
|
||||
|
||||
await StopModulesAsync(modules, logger).ConfigureAwait(false);
|
||||
logger.Info("Core", "IBKRTrader beendet.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task StopModulesAsync(IReadOnlyList<IModule> modules, LoggingService logger)
|
||||
{
|
||||
foreach (var module in modules)
|
||||
{
|
||||
try { await module.StopAsync(default).ConfigureAwait(false); }
|
||||
catch (Exception ex) { logger.Warn(module.Name, $"{module.Name}: Stopp fehlgeschlagen: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasFlag(string[] args, string flag) =>
|
||||
args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static string RuntimeDescription() =>
|
||||
$"{System.Runtime.InteropServices.RuntimeInformation.OSDescription.Trim()} " +
|
||||
$"({System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture})";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Database": {
|
||||
"MySqlConnectionString": ""
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using IBKRTrader.Core.AI;
|
||||
using IBKRTrader.Core.Budget;
|
||||
using IBKRTrader.Core.Configuration;
|
||||
using IBKRTrader.Core.DependencyInjection;
|
||||
using IBKRTrader.Core.IBKR;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Persistence;
|
||||
using IBKRTrader.Core.Persistence.Ef;
|
||||
using IBKRTrader.Core.Security;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using IBKRTrader.Core.Trading.Ibkr;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.Core.Workers.BuiltIn;
|
||||
using IBKRTrader.Modules.Accounting;
|
||||
using IBKRTrader.Modules.CongressTrading;
|
||||
using IBKRTrader.Modules.Supervisor;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace IBKRTrader.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Baut den Anwendungs-Host: Konfiguration, Persistenz, Core-Dienste, Worker und Module –
|
||||
/// <b>ohne jeden Bezug zur Oberfläche</b>.
|
||||
///
|
||||
/// <para>Bewusst ein eigenes Projekt: derselbe Host trägt den kopflosen Linux-Dienst
|
||||
/// (<c>IBKRTrader.Daemon</c>) und die Desktop-Shell. Läge die Zusammenstellung wie bisher in der
|
||||
/// WinForms-<c>Program.cs</c>, müsste sie für den Daemon dupliziert werden – und beide würden mit
|
||||
/// der Zeit auseinanderlaufen. Ein Modul, das nur in einer Variante registriert ist, wäre ein
|
||||
/// Fehler, den man erst im Betrieb bemerkt.</para>
|
||||
/// </summary>
|
||||
public static class AppHostBuilder
|
||||
{
|
||||
/// <summary>Die in dieser Ausbaustufe aktiven Module.</summary>
|
||||
public static IReadOnlyList<IModule> CreateModules() =>
|
||||
[
|
||||
new CongressTradingModule(),
|
||||
new AccountingModule(),
|
||||
new SupervisorModule()
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Stellt den Host zusammen. <paramref name="configureExtra"/> erlaubt der jeweiligen Shell,
|
||||
/// ihre eigenen Dienste (Fensterverwaltung, Launcher) zu ergänzen – der Daemon übergibt nichts.
|
||||
/// </summary>
|
||||
public static IHost Build(IReadOnlyList<IModule> modules,
|
||||
Action<IServiceCollection, IConfiguration>? configureExtra = null) =>
|
||||
Host.CreateDefaultBuilder()
|
||||
.UseContentRoot(AppContext.BaseDirectory)
|
||||
// Meldet systemd die Betriebsbereitschaft (sd_notify) und behandelt SIGTERM sauber.
|
||||
// Ohne das würde eine Unit mit Type=notify beim Start bis zum Timeout hängen.
|
||||
// Läuft der Prozess nicht unter systemd, ist der Aufruf wirkungslos – die
|
||||
// Windows-Shell nutzt denselben Host-Aufbau und merkt davon nichts.
|
||||
.UseSystemd()
|
||||
.ConfigureAppConfiguration((_, config) =>
|
||||
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
RegisterCoreServices(services, context.Configuration);
|
||||
foreach (var module in modules)
|
||||
{
|
||||
services.AddSingleton(module);
|
||||
module.RegisterServices(services, context.Configuration);
|
||||
}
|
||||
configureExtra?.Invoke(services, context.Configuration);
|
||||
})
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Die Startschritte, die vor dem Anlaufen der Dienste passieren müssen – in dieser Reihenfolge.
|
||||
/// Gemeinsam für Daemon und Shell, damit keine Variante einen davon vergisst.
|
||||
/// </summary>
|
||||
public static void RunStartupChecks(IServiceProvider services)
|
||||
{
|
||||
var logger = services.GetRequiredService<LoggingService>();
|
||||
|
||||
// 1. Betriebszeitzone VOR dem ersten Logeintrag – sie bestimmt die Tagesgrenzen der Logdateien.
|
||||
ConfigureAppTimeZone(services, logger);
|
||||
|
||||
// 2. Ablageorte melden. Auf Linux können sie je nach Installation vom Binärverzeichnis
|
||||
// abweichen (FHS) – im Betrieb muss sichtbar sein, wohin geschrieben wird.
|
||||
logger.Info("Core", $"Ablage: {AppPaths.Describe()}");
|
||||
|
||||
// 3. Master-Key VOR jeder Entschlüsselung.
|
||||
ConfigureSecretProtection(logger);
|
||||
WarnIfKeyFileIsWorldReadable(logger);
|
||||
|
||||
// 4. Transportverschlüsselung der DB prüfen.
|
||||
WarnIfDbTlsNotEnforced(services, logger);
|
||||
|
||||
// 5. Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine (vor dem Start).
|
||||
services.GetRequiredService<WebApiService>()
|
||||
.SetEngine(services.GetRequiredService<WorkerEngine>());
|
||||
}
|
||||
|
||||
/// <summary>Registriert alle Core-Services im DI-Container.</summary>
|
||||
private static void RegisterCoreServices(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// EF-Core-Persistenz (Connection aus appsettings.Local.json).
|
||||
services.AddCorePersistence(new DatabaseOptions
|
||||
{
|
||||
MySqlConnectionString = configuration["Database:MySqlConnectionString"] ?? string.Empty
|
||||
});
|
||||
|
||||
// Settings zuerst laden (eine Quelle, als Singleton weitergereicht).
|
||||
var settingsService = new SettingsService();
|
||||
settingsService.Load();
|
||||
services.AddSingleton(settingsService);
|
||||
|
||||
services.AddSingleton<LoggingService>();
|
||||
services.AddSingleton<CoreSettingsService>(); // core_settings via EF
|
||||
|
||||
services.AddSingleton<IBKRGatewayService>();
|
||||
services.AddSingleton<IBKRMarketDataRepository>();
|
||||
|
||||
services.AddSingleton<BudgetService>();
|
||||
services.AddSingleton<TradeHistoryService>();
|
||||
services.AddSingleton<AIModelService>();
|
||||
|
||||
// Datenfundament für Analyse/Forensik (Supervisor): Entscheidungsjournal + Order-Events.
|
||||
services.AddSingleton<IDecisionJournal, EfDecisionJournal>();
|
||||
services.AddSingleton<IOrderEventLog, EfOrderEventLog>();
|
||||
|
||||
// Trading-Kern
|
||||
services.AddSingleton<DashboardService>();
|
||||
services.AddSingleton<IRiskService, RiskService>();
|
||||
services.AddSingleton<IPortfolioService, PortfolioService>();
|
||||
services.AddSingleton<IExecutionService, ExecutionService>();
|
||||
|
||||
// Echter TWS-Broker nur, wenn ausdrücklich aktiviert – sonst der NullBroker, der nie handelt.
|
||||
// Beide Rollen (Handel + lesender Bestandsabgleich) bedient dieselbe Instanz.
|
||||
if (settingsService.Settings.IBKR.UseTwsApi)
|
||||
{
|
||||
services.AddSingleton<IbkrBrokerClient>();
|
||||
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<NullBrokerClient>();
|
||||
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||
}
|
||||
|
||||
// Core-Worker/Services
|
||||
services.AddSingleton<BackupWorker>();
|
||||
services.AddSingleton<WebserverService>();
|
||||
services.AddSingleton<WebApiService>();
|
||||
services.AddSingleton<IBKRInstrumentSyncWorker>();
|
||||
services.AddSingleton<IBKRPriceHistoryWorker>();
|
||||
|
||||
// Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable<IWorker>.
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebserverService>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
|
||||
|
||||
// Lebenszyklus über den Generic Host (jeder Worker ist ein IHostedService).
|
||||
services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<WebserverService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<WebApiService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
|
||||
|
||||
services.AddSingleton<WorkerEngine>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt die Betriebszeitzone dieser Instanz aus den Einstellungen. Sie gilt für Anzeige,
|
||||
/// Logdatei-Tagesgrenzen, Berichtszeiten und Buchungsperioden; gespeichert wird weiterhin UTC.
|
||||
/// Wir betreiben Instanzen in EU und US – ohne diese Festlegung hinge die Ortszeit an der
|
||||
/// Zeitzone des Rechners, auf einem UTC-Container also woanders als auf dem Desktop.
|
||||
/// </summary>
|
||||
private static void ConfigureAppTimeZone(IServiceProvider services, LoggingService logger)
|
||||
{
|
||||
var configured = services.GetRequiredService<SettingsService>().Settings.Trading.ApplicationTimeZoneId;
|
||||
AppTimeZone.Configure(configured, msg => logger.Warn("Core", msg));
|
||||
logger.Info("Core", $"Betriebszeitzone: {AppTimeZone.CurrentId} (Persistenz bleibt UTC).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lädt den Master-Key (env IBKRTRADER_MASTER_KEY, sonst gitignorierte master.key) und aktiviert
|
||||
/// die at-rest-Verschlüsselung. Ohne Key läuft die Anwendung mit Klartext – mit deutlicher Warnung.
|
||||
/// </summary>
|
||||
private static void ConfigureSecretProtection(LoggingService logger)
|
||||
{
|
||||
var masterKey = Environment.GetEnvironmentVariable("IBKRTRADER_MASTER_KEY");
|
||||
if (string.IsNullOrWhiteSpace(masterKey))
|
||||
{
|
||||
var keyFile = AppPaths.ConfigFile("master.key");
|
||||
if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim();
|
||||
}
|
||||
SecretProtection.Configure(masterKey);
|
||||
|
||||
if (SecretProtection.IsConfigured)
|
||||
logger.Info("Core", "🔐 Secret-Verschlüsselung aktiv – sensible Daten werden at-rest verschlüsselt (AES-256-GCM).");
|
||||
else
|
||||
logger.Warn("Core", "⚠️ SICHERHEIT: Kein IBKRTRADER_MASTER_KEY gesetzt – sensible Daten würden UNVERSCHLÜSSELT gespeichert. " +
|
||||
"Master-Key setzen (env IBKRTRADER_MASTER_KEY oder Datei master.key).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warnt, wenn Schlüsseldateien für andere Nutzer lesbar sind.
|
||||
///
|
||||
/// <para>Nur auf Unix sinnvoll: Windows-ACLs übertragen sich nicht beim Kopieren einer Datei
|
||||
/// auf einen Linux-Host, und eine <c>master.key</c> mit Standardrechten (644) ist dort für
|
||||
/// jeden lokalen Nutzer lesbar. Nur eine Warnung – die Rechte gehören dem Betreiber.</para>
|
||||
/// </summary>
|
||||
private static void WarnIfKeyFileIsWorldReadable(LoggingService logger)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
|
||||
foreach (var name in new[] { "master.key", "openrouter.key" })
|
||||
{
|
||||
var path = AppPaths.ConfigFile(name);
|
||||
if (!File.Exists(path)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
const UnixFileMode others = UnixFileMode.GroupRead | UnixFileMode.GroupWrite
|
||||
| UnixFileMode.OtherRead | UnixFileMode.OtherWrite;
|
||||
if ((mode & others) != 0)
|
||||
logger.Warn("Core", $"⚠️ SICHERHEIT: {name} ist auch für Gruppe/andere zugänglich " +
|
||||
$"({mode}). Empfohlen: chmod 600 \"{path}\".");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warn("Core", $"Rechte von {name} nicht prüfbar: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Warnt, wenn der DB-Connection-String keine TLS-Option (SslMode) enthält. Der String wird NICHT geloggt.</summary>
|
||||
private static void WarnIfDbTlsNotEnforced(IServiceProvider services, LoggingService logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var conn = services.GetService<IConfiguration>()?["Database:MySqlConnectionString"] ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(conn)) return;
|
||||
if (conn.IndexOf("sslmode", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
logger.Warn("Core", "⚠️ SICHERHEIT: DB-Verbindung ohne SslMode – Transportverschlüsselung nicht erzwungen. " +
|
||||
"Im Connection-String 'SslMode=Required' setzen.");
|
||||
}
|
||||
catch { /* best-effort, darf den Start nie stören */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Plattformneutral: enthält den kompletten Anwendungs-Host OHNE Oberfläche. Wird sowohl vom
|
||||
kopflosen Linux-Daemon als auch von den Desktop-Shells verwendet, damit beide dieselbe
|
||||
Zusammenstellung starten und nicht auseinanderlaufen. -->
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
||||
<!-- systemd-Integration: sd_notify (Type=notify) und journald-taugliches Logformat.
|
||||
Ausserhalb von systemd ein No-Op, stoert die Windows-Shell also nicht. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\IBKRTrader.Core\IBKRTrader.Core.csproj" />
|
||||
<ProjectReference Include="..\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
|
||||
<ProjectReference Include="..\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
|
||||
<ProjectReference Include="..\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>IBKRTrader.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -4,7 +4,6 @@ using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Modules.Accounting.Persistence;
|
||||
using IBKRTrader.Modules.Accounting.Services;
|
||||
using IBKRTrader.Modules.Accounting.Ui;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -47,22 +46,12 @@ public sealed class AccountingModule : IModule
|
||||
services.AddHostedService(sp => sp.GetRequiredService<AccountingIngestService>());
|
||||
}
|
||||
|
||||
public void RegisterUi(IModuleUiHost host, IServiceProvider services)
|
||||
{
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "accounting.main",
|
||||
Title = "Accounting",
|
||||
Group = Name,
|
||||
Order = 400,
|
||||
CreateForm = () => new AccountingMainForm(
|
||||
services.GetRequiredService<ILedgerRepository>(),
|
||||
services.GetRequiredService<IIngestRunRepository>(),
|
||||
services.GetRequiredService<AccountingReportService>(),
|
||||
services.GetRequiredService<AccountingIngestService>(),
|
||||
services.GetRequiredService<LoggingService>())
|
||||
});
|
||||
}
|
||||
/// <summary>
|
||||
/// Bewusst leer: das Modulprojekt trägt keinen UI-Code mehr, damit es plattformneutral bleibt
|
||||
/// (kopfloser Linux-Betrieb). Das Accounting-Fenster registriert die Shell zentral in
|
||||
/// <c>UI/ModuleViews.cs</c>; die Dienste dafür kommen aus dem DI-Container.
|
||||
/// </summary>
|
||||
public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
|
||||
|
||||
// DB-Schema wird extern per `dotnet ef database update` angewendet (keine Laufzeit-Migration).
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<!-- Plattformneutral: das Modul traegt keinen UI-Code mehr (Fenster liegt in der Shell,
|
||||
siehe UI/ModuleViews.cs) und laeuft damit auch im kopflosen Linux-Betrieb. -->
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Modul trägt eigene WinForms-UI (Modul-Fenster) bei. -->
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections.Concurrent;
|
||||
using PdfSharp.Fonts;
|
||||
|
||||
namespace IBKRTrader.Modules.Accounting.Logic;
|
||||
|
||||
/// <summary>
|
||||
/// Schriftauflösung für den PDF-Export.
|
||||
///
|
||||
/// <para><b>Warum das nötig ist:</b> Der Export gab MigraDoc bisher „Segoe UI" vor – eine
|
||||
/// Windows-Schrift. PDFsharp 6 löst Schriften auf Nicht-Windows-Plattformen nicht von selbst auf;
|
||||
/// ohne einen eigenen <see cref="IFontResolver"/> scheitert der Export auf Linux zur Laufzeit.</para>
|
||||
///
|
||||
/// <para><b>Vorgehen:</b> Unter Windows übernimmt weiterhin die Plattform (unveränderte Optik) –
|
||||
/// wir geben <c>null</c> zurück und setzen <see cref="GlobalFontSettings.UseWindowsFontsUnderWindows"/>.
|
||||
/// Auf Linux wird eine der üblichen freien Schriften aus dem System genommen. Bewusst <b>keine</b>
|
||||
/// Schrift im Repository: das erspart uns eine Lizenzfrage und hält das Paket klein – die
|
||||
/// Abhängigkeit ist dafür ausdrücklich dokumentiert und die Fehlermeldung nennt das Paket.</para>
|
||||
/// </summary>
|
||||
public sealed class DocumentFontResolver : IFontResolver
|
||||
{
|
||||
/// <summary>Logischer Familienname, den der Export verwendet.</summary>
|
||||
public const string FamilyName = "IBKRTrader Sans";
|
||||
|
||||
/// <summary>Windows-Entsprechung – hält die Optik der bisherigen Exporte.</summary>
|
||||
private const string WindowsFamily = "Segoe UI";
|
||||
|
||||
/// <summary>
|
||||
/// Freie Sans-Serif-Schriften in Vorzugsreihenfolge, je (regular, bold). DejaVu ist auf
|
||||
/// praktisch jeder Distribution verfügbar, Liberation und Noto sind die üblichen Alternativen.
|
||||
/// </summary>
|
||||
private static readonly (string Regular, string Bold)[] Candidates =
|
||||
[
|
||||
("DejaVuSans.ttf", "DejaVuSans-Bold.ttf"),
|
||||
("LiberationSans-Regular.ttf", "LiberationSans-Bold.ttf"),
|
||||
("NotoSans-Regular.ttf", "NotoSans-Bold.ttf"),
|
||||
("FreeSans.ttf", "FreeSansBold.ttf"),
|
||||
];
|
||||
|
||||
private static readonly string[] SearchDirs =
|
||||
[
|
||||
"/usr/share/fonts",
|
||||
"/usr/local/share/fonts",
|
||||
"/run/host/usr/share/fonts", // Flatpak/Toolbox
|
||||
];
|
||||
|
||||
private static readonly ConcurrentDictionary<string, byte[]> Cache = new();
|
||||
private static readonly Lazy<(string Regular, string Bold)?> Found = new(Locate);
|
||||
|
||||
/// <summary>
|
||||
/// Richtet die Schriftauflösung einmalig ein. Mehrfachaufrufe sind unschädlich –
|
||||
/// PDFsharp lässt den Resolver nur einmal setzen.
|
||||
/// </summary>
|
||||
public static void EnsureConfigured()
|
||||
{
|
||||
if (GlobalFontSettings.FontResolver is not null) return;
|
||||
|
||||
GlobalFontSettings.UseWindowsFontsUnderWindows = true;
|
||||
GlobalFontSettings.FontResolver = new DocumentFontResolver();
|
||||
}
|
||||
|
||||
public FontResolverInfo? ResolveTypeface(string familyName, bool bold, bool italic)
|
||||
{
|
||||
// Unter Windows die Plattform machen lassen: null bedeutet „nicht zuständig".
|
||||
if (OperatingSystem.IsWindows()) return null;
|
||||
|
||||
if (Found.Value is not { } files)
|
||||
throw new InvalidOperationException(
|
||||
"Für den PDF-Export wurde keine Schriftart gefunden. Auf dem System muss eine freie " +
|
||||
"Sans-Serif-Schrift installiert sein – unter Debian/Ubuntu z. B. per " +
|
||||
"\"apt-get install fonts-dejavu-core\", unter Alpine \"apk add font-dejavu\". " +
|
||||
$"Gesucht wurde in: {string.Join(", ", SearchDirs)}.");
|
||||
|
||||
// Kursiv wird von PDFsharp simuliert; wir liefern nur normal und fett.
|
||||
var path = bold ? files.Bold : files.Regular;
|
||||
return new FontResolverInfo(path, mustSimulateBold: false, mustSimulateItalic: italic);
|
||||
}
|
||||
|
||||
public byte[]? GetFont(string faceName) =>
|
||||
Cache.GetOrAdd(faceName, File.ReadAllBytes);
|
||||
|
||||
/// <summary>Sucht das erste vollständige Paar (regular + bold) in den üblichen Verzeichnissen.</summary>
|
||||
private static (string Regular, string Bold)? Locate()
|
||||
{
|
||||
var existing = SearchDirs.Where(Directory.Exists).ToArray();
|
||||
if (existing.Length == 0) return null;
|
||||
|
||||
foreach (var (regular, bold) in Candidates)
|
||||
{
|
||||
var regularPath = FindFile(existing, regular);
|
||||
var boldPath = FindFile(existing, bold);
|
||||
|
||||
// Ohne Fettschnitt bleibt der Bericht lesbar – dann eben beides normal.
|
||||
if (regularPath is not null) return (regularPath, boldPath ?? regularPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindFile(string[] dirs, string fileName)
|
||||
{
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hit = Directory.EnumerateFiles(dir, fileName, SearchOption.AllDirectories).FirstOrDefault();
|
||||
if (hit is not null) return hit;
|
||||
}
|
||||
catch (UnauthorizedAccessException) { /* Verzeichnis nicht lesbar – nächstes */ }
|
||||
catch (DirectoryNotFoundException) { /* zwischenzeitlich weg – nächstes */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Der Familienname, den das Dokument setzen soll.</summary>
|
||||
public static string DocumentFamily => OperatingSystem.IsWindows() ? WindowsFamily : FamilyName;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
using IBKRTrader.Core.Time;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using IBKRTrader.Modules.Accounting.Models;
|
||||
@@ -15,6 +17,17 @@ namespace IBKRTrader.Modules.Accounting.Logic;
|
||||
/// </summary>
|
||||
public static class PdfExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Zahlenformat des Dokuments – <b>fest deutsch</b>, nicht die Kultur des Hosts.
|
||||
///
|
||||
/// <para>Vorher stand hier <c>ToString("N2")</c> ohne Formatanbieter, also CurrentCulture. Auf
|
||||
/// dem deutschen Windows-Desktop kam "1.234,56" heraus; in einem Linux-Container mit
|
||||
/// <c>LANG=C</c> oder <c>InvariantGlobalization</c> wäre daraus "1,234.56" geworden – dieselbe
|
||||
/// Zahl, für einen Leser aber eine andere. Für ein Dokument, das ausdrücklich als prüfbare
|
||||
/// Aufstellung gedacht ist, darf das Format nicht vom Rechner abhängen, auf dem es entsteht.</para>
|
||||
/// </summary>
|
||||
private static readonly CultureInfo DocumentCulture = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
public static byte[] Render(
|
||||
PeriodStatement statement,
|
||||
IReadOnlyList<PeriodStatement> monthly,
|
||||
@@ -22,12 +35,16 @@ public static class PdfExporter
|
||||
string currencyCode, decimal currencyFactor, string currencyNote)
|
||||
{
|
||||
decimal V(decimal baseAmount) => Math.Round(baseAmount * currencyFactor, 2, MidpointRounding.AwayFromZero);
|
||||
string M(decimal baseAmount) => V(baseAmount).ToString("N2") + " " + currencyCode;
|
||||
string M(decimal baseAmount) => V(baseAmount).ToString("N2", DocumentCulture) + " " + currencyCode;
|
||||
|
||||
// Schriftauflösung: auf Linux gibt es kein "Segoe UI", und PDFsharp löst dort nichts von
|
||||
// selbst auf – ohne das scheitert der Export zur Laufzeit. Siehe DocumentFontResolver.
|
||||
DocumentFontResolver.EnsureConfigured();
|
||||
|
||||
var doc = new Document();
|
||||
doc.Info.Title = "Buchhalterische Abrechnung";
|
||||
var style = doc.Styles["Normal"]!;
|
||||
style.Font.Name = "Segoe UI";
|
||||
style.Font.Name = DocumentFontResolver.DocumentFamily;
|
||||
style.Font.Size = 9;
|
||||
|
||||
var section = doc.AddSection();
|
||||
@@ -48,7 +65,7 @@ public static class PdfExporter
|
||||
meta.AddLineBreak();
|
||||
meta.AddText($"Währung: {currencyCode} ({currencyNote})");
|
||||
meta.AddLineBreak();
|
||||
meta.AddText($"Erstellt: {DateTime.Now:yyyy-MM-dd HH:mm}");
|
||||
meta.AddText($"Erstellt: {AppTimeZone.Now:yyyy-MM-dd HH:mm}");
|
||||
|
||||
// ---- Aggregat ----
|
||||
AddSectionTitle(section, "Zusammenfassung");
|
||||
@@ -63,8 +80,8 @@ public static class PdfExporter
|
||||
AddKeyValue(agg, "Quellensteuer", M(statement.TaxWithheld));
|
||||
AddKeyValue(agg, "Netto-Handelsergebnis (Cash-Basis)", M(statement.NetTradingResult));
|
||||
AddKeyValue(agg, "Endsaldo", M(statement.ClosingBalance));
|
||||
AddKeyValue(agg, "Anzahl Trades", statement.TradeCount.ToString());
|
||||
AddKeyValue(agg, "Anzahl Buchungen", statement.EntryCount.ToString());
|
||||
AddKeyValue(agg, "Anzahl Trades", statement.TradeCount.ToString(DocumentCulture));
|
||||
AddKeyValue(agg, "Anzahl Buchungen", statement.EntryCount.ToString(DocumentCulture));
|
||||
|
||||
// ---- Monatsvergleich ----
|
||||
if (monthly.Count > 1)
|
||||
@@ -83,7 +100,7 @@ public static class PdfExporter
|
||||
HeaderRow(lt, "Zeit (UTC)", "Typ", "Side", "Symbol", "Menge", "Preis", "Netto", "Transaktion");
|
||||
foreach (var e in periodEntries.OrderBy(e => e.Timestamp))
|
||||
DataRow(lt, e.Timestamp.ToString("yyyy-MM-dd HH:mm"), e.EventType.ToString(), e.Side,
|
||||
Trim(e.Symbol, 12), e.Quantity.ToString("0.###"), e.PriceNative.ToString("0.###"),
|
||||
Trim(e.Symbol, 12), e.Quantity.ToString("0.###", DocumentCulture), e.PriceNative.ToString("0.###", DocumentCulture),
|
||||
M(e.NetBase), Trim(e.TransactionId, 22));
|
||||
|
||||
// ---- Methodik / Nachweis ----
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Modules.Accounting.Logic;
|
||||
using IBKRTrader.Modules.Accounting.Persistence;
|
||||
using IBKRTrader.Modules.Accounting.Services;
|
||||
|
||||
namespace IBKRTrader.Modules.Accounting.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Fenster des Accounting-Moduls: Übersicht/BWA, Ledger, Steuer (Platzhalter), Abrechnung/Export,
|
||||
/// Abruf/Status. Alle DB-Zugriffe laufen NUR auf Nutzer-Interaktion (nicht im Konstruktor) – so
|
||||
/// konstruiert der Smoke-UI-Check das Fenster auch ohne DB fehlerfrei.
|
||||
/// </summary>
|
||||
public sealed class AccountingMainForm : Form
|
||||
{
|
||||
private readonly ILedgerRepository _ledger;
|
||||
private readonly IIngestRunRepository _runs;
|
||||
private readonly AccountingReportService _report;
|
||||
private readonly AccountingIngestService _ingest;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
private readonly DateTimePicker _from = new() { Format = DateTimePickerFormat.Short, Width = 110 };
|
||||
private readonly DateTimePicker _to = new() { Format = DateTimePickerFormat.Short, Width = 110 };
|
||||
private readonly ComboBox _account = new() { DropDownStyle = ComboBoxStyle.DropDown, Width = 140 };
|
||||
private readonly ComboBox _currency = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 80 };
|
||||
|
||||
private readonly Label _kpis = new() { AutoSize = true, Location = new Point(12, 8) };
|
||||
private readonly DataGridView _monthly = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
|
||||
private readonly DataGridView _ledgerGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
|
||||
private readonly DataGridView _runsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
|
||||
private readonly Label _status = new() { AutoSize = true, ForeColor = SystemColors.GrayText, Location = new Point(12, 8) };
|
||||
|
||||
public AccountingMainForm(
|
||||
ILedgerRepository ledger, IIngestRunRepository runs, AccountingReportService report,
|
||||
AccountingIngestService ingest, LoggingService logger)
|
||||
{
|
||||
_ledger = ledger;
|
||||
_runs = runs;
|
||||
_report = report;
|
||||
_ingest = ingest;
|
||||
_logger = logger;
|
||||
|
||||
Text = "Accounting";
|
||||
Width = 1000;
|
||||
Height = 680;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimumSize = new Size(760, 480);
|
||||
|
||||
_from.Value = DateTime.Today.AddMonths(-1);
|
||||
_to.Value = DateTime.Today;
|
||||
_currency.Items.AddRange(new object[] { "USD", "EUR" });
|
||||
_currency.SelectedIndex = 0;
|
||||
|
||||
BuildLayout();
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
var tabs = new TabControl { Dock = DockStyle.Fill };
|
||||
|
||||
// ── gemeinsame Filterleiste ──
|
||||
var filter = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) };
|
||||
filter.Controls.Add(new Label { Text = "Von", AutoSize = true, Margin = new Padding(0, 8, 4, 0) });
|
||||
filter.Controls.Add(_from);
|
||||
filter.Controls.Add(new Label { Text = "Bis", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
|
||||
filter.Controls.Add(_to);
|
||||
filter.Controls.Add(new Label { Text = "Konto", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
|
||||
filter.Controls.Add(_account);
|
||||
filter.Controls.Add(new Label { Text = "Währung", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
|
||||
filter.Controls.Add(_currency);
|
||||
var btnRefresh = new Button { Text = "Aktualisieren", Width = 120, Margin = new Padding(12, 3, 0, 0) };
|
||||
btnRefresh.Click += (_, _) => RefreshAll();
|
||||
filter.Controls.Add(btnRefresh);
|
||||
|
||||
// ── Tab: Übersicht/BWA ──
|
||||
var tabOverview = new TabPage("Übersicht / BWA");
|
||||
_monthly.Top = 90;
|
||||
var overviewPanel = new Panel { Dock = DockStyle.Fill };
|
||||
overviewPanel.Controls.Add(_monthly);
|
||||
var kpiPanel = new Panel { Dock = DockStyle.Top, Height = 84 };
|
||||
kpiPanel.Controls.Add(_kpis);
|
||||
overviewPanel.Controls.Add(kpiPanel);
|
||||
tabOverview.Controls.Add(overviewPanel);
|
||||
|
||||
// ── Tab: Ledger ──
|
||||
var tabLedger = new TabPage("Ledger");
|
||||
tabLedger.Controls.Add(_ledgerGrid);
|
||||
|
||||
// ── Tab: Steuer (Platzhalter) ──
|
||||
var tabTax = new TabPage("Steuer");
|
||||
tabTax.Controls.Add(new Label
|
||||
{
|
||||
Dock = DockStyle.Fill, Padding = new Padding(16),
|
||||
Text = "Steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt).\n\n" +
|
||||
"Der neutrale Ledger und die Periodenabrechnung sind davon unabhängig gültig.\n" +
|
||||
"Eine konkrete Steuerschicht (z. B. DE-Kapitalertragsteuer oder US Form 8949 / Schedule D)\n" +
|
||||
"wird hier später als klar dokumentierte, prüfbare Rechenschicht ergänzt.\n\n" +
|
||||
"Hinweis: Dies ist keine Steuerberatung."
|
||||
});
|
||||
|
||||
// ── Tab: Abrechnung / Export ──
|
||||
var tabExport = new TabPage("Abrechnung / Export");
|
||||
var exportPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), FlowDirection = FlowDirection.TopDown };
|
||||
exportPanel.Controls.Add(new Label { AutoSize = true, Text = "Exportiert die aktuelle Auswahl (Zeitraum / Konto / Währung):" });
|
||||
var btnCsvLedger = new Button { Text = "Ledger als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
|
||||
btnCsvLedger.Click += (_, _) => ExportCsvLedger();
|
||||
var btnCsvStmt = new Button { Text = "Abrechnung als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
|
||||
btnCsvStmt.Click += (_, _) => ExportCsvStatement();
|
||||
var btnPdf = new Button { Text = "Abrechnung als PDF…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
|
||||
btnPdf.Click += (_, _) => ExportPdf();
|
||||
exportPanel.Controls.Add(btnCsvLedger);
|
||||
exportPanel.Controls.Add(btnCsvStmt);
|
||||
exportPanel.Controls.Add(btnPdf);
|
||||
tabExport.Controls.Add(exportPanel);
|
||||
|
||||
// ── Tab: Abruf / Status ──
|
||||
var tabIngest = new TabPage("Abruf / Status");
|
||||
var ingestButtons = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) };
|
||||
var btnIncr = new Button { Text = "Inkrementell abrufen", Width = 160 };
|
||||
btnIncr.Click += async (_, _) => await RunIngest(backfill: false);
|
||||
var btnBackfill = new Button { Text = "Backfill (voll)", Width = 140, Margin = new Padding(8, 0, 0, 0) };
|
||||
btnBackfill.Click += async (_, _) => await RunIngest(backfill: true);
|
||||
ingestButtons.Controls.Add(btnIncr);
|
||||
ingestButtons.Controls.Add(btnBackfill);
|
||||
var statusPanel = new Panel { Dock = DockStyle.Top, Height = 40 };
|
||||
statusPanel.Controls.Add(_status);
|
||||
_status.Text = "Offline-Standard: keine Live-Quelle registriert → der Ingest bucht nichts (korrekt).";
|
||||
tabIngest.Controls.Add(_runsGrid);
|
||||
tabIngest.Controls.Add(statusPanel);
|
||||
tabIngest.Controls.Add(ingestButtons);
|
||||
|
||||
tabs.TabPages.AddRange(new[] { tabOverview, tabLedger, tabTax, tabExport, tabIngest });
|
||||
|
||||
Controls.Add(tabs);
|
||||
Controls.Add(filter);
|
||||
}
|
||||
|
||||
// ── Daten laden (nur auf Interaktion) ──
|
||||
|
||||
private string? SelectedAccount()
|
||||
{
|
||||
var text = _account.Text?.Trim();
|
||||
return string.IsNullOrWhiteSpace(text) || text == "(alle)" ? null : text;
|
||||
}
|
||||
|
||||
private void RefreshAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
LoadAccounts();
|
||||
LoadOverview();
|
||||
LoadLedger();
|
||||
LoadRuns();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Accounting", $"UI-Refresh fehlgeschlagen: {ex.Message}", ex);
|
||||
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadAccounts()
|
||||
{
|
||||
var current = _account.Text;
|
||||
_account.Items.Clear();
|
||||
_account.Items.Add("(alle)");
|
||||
foreach (var a in _ledger.DistinctAccounts()) _account.Items.Add(a);
|
||||
_account.Text = string.IsNullOrEmpty(current) ? "(alle)" : current;
|
||||
}
|
||||
|
||||
private void LoadOverview()
|
||||
{
|
||||
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
|
||||
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
|
||||
var view = _report.GetCurrencyView(_currency.Text, to);
|
||||
decimal C(decimal v) => Math.Round(v * view.Factor, 2);
|
||||
|
||||
_kpis.Text =
|
||||
$"Netto-Handelsergebnis: {C(stmt.NetTradingResult):N2} {view.Code} " +
|
||||
$"Handelsvolumen: {C(stmt.TradeVolume):N2} Dividenden: {C(stmt.Dividends):N2} Fees: {C(stmt.Fees):N2}\n" +
|
||||
$"Einzahlungen: {C(stmt.Deposits):N2} Auszahlungen: {C(stmt.Withdrawals):N2} " +
|
||||
$"Endsaldo: {C(stmt.ClosingBalance):N2} Trades: {stmt.TradeCount} Buchungen: {stmt.EntryCount}\n" +
|
||||
$"{view.Note}";
|
||||
|
||||
var monthly = _report.BuildMonthly(SelectedAccount(), from, to)
|
||||
.Select(m => new
|
||||
{
|
||||
Monat = m.From.ToString("yyyy-MM"),
|
||||
Anfang = C(m.OpeningBalance),
|
||||
Einzahlungen = C(m.Deposits),
|
||||
Auszahlungen = C(m.Withdrawals),
|
||||
Volumen = C(m.TradeVolume),
|
||||
Fees = C(m.Fees),
|
||||
Ergebnis = C(m.NetTradingResult),
|
||||
Endsaldo = C(m.ClosingBalance)
|
||||
}).ToList();
|
||||
_monthly.DataSource = monthly;
|
||||
}
|
||||
|
||||
private void LoadLedger()
|
||||
{
|
||||
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
|
||||
var rows = _ledger.Query(SelectedAccount(), from, to, 2000)
|
||||
.Select(e => new
|
||||
{
|
||||
Zeit = e.Timestamp, e.AccountId, Typ = e.EventType.ToString(), e.Side, e.Symbol,
|
||||
e.Currency, e.Quantity, Preis = e.PriceNative, Brutto = e.GrossBase, Fee = e.FeeBase,
|
||||
Netto = e.NetBase, e.TransactionId
|
||||
}).ToList();
|
||||
_ledgerGrid.DataSource = rows;
|
||||
}
|
||||
|
||||
private void LoadRuns()
|
||||
{
|
||||
var rows = _runs.GetRecent(SelectedAccount(), 100)
|
||||
.Select(r => new
|
||||
{
|
||||
r.AccountId, Start = r.StartedAt, Ende = r.FinishedAt, r.Backfill,
|
||||
Neu = r.NewEntries, Duplikate = r.DuplicateEntries, r.Success,
|
||||
Anker = r.BalanceAnchorBase, LedgerNetto = r.LedgerNetBase, Delta = r.BalanceDeltaBase, r.Message
|
||||
}).ToList();
|
||||
_runsGrid.DataSource = rows;
|
||||
}
|
||||
|
||||
// ── Export ──
|
||||
|
||||
private void ExportCsvLedger()
|
||||
{
|
||||
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
|
||||
var entries = _ledger.Query(SelectedAccount(), from, to, 100000);
|
||||
SaveText("ledger.csv", "CSV|*.csv", CsvExporter.Ledger(entries));
|
||||
}
|
||||
|
||||
private void ExportCsvStatement()
|
||||
{
|
||||
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
|
||||
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
|
||||
SaveText("abrechnung.csv", "CSV|*.csv", CsvExporter.Statement(stmt));
|
||||
}
|
||||
|
||||
private void ExportPdf()
|
||||
{
|
||||
try
|
||||
{
|
||||
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
|
||||
var account = SelectedAccount();
|
||||
var stmt = _report.BuildStatement(account, from, to);
|
||||
var monthly = _report.BuildMonthly(account, from, to);
|
||||
var entries = _ledger.Query(account, from, to, 100000).OrderBy(e => e.Timestamp).ToList();
|
||||
var view = _report.GetCurrencyView(_currency.Text, to);
|
||||
|
||||
byte[] pdf = PdfExporter.Render(stmt, monthly, entries, view.Code, view.Factor, view.Note);
|
||||
|
||||
using var dlg = new SaveFileDialog { FileName = "abrechnung.pdf", Filter = "PDF|*.pdf" };
|
||||
if (dlg.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
File.WriteAllBytes(dlg.FileName, pdf);
|
||||
_logger.Info("Accounting", $"PDF-Abrechnung geschrieben: {dlg.FileName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Accounting", $"PDF-Export fehlgeschlagen: {ex.Message}", ex);
|
||||
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveText(string suggested, string filter, string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var dlg = new SaveFileDialog { FileName = suggested, Filter = filter };
|
||||
if (dlg.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
File.WriteAllText(dlg.FileName, content);
|
||||
_logger.Info("Accounting", $"Export geschrieben: {dlg.FileName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Accounting", $"Export fehlgeschlagen: {ex.Message}", ex);
|
||||
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ingest ──
|
||||
|
||||
private async Task RunIngest(bool backfill)
|
||||
{
|
||||
try
|
||||
{
|
||||
_status.Text = backfill ? "Backfill läuft…" : "Inkrementeller Abruf läuft…";
|
||||
await _ingest.IngestAllAsync(backfill, CancellationToken.None);
|
||||
_status.Text = $"Abruf abgeschlossen ({DateTime.Now:HH:mm:ss}).";
|
||||
LoadRuns();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_status.Text = $"Fehler: {ex.Message}";
|
||||
_logger.Error("Accounting", $"Manueller Ingest fehlgeschlagen: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.Modules.CongressTrading.Database;
|
||||
using IBKRTrader.Modules.CongressTrading.Persistence.Ef;
|
||||
using IBKRTrader.Modules.CongressTrading.Scraper;
|
||||
using IBKRTrader.Modules.CongressTrading.UI;
|
||||
using IBKRTrader.Modules.CongressTrading.Workers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -48,21 +47,12 @@ public sealed class CongressTradingModule : IModule
|
||||
services.AddHostedService(sp => sp.GetRequiredService<CongressScrapeWorker>());
|
||||
}
|
||||
|
||||
public void RegisterUi(IModuleUiHost host, IServiceProvider services)
|
||||
{
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "congresstrading.main",
|
||||
Title = "Congress Trading",
|
||||
Group = Name,
|
||||
Order = 100,
|
||||
CreateForm = () => new CongressTradingForm(
|
||||
services.GetRequiredService<CongressRepository>(),
|
||||
services.GetRequiredService<WorkerEngine>(),
|
||||
services.GetRequiredService<IPortfolioService>(),
|
||||
services.GetRequiredService<LoggingService>())
|
||||
});
|
||||
}
|
||||
/// <summary>
|
||||
/// Bewusst leer: das Modulprojekt trägt keinen UI-Code mehr, damit es plattformneutral bleibt
|
||||
/// (kopfloser Linux-Betrieb). Das Modul-Fenster registriert die Shell zentral in
|
||||
/// <c>UI/ModuleViews.cs</c>; die Dienste dafür kommen aus dem DI-Container.
|
||||
/// </summary>
|
||||
public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
|
||||
|
||||
// DB-Schema wird extern per `dotnet ef database update` angewendet (keine Laufzeit-Migration).
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<!-- Plattformneutral: das Modul traegt keinen UI-Code mehr (Fenster liegt in der Shell,
|
||||
siehe UI/ModuleViews.cs) und laeuft damit auch im kopflosen Linux-Betrieb. -->
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Modul trägt eigene WinForms-UI (Modul-Fenster) bei. -->
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -121,7 +122,7 @@ public class CapitolTradesScraper
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
var total = int.Parse(match.Groups[1].Value);
|
||||
var total = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
_logger.Info("CT", $"GetTotalPagesAsync: {total} Seiten gefunden.");
|
||||
return total;
|
||||
}
|
||||
@@ -326,11 +327,18 @@ public class CapitolTradesScraper
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Datum aus dem RSC-Stream ("2026-08-04T…"). Bewusst <c>TryParseExact</c> mit fester
|
||||
/// Invariant-Kultur: <c>TryParse</c> ohne Formatanbieter hätte die Kultur des Rechners benutzt
|
||||
/// und damit je nach Host anders geraten. Ändert die Quelle ihr Format, soll das <b>auffallen</b>
|
||||
/// (null → Satz wird verworfen) statt still zu einem falschen Datum zu werden.
|
||||
/// </summary>
|
||||
private static DateOnly? ParseDate(string? s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return null;
|
||||
var datePart = s.Length > 10 ? s[..10] : s;
|
||||
return DateOnly.TryParse(datePart, out var d) ? d : null;
|
||||
return DateOnly.TryParseExact(datePart, "yyyy-MM-dd", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var d) ? d : null;
|
||||
}
|
||||
|
||||
private static string CapFirst(string s)
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.Modules.CongressTrading.Database;
|
||||
|
||||
namespace IBKRTrader.Modules.CongressTrading.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Eigenständiges Fenster des CongressTrading-Moduls: DB-Kennzahlen, manueller Scrape-Trigger
|
||||
/// und die offenen Positionen des Moduls (aus dem Core-Portfolio).
|
||||
/// </summary>
|
||||
public sealed class CongressTradingForm : Form
|
||||
{
|
||||
private const string ScrapeWorkerName = "CT-ScrapeWorker";
|
||||
|
||||
private readonly CongressRepository _repo;
|
||||
private readonly WorkerEngine _engine;
|
||||
private readonly IPortfolioService _portfolio;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
private readonly Label _lblTrades = new() { AutoSize = true, Location = new Point(20, 70) };
|
||||
private readonly Label _lblMembers = new() { AutoSize = true, Location = new Point(20, 100) };
|
||||
private readonly Button _btnRefresh = new() { Text = "Aktualisieren", Location = new Point(20, 140), Width = 140 };
|
||||
private readonly Button _btnScrape = new() { Text = "Scrape jetzt", Location = new Point(170, 140), Width = 140 };
|
||||
private readonly Label _lblStatus = new() { AutoSize = true, Location = new Point(20, 185), ForeColor = SystemColors.GrayText };
|
||||
|
||||
private readonly DataGridView _positions = new()
|
||||
{
|
||||
Location = new Point(20, 250),
|
||||
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
|
||||
ReadOnly = true,
|
||||
AllowUserToAddRows = false,
|
||||
RowHeadersVisible = false,
|
||||
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill
|
||||
};
|
||||
|
||||
public CongressTradingForm(CongressRepository repo, WorkerEngine engine, IPortfolioService portfolio, LoggingService logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_engine = engine;
|
||||
_portfolio = portfolio;
|
||||
_logger = logger;
|
||||
|
||||
Text = "Congress Trading [CT]";
|
||||
Width = 900;
|
||||
Height = 600;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimumSize = new Size(600, 400);
|
||||
BuildLayout();
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
var title = new Label
|
||||
{
|
||||
Text = "Congress Trading",
|
||||
Font = new Font(Font.FontFamily, 14f, FontStyle.Bold),
|
||||
Location = new Point(18, 20),
|
||||
AutoSize = true
|
||||
};
|
||||
|
||||
var posLabel = new Label
|
||||
{
|
||||
Text = "Offene Positionen (Modul CT):",
|
||||
Location = new Point(20, 225),
|
||||
AutoSize = true
|
||||
};
|
||||
|
||||
_positions.Size = new Size(ClientSize.Width - 40, ClientSize.Height - 270);
|
||||
|
||||
_btnRefresh.Click += async (_, _) => await RefreshStatsAsync();
|
||||
_btnScrape.Click += async (_, _) => await TriggerScrapeAsync();
|
||||
|
||||
Controls.Add(title);
|
||||
Controls.Add(_lblTrades);
|
||||
Controls.Add(_lblMembers);
|
||||
Controls.Add(_btnRefresh);
|
||||
Controls.Add(_btnScrape);
|
||||
Controls.Add(_lblStatus);
|
||||
Controls.Add(posLabel);
|
||||
Controls.Add(_positions);
|
||||
}
|
||||
|
||||
protected override async void OnShown(EventArgs e)
|
||||
{
|
||||
base.OnShown(e);
|
||||
await RefreshStatsAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshStatsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var trades = await _repo.GetTradeCountAsync();
|
||||
var members = await _repo.GetMemberCountAsync();
|
||||
_lblTrades.Text = $"Trades in DB: {trades:N0}";
|
||||
_lblMembers.Text = $"Mitglieder in DB: {members:N0}";
|
||||
|
||||
var positions = await _portfolio.GetPositionsAsync(CongressTradingModule.LogTag);
|
||||
_positions.DataSource = positions
|
||||
.Select(p => new { p.Symbol, Stück = p.Quantity, Ø_Kurs = p.AvgPrice, Wert = p.Notional })
|
||||
.ToList();
|
||||
|
||||
_lblStatus.Text = $"Aktualisiert: {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_lblTrades.Text = "Trades in DB: n/v";
|
||||
_lblMembers.Text = "Mitglieder in DB: n/v";
|
||||
_lblStatus.Text = $"DB nicht erreichbar: {ex.Message}";
|
||||
_logger.Warn(CongressTradingModule.LogTag, $"Kennzahlen konnten nicht geladen werden: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TriggerScrapeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_lblStatus.Text = "Scrape angestoßen...";
|
||||
await _engine.TriggerWorkerAsync(ScrapeWorkerName);
|
||||
_logger.Info(CongressTradingModule.LogTag, "Scrape-Worker manuell ausgelöst (aus Modul-Fenster).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_lblStatus.Text = $"Scrape fehlgeschlagen: {ex.Message}";
|
||||
_logger.Error(CongressTradingModule.LogTag, "Manueller Scrape-Trigger fehlgeschlagen.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Core.Persistence.Ef;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using IBKRTrader.Core.Logging;
|
||||
@@ -50,7 +51,7 @@ public class CongressScrapeWorker : WorkerBase
|
||||
if (trades.Count == 0)
|
||||
{
|
||||
Logger.Warn(Module, "Scrape: Keine Trades auf Seite 1 gefunden.");
|
||||
Info.Info = $"Letzter Lauf {DateTime.Now:HH:mm}: 0 Trades";
|
||||
Info.Info = $"Letzter Lauf {AppTimeZone.Now:HH:mm}: 0 Trades";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,6 +87,6 @@ public class CongressScrapeWorker : WorkerBase
|
||||
|
||||
var summary = $"{newTrades} neue Trades, {newMembers} neue Mitglieder";
|
||||
Logger.Info(Module, $"Scrape abgeschlossen: {summary}");
|
||||
Info.Info = $"Letzter Lauf {DateTime.Now:HH:mm}: {summary}";
|
||||
Info.Info = $"Letzter Lauf {AppTimeZone.Now:HH:mm}: {summary}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using IBKRTrader.Core.Configuration;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -36,7 +37,7 @@ public sealed class OpenRouterClient : IChatCompletionClient
|
||||
{
|
||||
string? key = Environment.GetEnvironmentVariable("IBKRTRADER_OPENROUTER_KEY");
|
||||
if (!string.IsNullOrWhiteSpace(key)) return key.Trim();
|
||||
string file = Path.Combine(AppContext.BaseDirectory, "openrouter.key");
|
||||
string file = AppPaths.ConfigFile("openrouter.key");
|
||||
return File.Exists(file) ? File.ReadAllText(file).Trim() : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using IBKRTrader.Core.Configuration;
|
||||
using System.Text.Json;
|
||||
using IBKRTrader.Core.Analytics;
|
||||
using IBKRTrader.Core.Logging;
|
||||
@@ -27,7 +28,7 @@ public static class SupervisorTools
|
||||
ISupervisorCounterfactualRepository? counterfactuals = null)
|
||||
{
|
||||
var reg = new SupervisorToolRegistry();
|
||||
string logsDir = Path.Combine(AppContext.BaseDirectory, "Logs");
|
||||
string logsDir = AppPaths.Logs;
|
||||
|
||||
reg.Register(new SupervisorTool(
|
||||
"query_decisions",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<!-- Plattformneutral: das Modul traegt keinen UI-Code mehr (Fenster liegt in der Shell,
|
||||
siehe UI/ModuleViews.cs) und laeuft damit auch im kopflosen Linux-Betrieb. -->
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Modul trägt eigene WinForms-UI (Modul-Fenster) bei. -->
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Modules.Supervisor.Agent;
|
||||
using IBKRTrader.Modules.Supervisor.Persistence;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -38,10 +39,12 @@ public sealed class DailyReportService : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info("Supervisor", $"Tagesbericht aktiv: täglich um {hour:00}:00 Uhr.");
|
||||
_logger.Info("Supervisor",
|
||||
$"Tagesbericht aktiv: täglich um {hour:00}:00 Uhr ({AppTimeZone.CurrentId}).");
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var delay = NextRun(DateTime.Now, hour) - DateTime.Now;
|
||||
var now = DateTime.UtcNow;
|
||||
var delay = NextRunUtc(now, hour, AppTimeZone.Current) - now;
|
||||
try { await Task.Delay(delay, stoppingToken); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
@@ -51,10 +54,37 @@ public sealed class DailyReportService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
internal static DateTime NextRun(DateTime now, int hour)
|
||||
/// <summary>
|
||||
/// Nächster Ausführungszeitpunkt (UTC) für „täglich um <paramref name="hour"/> Uhr Ortszeit".
|
||||
///
|
||||
/// <para>Rechnet ausdrücklich gegen <paramref name="zone"/> statt gegen
|
||||
/// <see cref="DateTimeKind.Local"/>: sonst hinge die Berichtszeit an der Zeitzone des Rechners.
|
||||
/// Eine US-Instanz auf einem UTC-Container hätte den „18-Uhr-Bericht" um 13 Uhr Ortszeit
|
||||
/// erzeugt – über eine Zeitgrenze hinweg, die im Bericht selbst nicht sichtbar ist.</para>
|
||||
/// </summary>
|
||||
internal static DateTime NextRunUtc(DateTime nowUtc, int hour, TimeZoneInfo zone)
|
||||
{
|
||||
var candidate = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0, DateTimeKind.Local);
|
||||
return candidate <= now ? candidate.AddDays(1) : candidate;
|
||||
var localNow = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, zone);
|
||||
|
||||
// Zwei Tage reichen: der heutige Termin liegt entweder noch vor uns oder der morgige.
|
||||
for (int addDays = 0; addDays <= 2; addDays++)
|
||||
{
|
||||
var candidate = localNow.Date.AddDays(addDays).AddHours(hour);
|
||||
|
||||
// Beim Vorstellen der Uhr existiert die Stunde nicht – dann auf die nächste gültige
|
||||
// ausweichen, statt den Bericht des Tages ausfallen zu lassen.
|
||||
int guard = 0;
|
||||
while (zone.IsInvalidTime(candidate) && guard++ < 4)
|
||||
candidate = candidate.AddHours(1);
|
||||
if (zone.IsInvalidTime(candidate)) continue;
|
||||
|
||||
// Beim Zurückstellen ist die Stunde doppelt; ConvertTimeToUtc nimmt die Normalzeit – gewollt.
|
||||
var utc = TimeZoneInfo.ConvertTimeToUtc(
|
||||
DateTime.SpecifyKind(candidate, DateTimeKind.Unspecified), zone);
|
||||
if (utc > nowUtc) return utc;
|
||||
}
|
||||
|
||||
return nowUtc.AddDays(1);
|
||||
}
|
||||
|
||||
private async Task RunOnceAsync(CancellationToken ct)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using IBKRTrader.Core.Configuration;
|
||||
using IBKRTrader.Core.Analytics;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Persistence;
|
||||
@@ -26,7 +27,7 @@ public sealed class DossierService
|
||||
_journal = journal;
|
||||
_orderEvents = orderEvents;
|
||||
_trades = trades;
|
||||
_logsDirectory = Path.Combine(AppContext.BaseDirectory, "Logs");
|
||||
_logsDirectory = AppPaths.Logs;
|
||||
}
|
||||
|
||||
/// <summary>Jüngste Signale (gruppiert über das Entscheidungsjournal), neueste zuerst.</summary>
|
||||
|
||||
@@ -8,7 +8,6 @@ using IBKRTrader.Modules.Supervisor.Agent;
|
||||
using IBKRTrader.Modules.Supervisor.Counterfactual;
|
||||
using IBKRTrader.Modules.Supervisor.Persistence;
|
||||
using IBKRTrader.Modules.Supervisor.Services;
|
||||
using IBKRTrader.Modules.Supervisor.Ui;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -58,21 +57,12 @@ public sealed class SupervisorModule : IModule
|
||||
services.AddHostedService<Mcp.McpLightServer>();
|
||||
}
|
||||
|
||||
public void RegisterUi(IModuleUiHost host, IServiceProvider services)
|
||||
{
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "supervisor.main",
|
||||
Title = "Supervisor",
|
||||
Group = Name,
|
||||
Order = 300,
|
||||
CreateForm = () => new SupervisorMainForm(
|
||||
services.GetRequiredService<SupervisorAgent>(),
|
||||
services.GetRequiredService<DossierService>(),
|
||||
services.GetRequiredService<ISupervisorReportRepository>(),
|
||||
services.GetRequiredService<LoggingService>())
|
||||
});
|
||||
}
|
||||
/// <summary>
|
||||
/// Bewusst leer: das Modulprojekt trägt keinen UI-Code mehr, damit es plattformneutral bleibt
|
||||
/// (kopfloser Linux-Betrieb). Das Supervisor-Fenster registriert die Shell zentral in
|
||||
/// <c>UI/ModuleViews.cs</c>; die Dienste dafür kommen aus dem DI-Container.
|
||||
/// </summary>
|
||||
public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using IBKRTrader.Core.Analytics;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Modules.Supervisor.Agent;
|
||||
using IBKRTrader.Modules.Supervisor.Persistence;
|
||||
using IBKRTrader.Modules.Supervisor.Services;
|
||||
|
||||
namespace IBKRTrader.Modules.Supervisor.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Fenster des Supervisor-Moduls: Analyse (Chat mit dem Agenten, Tool-Aufrufe sichtbar), Dossier-Browser,
|
||||
/// Berichte, Settings. Read-only. DB-/Agent-Zugriffe laufen NUR auf Nutzer-Interaktion (Smoke-UI-sicher).
|
||||
/// </summary>
|
||||
public sealed class SupervisorMainForm : Form
|
||||
{
|
||||
private readonly SupervisorAgent _agent;
|
||||
private readonly DossierService _dossiers;
|
||||
private readonly ISupervisorReportRepository _reports;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
private readonly ComboBox _profile = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 160 };
|
||||
private readonly TextBox _question = new() { Dock = DockStyle.Fill, Multiline = true, Height = 60 };
|
||||
private readonly RichTextBox _answer = new() { Dock = DockStyle.Fill, ReadOnly = true, Font = new Font("Consolas", 9f) };
|
||||
private readonly Button _ask = new() { Text = "Fragen", Width = 100 };
|
||||
|
||||
private readonly DataGridView _signals = new() { Dock = DockStyle.Left, Width = 360, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
|
||||
private readonly RichTextBox _dossier = new() { Dock = DockStyle.Fill, ReadOnly = true, Font = new Font("Consolas", 9f) };
|
||||
private readonly DataGridView _reportsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
|
||||
|
||||
public SupervisorMainForm(
|
||||
SupervisorAgent agent, DossierService dossiers, ISupervisorReportRepository reports, LoggingService logger)
|
||||
{
|
||||
_agent = agent;
|
||||
_dossiers = dossiers;
|
||||
_reports = reports;
|
||||
_logger = logger;
|
||||
|
||||
Text = "Supervisor";
|
||||
Width = 1080;
|
||||
Height = 720;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimumSize = new Size(800, 520);
|
||||
|
||||
foreach (var p in SupervisorProfiles.All) _profile.Items.Add(p.Name);
|
||||
_profile.SelectedIndex = 0;
|
||||
|
||||
BuildLayout();
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
var tabs = new TabControl { Dock = DockStyle.Fill };
|
||||
|
||||
// ── Tab: Analyse ──
|
||||
var tabChat = new TabPage("Analyse");
|
||||
var top = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 36, Padding = new Padding(8, 6, 8, 0) };
|
||||
top.Controls.Add(new Label { Text = "Profil", AutoSize = true, Margin = new Padding(0, 8, 4, 0) });
|
||||
top.Controls.Add(_profile);
|
||||
_ask.Click += async (_, _) => await AskAsync();
|
||||
var qPanel = new Panel { Dock = DockStyle.Top, Height = 70, Padding = new Padding(8, 2, 8, 4) };
|
||||
qPanel.Controls.Add(_question);
|
||||
var askPanel = new Panel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(8, 0, 8, 0) };
|
||||
askPanel.Controls.Add(_ask);
|
||||
var answerPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8) };
|
||||
answerPanel.Controls.Add(_answer);
|
||||
tabChat.Controls.Add(answerPanel);
|
||||
tabChat.Controls.Add(askPanel);
|
||||
tabChat.Controls.Add(qPanel);
|
||||
tabChat.Controls.Add(top);
|
||||
|
||||
// ── Tab: Dossier-Browser ──
|
||||
var tabDossier = new TabPage("Dossier-Browser");
|
||||
_signals.SelectionChanged += (_, _) => ShowSelectedDossier();
|
||||
var refreshSignals = new Button { Text = "Signale laden", Dock = DockStyle.Top, Height = 28 };
|
||||
refreshSignals.Click += (_, _) => LoadSignals();
|
||||
var left = new Panel { Dock = DockStyle.Left, Width = 360 };
|
||||
left.Controls.Add(_signals);
|
||||
left.Controls.Add(refreshSignals);
|
||||
var dossierPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8) };
|
||||
dossierPanel.Controls.Add(_dossier);
|
||||
tabDossier.Controls.Add(dossierPanel);
|
||||
tabDossier.Controls.Add(left);
|
||||
|
||||
// ── Tab: Berichte ──
|
||||
var tabReports = new TabPage("Berichte");
|
||||
var refreshReports = new Button { Text = "Berichte laden", Dock = DockStyle.Top, Height = 28 };
|
||||
refreshReports.Click += (_, _) => LoadReports();
|
||||
tabReports.Controls.Add(_reportsGrid);
|
||||
tabReports.Controls.Add(refreshReports);
|
||||
|
||||
// ── Tab: Settings (Info) ──
|
||||
var tabSettings = new TabPage("Settings");
|
||||
tabSettings.Controls.Add(new Label
|
||||
{
|
||||
Dock = DockStyle.Fill, Padding = new Padding(16),
|
||||
Text =
|
||||
"Supervisor – read-only Analyse/Forensik über alle Module.\n\n" +
|
||||
"OpenRouter-Key: env IBKRTRADER_OPENROUTER_KEY oder Datei 'openrouter.key' (gitignored).\n" +
|
||||
$" Status: {(string.IsNullOrEmpty(OpenRouterClient.DefaultApiKeyProvider()) ? "NICHT gesetzt – Chat nicht verfügbar" : "gesetzt")}\n\n" +
|
||||
"Tagesbericht (opt-in): env IBKRTRADER_SUPERVISOR_DAILY = Stunde 0–23.\n" +
|
||||
"MCP-Light (opt-in): env IBKRTRADER_MCP_PORT = Port (bindet nur 127.0.0.1).\n\n" +
|
||||
"Sicherheit: OpenRouter ist ein bewusst freigegebener externer Datenempfänger. Es werden nur\n" +
|
||||
"Analyse-Daten der Tools gesendet, niemals Secrets. Kein Tool kann handeln oder schreiben."
|
||||
});
|
||||
|
||||
tabs.TabPages.AddRange(new[] { tabChat, tabDossier, tabReports, tabSettings });
|
||||
Controls.Add(tabs);
|
||||
}
|
||||
|
||||
// ── Analyse ──
|
||||
|
||||
private async Task AskAsync()
|
||||
{
|
||||
var question = _question.Text.Trim();
|
||||
if (string.IsNullOrEmpty(question)) return;
|
||||
|
||||
_ask.Enabled = false;
|
||||
_answer.Clear();
|
||||
var profile = SupervisorProfiles.ByName(_profile.Text);
|
||||
var progress = new Progress<string>(s => AppendLine(s));
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _agent.AskAsync(question, profile: profile, progress: progress);
|
||||
AppendLine("");
|
||||
AppendLine("─── Antwort ───");
|
||||
AppendLine(result.Answer);
|
||||
|
||||
_reports.Insert(new SupervisorReport
|
||||
{
|
||||
Profile = profile.Name,
|
||||
Model = SupervisorAgent.DefaultModel,
|
||||
Question = question,
|
||||
Answer = result.Answer,
|
||||
ToolCallsJson = JsonSerializer.Serialize(result.ToolInvocations.Select(i => new { i.Tool, i.Arguments })),
|
||||
ToolCallCount = result.ToolInvocations.Count,
|
||||
PromptTokens = result.PromptTokens,
|
||||
CompletionTokens = result.CompletionTokens
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendLine("");
|
||||
AppendLine($"FEHLER: {ex.Message}");
|
||||
_logger.Warn("Supervisor", $"Analyse fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ask.Enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void AppendLine(string text)
|
||||
{
|
||||
if (_answer.InvokeRequired) { _answer.BeginInvoke(() => AppendLine(text)); return; }
|
||||
_answer.AppendText(text + "\n");
|
||||
_answer.ScrollToCaret();
|
||||
}
|
||||
|
||||
// ── Dossier ──
|
||||
|
||||
private void LoadSignals()
|
||||
{
|
||||
try { _signals.DataSource = _dossiers.RecentSignals(200); }
|
||||
catch (Exception ex) { _logger.Warn("Supervisor", $"Signale laden fehlgeschlagen: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private void ShowSelectedDossier()
|
||||
{
|
||||
if (_signals.CurrentRow?.DataBoundItem is not SignalSummary s) return;
|
||||
try { _dossier.Text = DossierBuilder.ToMarkdown(_dossiers.BuildForSignal(s.SignalId)); }
|
||||
catch (Exception ex) { _dossier.Text = $"FEHLER: {ex.Message}"; }
|
||||
}
|
||||
|
||||
// ── Berichte ──
|
||||
|
||||
private void LoadReports()
|
||||
{
|
||||
try
|
||||
{
|
||||
_reportsGrid.DataSource = _reports.GetRecent(100)
|
||||
.Select(r => new { r.CreatedAt, r.Profile, r.Model, r.Question, r.ToolCallCount, r.PromptTokens, r.CompletionTokens })
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex) { _logger.Warn("Supervisor", $"Berichte laden fehlgeschlagen: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Core.Configuration;
|
||||
|
||||
namespace IBKRTrader.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Die Ablageorte müssen auf beiden Plattformen bestimmbar und beschreibbar sein. Bisher lag alles
|
||||
/// neben der Binärdatei – unter <c>/opt</c> hat der Dienstbenutzer dort keinen Schreibzugriff, der
|
||||
/// Dienst wäre beim ersten Logeintrag gescheitert.
|
||||
/// </summary>
|
||||
[Trait("cat", "unit")]
|
||||
public class AppPathsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AlleVerzeichnisse_SindErmittelbarUndExistieren()
|
||||
{
|
||||
// Der Zugriff legt sie an – ein Dienst darf nicht daran scheitern, dass ein Verzeichnis fehlt.
|
||||
foreach (var dir in new[] { AppPaths.Config, AppPaths.Data, AppPaths.Logs })
|
||||
{
|
||||
dir.Should().NotBeNullOrWhiteSpace();
|
||||
Directory.Exists(dir).Should().BeTrue($"AppPaths muss \"{dir}\" anlegen");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verzeichnisse_SindTatsaechlichBeschreibbar()
|
||||
{
|
||||
// Die Auflösung prüft den Schreibzugriff durch einen echten Schreibversuch. Dieser Test
|
||||
// haelt fest, dass das Ergebnis stimmt – eine reine Attributpruefung waere hier wertlos,
|
||||
// weil unter Linux Besitzer/Gruppe/Modus und unter Windows die ACL entscheiden.
|
||||
foreach (var dir in new[] { AppPaths.Config, AppPaths.Data, AppPaths.Logs })
|
||||
{
|
||||
var probe = Path.Combine(dir, $"test-{Guid.NewGuid():N}.tmp");
|
||||
File.WriteAllText(probe, "x");
|
||||
File.Delete(probe);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigFile_LiegtImKonfigurationsverzeichnis()
|
||||
{
|
||||
var path = AppPaths.ConfigFile("master.key");
|
||||
|
||||
Path.GetDirectoryName(path).Should().Be(AppPaths.Config.TrimEnd(Path.DirectorySeparatorChar));
|
||||
Path.GetFileName(path).Should().Be("master.key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataPath_LiegtImDatenverzeichnis()
|
||||
{
|
||||
AppPaths.DataPath("Backups").Should().StartWith(AppPaths.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_NenntAlleDreiOrte()
|
||||
{
|
||||
// Wird beim Start geloggt – im Betrieb muss sichtbar sein, wohin geschrieben wird.
|
||||
var text = AppPaths.Describe();
|
||||
|
||||
text.Should().Contain("config=").And.Contain("data=").And.Contain("logs=");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- net10.0-windows + WinForms, weil das Testprojekt die WinForms-Hauptassembly referenziert -->
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<!-- Plattformneutral: die Tests decken Core und Module ab, die beide keinen UI-Code mehr
|
||||
tragen. Die UI-Konstruktionsprüfung liegt jetzt beim Smoke-UI-Lauf der Shell. -->
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Modules.CongressTrading.Scraper;
|
||||
@@ -44,6 +45,41 @@ public class CapitolTradesScraperTests
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("de-DE")] // Tag.Monat.Jahr
|
||||
[InlineData("th-TH")] // buddhistischer Kalender
|
||||
[InlineData("fa-IR")] // persischer Kalender
|
||||
[InlineData("ar-SA")] // Hidschri-Kalender
|
||||
[InlineData("")] // Invariant – so verhält sich ein Container ohne LANG
|
||||
public void ParseTradesFromHtml_LiefertDieselbenDaten_UnabhaengigVonDerHostKultur(string culture)
|
||||
{
|
||||
// Die Datumsfelder kamen ueber DateOnly.TryParse OHNE Formatanbieter herein, also mit der
|
||||
// Kultur des Rechners. Bei Kulturen mit eigenem Kalender ist das nicht theoretisch:
|
||||
// aus "2026-08-04" wurde unter th-TH das Jahr 1483 und unter fa-IR das Jahr 2647,
|
||||
// unter ar-SA schlug das Parsen ganz fehl. de-DE und en-US kommen mit ISO klar - genau
|
||||
// deshalb faellt so ein Fehler auf dem Entwicklungsrechner nie auf.
|
||||
var previous = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
|
||||
|
||||
var (trades, _) = new CapitolTradesScraper(new LoggingService()).ParseTradesFromHtml(LoadFixture());
|
||||
|
||||
trades.Should().NotBeEmpty();
|
||||
trades.Should().Contain(t => t.TradeDate != null,
|
||||
"die Fixture enthaelt Handelsdaten im Format yyyy-MM-dd");
|
||||
|
||||
// Alle geparsten Daten muessen plausibel sein – ein kulturbedingter Tag/Monat-Dreher
|
||||
// faellt hier auf, weil Tage > 12 sonst gar nicht parsen wuerden.
|
||||
foreach (var t in trades.Where(t => t.TradeDate != null))
|
||||
t.TradeDate!.Value.Year.Should().BeInRange(2000, 2100);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = previous;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTradesFromHtml_EmptyHtml_ReturnsNoTrades()
|
||||
{
|
||||
|
||||
@@ -55,17 +55,17 @@ public class CongressTradingModuleTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterUi_RegistersMainView()
|
||||
public void RegisterUi_RegistriertNichts_DamitDasModulPlattformneutralBleibt()
|
||||
{
|
||||
// Absicht, kein Versehen: würde das Modul sein Fenster selbst erzeugen, müsste es das
|
||||
// UI-Toolkit referenzieren – und wäre damit nicht mehr kopflos auf Linux lauffähig.
|
||||
// Das Fenster registriert die Shell zentral (App: UI/ModuleViews.cs).
|
||||
var host = new CapturingUiHost();
|
||||
// CreateForm wird hier NICHT aufgerufen – daher genügt ein leerer Provider.
|
||||
var provider = new ServiceCollection().BuildServiceProvider();
|
||||
|
||||
new CongressTradingModule().RegisterUi(host, provider);
|
||||
|
||||
host.Views.Should().ContainSingle();
|
||||
host.Views[0].Id.Should().Be("congresstrading.main");
|
||||
host.Views[0].Title.Should().Be("Congress Trading");
|
||||
host.Views.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using FluentAssertions;
|
||||
using IBKRTrader.Modules.Supervisor.Services;
|
||||
|
||||
namespace IBKRTrader.Tests.Modules.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Der Tagesbericht soll um eine feste ORTSZEIT laufen – unabhängig davon, in welcher Zeitzone der
|
||||
/// Rechner steht. Vorher rechnete er gegen <c>DateTimeKind.Local</c>: eine US-Instanz auf einem
|
||||
/// UTC-Container hätte den „18-Uhr-Bericht" um 13 Uhr Ortszeit erzeugt, über eine Zeitgrenze
|
||||
/// hinweg, die im Bericht selbst nicht sichtbar ist.
|
||||
/// </summary>
|
||||
[Trait("cat", "unit")]
|
||||
public class DailyReportScheduleTests
|
||||
{
|
||||
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
|
||||
|
||||
[Fact]
|
||||
public void NaechsterLauf_LiegtHeute_WennDieStundeNochBevorsteht()
|
||||
{
|
||||
// 08:00 UTC = 10:00 Berliner Sommerzeit. 18 Uhr Berlin steht heute noch bevor = 16:00 UTC.
|
||||
var now = new DateTime(2026, 7, 15, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
DailyReportService.NextRunUtc(now, 18, Berlin)
|
||||
.Should().Be(new DateTime(2026, 7, 15, 16, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaechsterLauf_RutschtAufMorgen_WennDieStundeVorbeiIst()
|
||||
{
|
||||
// 20:00 UTC = 22:00 Berlin. 18 Uhr ist durch, also morgen 18 Uhr Berlin = 16:00 UTC.
|
||||
var now = new DateTime(2026, 7, 15, 20, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
DailyReportService.NextRunUtc(now, 18, Berlin)
|
||||
.Should().Be(new DateTime(2026, 7, 16, 16, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DieselbeStunde_ErgibtJeZoneEinenAnderenUtcZeitpunkt()
|
||||
{
|
||||
var now = new DateTime(2026, 7, 15, 6, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// 18 Uhr Ortszeit: Berlin (UTC+2) = 16:00 UTC, New York (UTC-4) = 22:00 UTC.
|
||||
DailyReportService.NextRunUtc(now, 18, Berlin).Hour.Should().Be(16);
|
||||
DailyReportService.NextRunUtc(now, 18, NewYork).Hour.Should().Be(22);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Winterzeit_NutztDenRichtigenVersatz()
|
||||
{
|
||||
// Januar: Berlin ist UTC+1, 18 Uhr Ortszeit = 17:00 UTC.
|
||||
var now = new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
DailyReportService.NextRunUtc(now, 18, Berlin)
|
||||
.Should().Be(new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zeitumstellung_LaesstDenBerichtNichtAusfallen()
|
||||
{
|
||||
// 29.03.2026: Berlin stellt um 02:00 auf 03:00 vor – die Stunde 02:00 existiert nicht.
|
||||
// Der Bericht muss trotzdem laufen (auf die naechste gueltige Stunde ausweichen),
|
||||
// statt den Tag zu ueberspringen.
|
||||
var now = new DateTime(2026, 3, 28, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var next = DailyReportService.NextRunUtc(now, 2, Berlin);
|
||||
|
||||
next.Should().BeAfter(now);
|
||||
next.Should().BeBefore(now.AddDays(2), "der Bericht darf hoechstens einen Tag spaeter kommen");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ergebnis_IstImmerUtc()
|
||||
{
|
||||
var now = new DateTime(2026, 7, 15, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
DailyReportService.NextRunUtc(now, 18, Berlin).Kind.Should().Be(DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user