R2: Generic Host + Modul-Vertrag + Shell-UI (PolytraderSharp-Konzept)

- Core/Modularity: neuer IModule (Name, DbPrefix, RegisterServices(services,config),
  RegisterUi(host,sp), StartAsync/StopAsync, GetActivationBlocker) + ModuleView
  + IModuleUiHost + WindowMenu. Alte IModule/ModuleRegistry/WindowManager/ModuleFormBase entfernt.
- Program.cs: Host.CreateDefaultBuilder + IConfiguration (appsettings.json/.Local.json);
  Core-Services registriert, Module via RegisterServices, Views via RegisterUi.
- UI: ShellUiHost (Einzelinstanz-Fenster + Fenster-Menue), LauncherForm als Shell
  (Buttons je View), Core-Views Logs/Settings/Workers als eigene Fenster.
- CongressTrading auf neuen Vertrag; Worker als IWorker registriert.
- --smoke-ui Headless-Test (konstruiert jede View + Launcher).
- appsettings.Local.json gitignored.
- Tests angepasst -> 30/30 gruen; Build + Smoke-UI + App-Start verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-28 00:24:00 +02:00
parent c20538c8d8
commit 8f44c0fd82
25 changed files with 695 additions and 1047 deletions
+1
View File
@@ -39,6 +39,7 @@ $RECYCLE.BIN/
## Application data (do NOT commit secrets or runtime data) ## Application data (do NOT commit secrets or runtime data)
settings.json settings.json
appsettings.Local.json
Backups/ Backups/
Logs/ Logs/
*.log *.log
+7
View File
@@ -24,6 +24,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -35,6 +36,12 @@
<None Update="settings.json"> <None Update="settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup> </ItemGroup>
</Project> </Project>
-274
View File
@@ -1,274 +0,0 @@
namespace IBKRTrader
{
partial class LauncherForm
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(LauncherForm));
menuStrip1 = new MenuStrip();
toolStrip1 = new ToolStrip();
statusStrip1 = new StatusStrip();
tabControl1 = new TabControl();
tabPage_dash = new TabPage();
tabPage_sett = new TabPage();
tabPage_modules = new TabPage();
flp_modules = new FlowLayoutPanel();
btn_activateTrading = new ToolStripButton();
tabPage_history = new TabPage();
tabPage_workers = new TabPage();
toolStrip2 = new ToolStrip();
dgv_workerlist = new DataGridView();
pg_settings = new PropertyGrid();
tabPage_logs = new TabPage();
rtb_logs = new RichTextBox();
toolStrip3 = new ToolStrip();
toolStrip1.SuspendLayout();
tabControl1.SuspendLayout();
tabPage_sett.SuspendLayout();
tabPage_workers.SuspendLayout();
((System.ComponentModel.ISupportInitialize)dgv_workerlist).BeginInit();
tabPage_logs.SuspendLayout();
SuspendLayout();
//
// menuStrip1
//
menuStrip1.ImageScalingSize = new Size(24, 24);
menuStrip1.Location = new Point(0, 0);
menuStrip1.Name = "menuStrip1";
menuStrip1.Size = new Size(2441, 24);
menuStrip1.TabIndex = 0;
menuStrip1.Text = "menuStrip1";
//
// toolStrip1
//
toolStrip1.ImageScalingSize = new Size(24, 24);
toolStrip1.Items.AddRange(new ToolStripItem[] { btn_activateTrading });
toolStrip1.Location = new Point(0, 24);
toolStrip1.Name = "toolStrip1";
toolStrip1.Size = new Size(2441, 34);
toolStrip1.TabIndex = 1;
toolStrip1.Text = "toolStrip1";
//
// statusStrip1
//
statusStrip1.ImageScalingSize = new Size(24, 24);
statusStrip1.Location = new Point(0, 1197);
statusStrip1.Name = "statusStrip1";
statusStrip1.Size = new Size(2441, 22);
statusStrip1.TabIndex = 2;
statusStrip1.Text = "statusStrip1";
//
// tabControl1
//
tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
tabControl1.Controls.Add(tabPage_dash);
tabControl1.Controls.Add(tabPage_history);
tabControl1.Controls.Add(tabPage_sett);
tabControl1.Controls.Add(tabPage_logs);
tabControl1.Controls.Add(tabPage_workers);
tabControl1.Controls.Add(tabPage_modules);
tabControl1.Location = new Point(12, 52);
tabControl1.Name = "tabControl1";
tabControl1.SelectedIndex = 0;
tabControl1.Size = new Size(2429, 1142);
tabControl1.TabIndex = 3;
//
// tabPage_dash
//
tabPage_dash.Location = new Point(4, 34);
tabPage_dash.Name = "tabPage_dash";
tabPage_dash.Padding = new Padding(3);
tabPage_dash.Size = new Size(2421, 1104);
tabPage_dash.TabIndex = 0;
tabPage_dash.Text = "Dashboard";
tabPage_dash.UseVisualStyleBackColor = true;
//
// tabPage_sett
//
tabPage_sett.Controls.Add(pg_settings);
tabPage_sett.Location = new Point(4, 34);
tabPage_sett.Name = "tabPage_sett";
tabPage_sett.Padding = new Padding(3);
tabPage_sett.Size = new Size(2421, 1104);
tabPage_sett.TabIndex = 1;
tabPage_sett.Text = "Settings";
tabPage_sett.UseVisualStyleBackColor = true;
//
// tabPage_modules
//
tabPage_modules.Controls.Add(flp_modules);
tabPage_modules.Location = new Point(4, 34);
tabPage_modules.Name = "tabPage_modules";
tabPage_modules.Padding = new Padding(3);
tabPage_modules.Size = new Size(2421, 1104);
tabPage_modules.TabIndex = 2;
tabPage_modules.Text = "Module";
tabPage_modules.UseVisualStyleBackColor = true;
//
// flp_modules
//
flp_modules.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
flp_modules.AutoScroll = true;
flp_modules.Location = new Point(6, 6);
flp_modules.Name = "flp_modules";
flp_modules.Padding = new Padding(6);
flp_modules.Size = new Size(2409, 1092);
flp_modules.TabIndex = 0;
//
// btn_activateTrading
//
btn_activateTrading.Alignment = ToolStripItemAlignment.Right;
btn_activateTrading.Image = (Image)resources.GetObject("btn_activateTrading.Image");
btn_activateTrading.ImageTransparentColor = Color.Magenta;
btn_activateTrading.Name = "btn_activateTrading";
btn_activateTrading.Size = new Size(166, 29);
btn_activateTrading.Text = "Activate Trading";
//
// tabPage_history
//
tabPage_history.Location = new Point(4, 34);
tabPage_history.Name = "tabPage_history";
tabPage_history.Padding = new Padding(3);
tabPage_history.Size = new Size(2421, 1104);
tabPage_history.TabIndex = 3;
tabPage_history.Text = "Trade History";
tabPage_history.UseVisualStyleBackColor = true;
//
// tabPage_workers
//
tabPage_workers.Controls.Add(dgv_workerlist);
tabPage_workers.Controls.Add(toolStrip2);
tabPage_workers.Location = new Point(4, 34);
tabPage_workers.Name = "tabPage_workers";
tabPage_workers.Padding = new Padding(3);
tabPage_workers.Size = new Size(2421, 1104);
tabPage_workers.TabIndex = 4;
tabPage_workers.Text = "Workers / Services";
tabPage_workers.UseVisualStyleBackColor = true;
//
// toolStrip2
//
toolStrip2.ImageScalingSize = new Size(24, 24);
toolStrip2.Location = new Point(3, 3);
toolStrip2.Name = "toolStrip2";
toolStrip2.Size = new Size(2415, 25);
toolStrip2.TabIndex = 0;
toolStrip2.Text = "toolStrip2";
//
// dgv_workerlist
//
dgv_workerlist.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
dgv_workerlist.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize;
dgv_workerlist.Location = new Point(6, 31);
dgv_workerlist.Name = "dgv_workerlist";
dgv_workerlist.RowHeadersWidth = 62;
dgv_workerlist.Size = new Size(2409, 1067);
dgv_workerlist.TabIndex = 1;
//
// pg_settings
//
pg_settings.BackColor = SystemColors.Control;
pg_settings.Location = new Point(3, 6);
pg_settings.Name = "pg_settings";
pg_settings.Size = new Size(1101, 1075);
pg_settings.TabIndex = 0;
//
// tabPage_logs
//
tabPage_logs.Controls.Add(toolStrip3);
tabPage_logs.Controls.Add(rtb_logs);
tabPage_logs.Location = new Point(4, 34);
tabPage_logs.Name = "tabPage_logs";
tabPage_logs.Padding = new Padding(3);
tabPage_logs.Size = new Size(2421, 1104);
tabPage_logs.TabIndex = 5;
tabPage_logs.Text = "Logs";
tabPage_logs.UseVisualStyleBackColor = true;
//
// rtb_logs
//
rtb_logs.Location = new Point(6, 31);
rtb_logs.Name = "rtb_logs";
rtb_logs.Size = new Size(1849, 811);
rtb_logs.TabIndex = 0;
rtb_logs.Text = "";
//
// toolStrip3
//
toolStrip3.ImageScalingSize = new Size(24, 24);
toolStrip3.Location = new Point(3, 3);
toolStrip3.Name = "toolStrip3";
toolStrip3.Size = new Size(2415, 25);
toolStrip3.TabIndex = 1;
toolStrip3.Text = "toolStrip3";
//
// Form1
//
AutoScaleDimensions = new SizeF(10F, 25F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(2441, 1219);
Controls.Add(tabControl1);
Controls.Add(statusStrip1);
Controls.Add(toolStrip1);
Controls.Add(menuStrip1);
MainMenuStrip = menuStrip1;
Name = "LauncherForm";
Text = "IBKRTrader — Launcher";
toolStrip1.ResumeLayout(false);
toolStrip1.PerformLayout();
tabControl1.ResumeLayout(false);
tabPage_sett.ResumeLayout(false);
tabPage_workers.ResumeLayout(false);
tabPage_workers.PerformLayout();
((System.ComponentModel.ISupportInitialize)dgv_workerlist).EndInit();
tabPage_logs.ResumeLayout(false);
tabPage_logs.PerformLayout();
ResumeLayout(false);
PerformLayout();
}
#endregion
private MenuStrip menuStrip1;
private ToolStrip toolStrip1;
private ToolStripButton btn_activateTrading;
private StatusStrip statusStrip1;
private TabControl tabControl1;
private TabPage tabPage_dash;
private TabPage tabPage_sett;
private TabPage tabPage_modules;
private FlowLayoutPanel flp_modules;
private TabPage tabPage_history;
private TabPage tabPage_workers;
private DataGridView dgv_workerlist;
private ToolStrip toolStrip2;
private PropertyGrid pg_settings;
private TabPage tabPage_logs;
private RichTextBox rtb_logs;
private ToolStrip toolStrip3;
}
}
+99 -168
View File
@@ -1,224 +1,155 @@
using IBKRTrader.Core.Database.Migrations; using IBKRTrader.Core.Database.Migrations;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modules; using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers;
using IBKRTrader.UI; using IBKRTrader.UI;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader; namespace IBKRTrader;
/// <summary> /// <summary>
/// Launcher das Basis-Fenster der Anwendung. /// Launcher das Basis-Fenster (Shell). Zeigt je registrierter View einen Button, führt beim Start
/// Enthält die Core-Panels (Workers, Logs, Settings) und eine Modul-Liste, /// Migrationen aus, startet Module und die WorkerEngine, und trägt das gemeinsame Fenster-Menü.
/// aus der jedes Modul als eigenständiges Fenster geöffnet wird. /// Die inhaltlichen Ansichten (Logs, Settings, Workers, Module) sind eigenständige Fenster.
/// </summary> /// </summary>
public partial class LauncherForm : Form public sealed class LauncherForm : Form
{ {
private readonly LoggingService _logger; private readonly ShellUiHost _uiHost;
private readonly WorkerEngine _workerEngine; private readonly IServiceProvider _services;
private readonly SettingsService _settings; private readonly LoggingService _logger;
private readonly CoreMigrations _migrations; private readonly WorkerEngine _workerEngine;
private readonly IBKRMigrations _ibkrMigrations; private readonly IReadOnlyList<IModule> _modules;
private readonly ModuleRegistry _modules;
private readonly WindowManager _windows;
private readonly IServiceProvider _services;
private LogPanelController? _logPanel; private readonly Dictionary<string, Button> _viewButtons = new(StringComparer.OrdinalIgnoreCase);
private readonly FlowLayoutPanel _buttonPanel = new()
public LauncherForm(
LoggingService logger,
WorkerEngine workerEngine,
SettingsService settings,
CoreMigrations migrations,
IBKRMigrations ibkrMigrations,
ModuleRegistry modules,
WindowManager windows,
IServiceProvider services)
{ {
InitializeComponent(); Dock = DockStyle.Fill, Padding = new Padding(16), AutoScroll = true
};
private readonly ToolStripStatusLabel _status = new("Start...");
_logger = logger; public LauncherForm(ShellUiHost uiHost, IServiceProvider services)
_workerEngine = workerEngine; {
_settings = settings; _uiHost = uiHost;
_migrations = migrations; _services = services;
_ibkrMigrations = ibkrMigrations; _logger = services.GetRequiredService<LoggingService>();
_modules = modules; _workerEngine = services.GetRequiredService<WorkerEngine>();
_windows = windows; _modules = services.GetServices<IModule>().ToList();
_services = services;
Text = "IBKRTrader — Launcher";
Width = 720;
Height = 540;
StartPosition = FormStartPosition.CenterScreen;
_uiHost.SetMainWindow(this);
BuildUi();
_uiHost.OpenStateChanged += UpdateButtonStates;
} }
// ─── Form-Events ────────────────────────────────────────────────────────── private void BuildUi()
{
var menu = new MenuStrip { Dock = DockStyle.Top, ImageScalingSize = new Size(24, 24) };
WindowMenu.Wire(menu, _uiHost, null);
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
var id = view.Id;
var btn = new Button
{
Text = view.Title, Width = 210, Height = 48, Margin = new Padding(8),
TextAlign = ContentAlignment.MiddleLeft, Image = view.Icon,
ImageAlign = ContentAlignment.MiddleLeft, TextImageRelation = TextImageRelation.ImageBeforeText
};
btn.Click += (_, _) => _uiHost.OpenView(id);
_viewButtons[id] = btn;
_buttonPanel.Controls.Add(btn);
}
var statusStrip = new StatusStrip();
statusStrip.Items.Add(_status);
// Reihenfolge: Fill-Panel zuerst, dann Bottom, dann Top (WinForms Dock-Stacking).
Controls.Add(_buttonPanel);
Controls.Add(statusStrip);
Controls.Add(menu);
MainMenuStrip = menu;
}
protected override void OnLoad(EventArgs e) protected override void OnLoad(EventArgs e)
{ {
base.OnLoad(e); base.OnLoad(e);
InitializeLogPanel();
InitializeWorkerList();
InitializeSettingsGrid();
InitializeModulePanel();
_ = StartupAsync(); _ = StartupAsync();
} }
protected override void OnFormClosing(FormClosingEventArgs e) protected override void OnFormClosing(FormClosingEventArgs e)
{ {
base.OnFormClosing(e); // Auch das Schließen-X läuft über die Sicherheitsabfrage.
// Offene Modul-Fenster schließen, dann Worker sauber beenden. if (!_uiHost.ShutdownConfirmed)
_windows.CloseAll();
_workerEngine.StopAllAsync().GetAwaiter().GetResult();
}
// ─── Initialisierung ──────────────────────────────────────────────────────
private void InitializeLogPanel()
{
// RichTextBox auf Dock.Fill setzen
rtb_logs.Dock = DockStyle.Fill;
_logPanel = new LogPanelController(rtb_logs, _logger);
// LogLevel aus Settings setzen
var levelStr = _settings.Settings.Logging.Level;
if (Enum.TryParse<AppLogLevel>(levelStr, true, out var level))
_logger.SetMinLevel(level);
}
private void InitializeWorkerList()
{
WorkerListBindingSource.Setup(dgv_workerlist, _workerEngine.WorkerInfos);
}
private void InitializeSettingsGrid()
{
pg_settings.SelectedObject = _settings.Settings;
}
/// <summary>Baut je Modul eine Karte mit „Fenster öffnen"-Button.</summary>
private void InitializeModulePanel()
{
flp_modules.FlowDirection = FlowDirection.LeftToRight;
flp_modules.WrapContents = true;
flp_modules.Controls.Clear();
foreach (var module in _modules.Modules)
flp_modules.Controls.Add(BuildModuleCard(module));
}
private Control BuildModuleCard(IModule module)
{
var card = new Panel
{ {
Width = 320, e.Cancel = true;
Height = 150, BeginInvoke((Action)(() => _uiHost.RequestShutdown()));
Margin = new Padding(8), return;
BorderStyle = BorderStyle.FixedSingle }
};
var title = new Label _uiHost.CloseAllViews();
{
Text = $"{module.DisplayName} [{module.Key}]",
Font = new Font(Font.FontFamily, 11f, FontStyle.Bold),
Location = new Point(10, 10),
AutoSize = true
};
var desc = new Label
{
Text = module.Description,
Location = new Point(10, 42),
Size = new Size(300, 60),
AutoEllipsis = true
};
var open = new Button
{
Text = "Fenster öffnen",
Location = new Point(10, 108),
Width = 140,
Tag = module
};
open.Click += (_, _) => OpenModuleWindow(module);
var version = new Label
{
Text = $"v{module.Version}",
Location = new Point(240, 113),
AutoSize = true,
ForeColor = SystemColors.GrayText
};
card.Controls.Add(title);
card.Controls.Add(desc);
card.Controls.Add(open);
card.Controls.Add(version);
return card;
}
/// <summary>Öffnet (oder fokussiert) das eigenständige Fenster eines Moduls.</summary>
private void OpenModuleWindow(IModule module)
{
try try
{ {
_windows.OpenOrFocus(module.Key, () => module.CreateWindow(_services)); foreach (var module in _modules)
module.StopAsync(default).GetAwaiter().GetResult();
} }
catch (Exception ex) catch { /* Shutdown darf nicht am Modul scheitern */ }
{ _workerEngine.StopAllAsync().GetAwaiter().GetResult();
_logger.Error(module.Key, $"{module.DisplayName}: Fenster konnte nicht geöffnet werden.", ex);
MessageBox.Show(this,
$"Fenster für '{module.DisplayName}' konnte nicht geöffnet werden:\n{ex.Message}",
"Modul-Fenster", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// ─── Async Startup ──────────────────────────────────────────────────────── base.OnFormClosing(e);
}
private async Task StartupAsync() private async Task StartupAsync()
{ {
_logger.Info("Core", "=== IBKRTrader startet ==="); _logger.Info("Core", "=== IBKRTrader startet ===");
_logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}"); _logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}");
// DB-Verbindung testen und Core-Migrationen laufen lassen // Log-Level aus Settings.
var levelStr = _services.GetRequiredService<SettingsService>().Settings.Logging.Level;
if (Enum.TryParse<AppLogLevel>(levelStr, true, out var level))
_logger.SetMinLevel(level);
SetStatus("Migrationen...");
try try
{ {
_logger.Info("Core", "Verbinde mit Datenbank..."); await _services.GetRequiredService<CoreMigrations>().RunAsync();
await _migrations.RunAsync(); await _services.GetRequiredService<IBKRMigrations>().RunAsync();
await _ibkrMigrations.RunAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Error("Core", "Core-Datenbankfehler beim Start.", ex); _logger.Error("Core", "Core-Datenbankfehler beim Start.", ex);
} }
// Modul-Migrationen: über die Registry iterieren (Core kennt kein Modul). foreach (var module in _modules)
foreach (var module in _modules.Modules)
{ {
try try { await module.StartAsync(default); }
{ catch (Exception ex) { _logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
await module.InitializeAsync(_services);
}
catch (Exception ex)
{
_logger.Error(module.Key, $"{module.DisplayName}: Initialisierung fehlgeschlagen.", ex);
}
} }
// Worker starten
await _workerEngine.StartAllAsync(); await _workerEngine.StartAllAsync();
_logger.Info("Core", "IBKRTrader bereit."); _logger.Info("Core", "IBKRTrader bereit.");
UpdateStatusBar("Bereit"); SetStatus("Bereit");
UpdateButtonStates();
} }
// ─── StatusBar ──────────────────────────────────────────────────────────── private void UpdateButtonStates()
private void UpdateStatusBar(string text)
{ {
if (statusStrip1.InvokeRequired) if (IsDisposed) return;
statusStrip1.BeginInvoke(() => UpdateStatusBar(text)); if (InvokeRequired) { BeginInvoke((Action)UpdateButtonStates); return; }
else foreach (var (id, btn) in _viewButtons)
{ btn.Font = new Font(btn.Font, _uiHost.IsOpen(id) ? FontStyle.Bold : FontStyle.Regular);
// Vorhandenes Label nutzen oder neues anlegen }
if (statusStrip1.Items.Count == 0)
statusStrip1.Items.Add(new ToolStripStatusLabel()); private void SetStatus(string text)
statusStrip1.Items[0].Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}"; {
} if (IsDisposed) return;
if (InvokeRequired) { BeginInvoke((Action)(() => SetStatus(text))); return; }
_status.Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}";
} }
} }
-150
View File
@@ -1,150 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<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>
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>175, 17</value>
</metadata>
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>320, 17</value>
</metadata>
<metadata name="toolStrip3.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>625, 17</value>
</metadata>
<metadata name="toolStrip2.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>480, 17</value>
</metadata>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="btn_activateTrading.Image" type="System.Drawing.Bitmap, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAEBSURBVEhL3ZIvC8JAGIf3OTTZ/A62+RXs2g2rFsGi2P0C
WlwT02wGMQjLhgVRnAMR/5bVV37CHeduf9TdFQfPDt7wPOy9GWEYkk4MvGzbJsuylAInD2BgmqZS4JQC
99tDCcoCl+OBPKdH3qxHe3eiPuAOTFq0CxwWURYQ5QBfojfgKA5sl0Mux7pwJx8H/NOdmvPj64yKs8gM
QFqdBlQc7V/nt5HUwDq4cTnj20hiYLW7Unnsv8l/icQGKrVGojwrgpk4lwL1VpdKw40kjCMaYfclzqVA
3z1LojSYTPwZ2Bxrzh1gsujPALBmrDt3IA2sG2vXFgBw/lFAJwh0dGLofp4xWrfOyeSKuAAAAABJRU5E
rkJggg==
</value>
</data>
<metadata name="toolStrip3.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>625, 17</value>
</metadata>
</root>
+132 -56
View File
@@ -4,107 +4,183 @@ using IBKRTrader.Core.Database;
using IBKRTrader.Core.Database.Migrations; using IBKRTrader.Core.Database.Migrations;
using IBKRTrader.Core.IBKR; using IBKRTrader.Core.IBKR;
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modules; using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Settings; using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading; using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers;
using IBKRTrader.Core.Workers.BuiltIn; using IBKRTrader.Core.Workers.BuiltIn;
using IBKRTrader.Modules.CongressTrading; using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.UI; using IBKRTrader.UI;
using IBKRTrader.UI.Views;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace IBKRTrader; namespace IBKRTrader;
internal static class Program internal static class Program
{ {
public static IHost? AppHost { get; private set; }
[STAThread] [STAThread]
static void Main() 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;
}
ApplicationConfiguration.Initialize(); ApplicationConfiguration.Initialize();
// ── DI-Container aufbauen ──────────────────────────────────────────── var modules = new List<IModule> { new CongressTradingModule() };
var services = new ServiceCollection();
// Core: Settings (zuerst laden) AppHost = Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
.ConfigureAppConfiguration((_, config) =>
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
.ConfigureServices((context, services) =>
{
RegisterCoreServices(services);
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();
// Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine-Referenz.
AppHost.Services.GetRequiredService<WebApiService>()
.SetEngine(AppHost.Services.GetRequiredService<WorkerEngine>());
// 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);
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)
{
// Settings zuerst laden (eine Quelle, als Singleton weitergereicht).
var settingsService = new SettingsService(); var settingsService = new SettingsService();
settingsService.Load(); settingsService.Load();
services.AddSingleton(settingsService); services.AddSingleton(settingsService);
// Core: Logging
services.AddSingleton<LoggingService>(); services.AddSingleton<LoggingService>();
// Core: Database
services.AddSingleton<DatabaseService>(); services.AddSingleton<DatabaseService>();
services.AddSingleton<CoreMigrations>(); services.AddSingleton<CoreMigrations>();
services.AddSingleton<IBKRMigrations>(); services.AddSingleton<IBKRMigrations>();
// Core: IBKR Services
services.AddSingleton<IBKRGatewayService>(); services.AddSingleton<IBKRGatewayService>();
services.AddSingleton<IBKRMarketDataRepository>(); services.AddSingleton<IBKRMarketDataRepository>();
// Core: Other Services
services.AddSingleton<BudgetService>(); services.AddSingleton<BudgetService>();
services.AddSingleton<TradeHistoryService>(); services.AddSingleton<TradeHistoryService>();
services.AddSingleton<AIModelService>(); services.AddSingleton<AIModelService>();
// Core: Trading-Kern // Trading-Kern
services.AddSingleton<IRiskService, RiskService>(); services.AddSingleton<IRiskService, RiskService>();
services.AddSingleton<IPortfolioService, PortfolioService>(); services.AddSingleton<IPortfolioService, PortfolioService>();
services.AddSingleton<IExecutionService, ExecutionService>(); services.AddSingleton<IExecutionService, ExecutionService>();
// Sicherer Standard-Broker: handelt nicht, bis der echte IBKR-Adapter // Sicherer Standard-Broker: handelt nicht, bis der echte IBKR-Adapter verifiziert ist.
// angebunden und gegen den Paper-Gateway verifiziert ist.
services.AddSingleton<IBrokerClient, NullBrokerClient>(); services.AddSingleton<IBrokerClient, NullBrokerClient>();
// Core: Built-In Worker // Core-Worker/Services
services.AddSingleton<BackupWorker>(); services.AddSingleton<BackupWorker>();
services.AddSingleton<WebserverService>(); services.AddSingleton<WebserverService>();
services.AddSingleton<WebApiService>(); services.AddSingleton<WebApiService>();
services.AddSingleton<IBKRInstrumentSyncWorker>(); services.AddSingleton<IBKRInstrumentSyncWorker>();
services.AddSingleton<IBKRPriceHistoryWorker>(); services.AddSingleton<IBKRPriceHistoryWorker>();
// ── Module registrieren ────────────────────────────────────────────── // Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable<IWorker>.
// Der Core kennt konkrete Module nicht: Module werden hier instanziiert, services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>());
// hängen ihre Services über IModule.RegisterServices ein und werden über services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebserverService>());
// die ModuleRegistry zugänglich gemacht. services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>());
IModule[] modules = services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
[ services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
new CongressTradingModule(),
];
foreach (var module in modules)
module.RegisterServices(services);
var moduleRegistry = new ModuleRegistry(modules);
services.AddSingleton(moduleRegistry);
// ── IWorker-Sammlung: Core + Module ──────────────────────────────────
services.AddSingleton<IEnumerable<IWorker>>(sp => new IWorker[]
{
// Core-Worker
sp.GetRequiredService<BackupWorker>(),
sp.GetRequiredService<WebserverService>(),
sp.GetRequiredService<WebApiService>(),
// IBKR Market Data Worker
sp.GetRequiredService<IBKRInstrumentSyncWorker>(),
sp.GetRequiredService<IBKRPriceHistoryWorker>(),
}
// Modul-Worker dynamisch anhängen
.Concat(moduleRegistry.Modules.SelectMany(m => m.GetWorkers(sp)))
.ToArray());
services.AddSingleton<WorkerEngine>(); services.AddSingleton<WorkerEngine>();
// UI: Launcher + Fenster-Verwaltung
services.AddSingleton<WindowManager>();
services.AddSingleton<LauncherForm>();
var provider = services.BuildServiceProvider();
// WebApiService bekommt Engine-Referenz (zirkuläre Abhängigkeit auflösen)
var webApi = provider.GetRequiredService<WebApiService>();
var engine = provider.GetRequiredService<WorkerEngine>();
webApi.SetEngine(engine);
Application.Run(provider.GetRequiredService<LauncherForm>());
} }
}
/// <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.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>
/// 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() };
using var host = Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
.ConfigureAppConfiguration((_, config) =>
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
.ConfigureServices((context, services) =>
{
RegisterCoreServices(services);
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;
}
}
+107
View File
@@ -0,0 +1,107 @@
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();
}
}
+21
View File
@@ -0,0 +1,21 @@
using IBKRTrader.Core.Logging;
namespace IBKRTrader.UI.Views;
/// <summary>Core-View: Live-Log (RichTextBox), an den LoggingService gebunden.</summary>
public sealed class LogsView : Form
{
public LogsView(LoggingService logger)
{
Text = "Logs";
Width = 1000;
Height = 650;
StartPosition = FormStartPosition.CenterScreen;
var rtb = new RichTextBox { Dock = DockStyle.Fill };
Controls.Add(rtb);
// Verbindet den Logger mit der RichTextBox (Theme + AttachRichTextBox).
_ = new LogPanelController(rtb, logger);
}
}
+23
View File
@@ -0,0 +1,23 @@
using IBKRTrader.Core.Settings;
namespace IBKRTrader.UI.Views;
/// <summary>Core-View: Einstellungen (PropertyGrid auf AppSettings) mit Speichern-Button.</summary>
public sealed class SettingsView : Form
{
public SettingsView(SettingsService settings)
{
Text = "Settings";
Width = 820;
Height = 720;
StartPosition = FormStartPosition.CenterScreen;
var grid = new PropertyGrid { Dock = DockStyle.Fill, SelectedObject = settings.Settings };
var save = new Button { Text = "Speichern", Dock = DockStyle.Bottom, Height = 36 };
save.Click += (_, _) => settings.Save();
Controls.Add(grid);
Controls.Add(save);
}
}
+20
View File
@@ -0,0 +1,20 @@
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);
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"Database": {
"MySqlConnectionString": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Warning"
}
}
}
+7 -6
View File
@@ -79,12 +79,13 @@ IBKRTrader.App (WinExe, Root) Generic Host + Shell (Launcher) + C
- [x] `tests/IBKRTrader.Tests` Referenzen auf Core + Modul; Fixture-Pfad angepasst - [x] `tests/IBKRTrader.Tests` Referenzen auf Core + Modul; Fixture-Pfad angepasst
- [x] Neue `.slnx`; ungenutztes HtmlAgilityPack entfernt; Build + **38/38 Tests grün**; App startet - [x] Neue `.slnx`; ungenutztes HtmlAgilityPack entfernt; Build + **38/38 Tests grün**; App startet
### R2 Core-Contracts + Generic Host + Shell-UI ### R2 Core-Contracts + Generic Host + Shell-UI
- [ ] `IModule` (PolytraderSharp-Stil) + `ModuleView` + `IModuleUiHost` + `WindowMenu` in Core - [x] `IModule` (PolytraderSharp-Stil) + `ModuleView` + `IModuleUiHost` + `WindowMenu` in Core
- [ ] `Program.cs``Host.CreateDefaultBuilder`; `IConfiguration` (appsettings.json + appsettings.Local.json) - [x] `Program.cs``Host.CreateDefaultBuilder`; `IConfiguration` (appsettings.json + appsettings.Local.json, gitignored)
- [ ] `ShellUiHost` + `LauncherForm` (View-Buttons + Fenster-Menü); Core-Views registrieren - [x] `ShellUiHost` + `LauncherForm` (View-Buttons + gemeinsames Fenster-Menü); Core-Views (Workers/Logs/Settings) als eigene Fenster
- [ ] CongressTrading auf neuen `IModule`-Vertrag; Module über `IHostedService` - [x] CongressTrading auf neuen `IModule`-Vertrag; Modul-Worker als `IWorker` registriert
- [ ] `--smoke-ui` Headless-Test - [x] `--smoke-ui` Headless-Test (alle Views + Launcher konstruieren) → grün
- [ ] **Offen (R4/R5):** Worker von `WorkerEngine`/`IWorker` auf `IHostedService` umstellen (aktuell noch WorkerEngine)
### R3 Persistenz auf EF Core ### R3 Persistenz auf EF Core
- [ ] `AddCorePersistence` + `CoreDbContext` + Entities + EF-Repos (core_) - [ ] `AddCorePersistence` + `CoreDbContext` + Entities + EF-Repos (core_)
@@ -14,6 +14,7 @@
<PackageReference Include="MySqlConnector" Version="2.4.0" /> <PackageReference Include="MySqlConnector" Version="2.4.0" />
<PackageReference Include="Dapper" Version="2.1.35" /> <PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
</ItemGroup> </ItemGroup>
<!-- Erlaubt dem Testprojekt, interne Helfer zu testen. --> <!-- Erlaubt dem Testprojekt, interne Helfer zu testen. -->
+43
View File
@@ -0,0 +1,43 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Core.Modularity;
/// <summary>
/// Vertrag für ein IBKRTrader-Modul. Module referenzieren nur den Core, niemals einander.
/// Ein Modul kapselt einen eigenständigen Funktionsbereich (z. B. eine Trading-Strategie)
/// mit eigenen Services, Jobs, DB-Objekten sowie eigenen UI-Ansichten.
///
/// Vorbild: PolytraderSharp <c>IPolyTraderModule</c>.
/// </summary>
public interface IModule
{
/// <summary>Eindeutiger Anzeigename des Moduls, z. B. "CongressTrading".</summary>
string Name { get; }
/// <summary>DB-Präfix für die Objekte des Moduls (z. B. "ct_"), damit sich Module nicht überschneiden.</summary>
string DbPrefix { get; }
/// <summary>
/// Registriert Services/Jobs des Moduls im DI-Container. Wird beim App-Start aufgerufen,
/// bevor der Host gebaut wird.
/// </summary>
void RegisterServices(IServiceCollection services, IConfiguration configuration);
/// <summary>
/// Registriert die UI-Ansichten des Moduls bei der Shell (Launcher). Die Shell öffnet jede Ansicht
/// auf Wunsch in einem eigenen Fenster; Module liefern eigene Forms, die Shell bleibt modul-agnostisch.
/// </summary>
void RegisterUi(IModuleUiHost host, IServiceProvider services);
/// <summary>
/// Optionale Vorab-Prüfung, ob das Modul aktiviert werden kann. Gibt <c>null</c> zurück, wenn
/// aktivierbar, sonst einen kurzen Grund (z. B. „IBKR-Gateway nicht konfiguriert").
/// </summary>
string? GetActivationBlocker(IConfiguration configuration) => null;
/// <summary>Wird nach der Core-Initialisierung gestartet (kein Anlaufen gegen leeren State).</summary>
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,57 @@
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.
/// </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>
public string Title { get; init; } = "Fenster";
/// <summary>Optionale Gruppierung (z. B. "Core", "CongressTrading").</summary>
public string? Group { get; init; }
/// <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>Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung).</summary>
public Func<Form> CreateForm { get; init; } = () => new Form();
}
/// <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"/>).
/// </summary>
public interface IModuleUiHost
{
void RegisterView(ModuleView view);
/// <summary>Alle registrierten Ansichten (Core + Module).</summary>
IReadOnlyList<ModuleView> Views { get; }
/// <summary>Ist das Fenster dieser View gerade offen?</summary>
bool IsOpen(string viewId);
/// <summary>Öffnet die View bzw. holt ein bereits offenes Fenster in den Vordergrund.</summary>
void OpenView(string viewId);
/// <summary>Holt das Hauptfenster (Launcher) in den Vordergrund.</summary>
void ActivateMain();
/// <summary>Leitet das (bestätigte) Herunterfahren der App ein. Aus jedem Fenster aufrufbar.</summary>
void RequestShutdown();
/// <summary>Feuert, wenn sich der Offen-Status irgendeiner View ändert.</summary>
event Action? OpenStateChanged;
}
@@ -0,0 +1,79 @@
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);
}
}
}
-44
View File
@@ -1,44 +0,0 @@
using IBKRTrader.Core.Workers;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Core.Modules;
/// <summary>
/// Vertrag für ein eigenständiges Trading-Modul.
///
/// Architektur-Regel: Der Core kennt konkrete Module NICHT. Module hängen sich
/// ausschließlich über diesen Vertrag und den DI-Container ein
/// (Abhängigkeit nur Modul → Core, niemals umgekehrt).
///
/// Lebenszyklus:
/// 1. <see cref="RegisterServices"/> vor dem Bauen des DI-Containers
/// 2. <see cref="InitializeAsync"/> beim App-Start (Migrationen etc.)
/// 3. <see cref="GetWorkers"/> Worker werden der WorkerEngine übergeben
/// 4. <see cref="CreateWindow"/> Launcher öffnet das Modul-Fenster
/// </summary>
public interface IModule
{
/// <summary>Eindeutiges Modul-Kürzel, zugleich DB-Tabellen-Präfix (z. B. "CT").</summary>
string Key { get; }
/// <summary>Anzeigename im Launcher (z. B. "Congress Trading").</summary>
string DisplayName { get; }
/// <summary>Kurzbeschreibung der Strategie für den Launcher.</summary>
string Description { get; }
/// <summary>Modul-Version.</summary>
string Version { get; }
/// <summary>Registriert alle Modul-Services im DI-Container (vor dem Build).</summary>
void RegisterServices(IServiceCollection services);
/// <summary>Führt Modul-Migrationen und einmalige Initialisierung aus (beim Start).</summary>
Task InitializeAsync(IServiceProvider provider);
/// <summary>Liefert die Worker/Services des Moduls für die WorkerEngine.</summary>
IEnumerable<IWorker> GetWorkers(IServiceProvider provider);
/// <summary>Erzeugt das eigenständige Fenster des Moduls (vom Launcher geöffnet).</summary>
Form CreateWindow(IServiceProvider provider);
}
@@ -1,22 +0,0 @@
namespace IBKRTrader.Core.Modules;
/// <summary>
/// Zentrale Registry aller geladenen Module.
/// Wird in Program.cs mit den Modul-Instanzen befüllt und als Singleton
/// bereitgestellt, damit Launcher und Startup-Code über die Module iterieren
/// können, ohne konkrete Modultypen zu kennen.
/// </summary>
public class ModuleRegistry
{
private readonly List<IModule> _modules;
public ModuleRegistry(IEnumerable<IModule> modules)
=> _modules = modules?.ToList() ?? throw new ArgumentNullException(nameof(modules));
/// <summary>Alle registrierten Module in Registrierungsreihenfolge.</summary>
public IReadOnlyList<IModule> Modules => _modules;
/// <summary>Findet ein Modul anhand seines Kürzels (case-insensitive) oder null.</summary>
public IModule? Find(string key)
=> _modules.FirstOrDefault(m => string.Equals(m.Key, key, StringComparison.OrdinalIgnoreCase));
}
-25
View File
@@ -1,25 +0,0 @@
namespace IBKRTrader.UI;
/// <summary>
/// Basisklasse für eigenständige Modul-Fenster.
/// Stellt gemeinsame Grundeinstellungen (Größe, Startposition) bereit und
/// hält den Modul-Key, damit der Launcher/WindowManager das Fenster zuordnen kann.
/// </summary>
public class ModuleFormBase : Form
{
/// <summary>Kürzel des zugehörigen Moduls (z. B. "CT").</summary>
public string ModuleKey { get; }
// Parameterloser Ctor nur für den WinForms-Designer.
protected ModuleFormBase() : this("") { }
protected ModuleFormBase(string moduleKey)
{
ModuleKey = moduleKey;
Width = 900;
Height = 600;
StartPosition = FormStartPosition.CenterScreen;
ShowInTaskbar = true;
MinimumSize = new Size(600, 400);
}
}
-82
View File
@@ -1,82 +0,0 @@
namespace IBKRTrader.UI;
/// <summary>
/// Verwaltet die eigenständigen Modul-Fenster des Launchers.
/// Pro Modul-Key wird höchstens ein Fenster gehalten: erneutes Öffnen
/// fokussiert das bestehende Fenster statt ein zweites zu erzeugen.
///
/// Die tatsächlichen UI-Aktionen (Show/Focus) sind in überschreibbare
/// Methoden gekapselt, damit die Tracking-Logik ohne Message-Loop testbar ist.
/// </summary>
public class WindowManager
{
private readonly Dictionary<string, Form> _open = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Öffnet das Fenster zum Key oder fokussiert ein bereits offenes.</summary>
public Form OpenOrFocus(string key, Func<Form> factory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(factory);
if (_open.TryGetValue(key, out var existing))
{
if (!existing.IsDisposed)
{
Focus(existing);
return existing;
}
_open.Remove(key);
}
var form = factory();
_open[key] = form;
form.FormClosed += OnFormClosed;
Present(form);
return form;
}
/// <summary>True, wenn zum Key aktuell ein nicht-disposetes Fenster offen ist.</summary>
public bool IsOpen(string key)
=> _open.TryGetValue(key, out var f) && !f.IsDisposed;
/// <summary>Anzahl aktuell offener Fenster.</summary>
public int OpenCount => _open.Values.Count(f => !f.IsDisposed);
/// <summary>Schließt alle offenen Fenster (z. B. beim Beenden des Launchers).</summary>
public void CloseAll()
{
foreach (var f in _open.Values.ToList())
{
if (!f.IsDisposed)
{
f.FormClosed -= OnFormClosed;
f.Close();
}
}
_open.Clear();
}
// ─── Überschreibbare UI-Aktionen (für Tests) ──────────────────────────────
/// <summary>Zeigt ein neu erzeugtes Fenster an.</summary>
protected virtual void Present(Form form) => form.Show();
/// <summary>Bringt ein bestehendes Fenster in den Vordergrund.</summary>
protected virtual void Focus(Form form)
{
if (form.WindowState == FormWindowState.Minimized)
form.WindowState = FormWindowState.Normal;
form.Activate();
form.BringToFront();
}
private void OnFormClosed(object? sender, FormClosedEventArgs e)
{
if (sender is not Form f) return;
f.FormClosed -= OnFormClosed;
var entry = _open.FirstOrDefault(p => ReferenceEquals(p.Value, f));
if (entry.Key is not null)
_open.Remove(entry.Key);
}
}
@@ -1,68 +1,68 @@
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modules; using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.CongressTrading.Scraper; using IBKRTrader.Modules.CongressTrading.Scraper;
using IBKRTrader.Modules.CongressTrading.UI; using IBKRTrader.Modules.CongressTrading.UI;
using IBKRTrader.Modules.CongressTrading.Workers; using IBKRTrader.Modules.CongressTrading.Workers;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Modules.CongressTrading; namespace IBKRTrader.Modules.CongressTrading;
/// <summary> /// <summary>
/// CongressTrading-Modul: kopiert Aktien-Trades von US-Kongressmitgliedern /// CongressTrading-Modul: kopiert Aktien-Trades von US-Kongressmitgliedern (capitoltrades.com).
/// (Quelle: capitoltrades.com). /// Hängt sich ausschließlich über den <see cref="IModule"/>-Vertrag ein (Abhängigkeit nur Modul → Core).
///
/// Architektur-Regel: Dieses Modul hängt sich ausschließlich über den
/// <see cref="IModule"/>-Vertrag ein. Der Core kennt dieses Modul NICHT
/// die Abhängigkeit geht nur von Modul → Core.
/// </summary> /// </summary>
public sealed class CongressTradingModule : IModule public sealed class CongressTradingModule : IModule
{ {
public const string ModuleKey = "CT"; /// <summary>Kürzel für Logging/DB-Präfix.</summary>
public const string LogTag = "CT";
public string Key => ModuleKey; public string Name => "CongressTrading";
public string DisplayName => "Congress Trading"; public string DbPrefix => "ct_";
public string Description => "Kopiert Aktien-Trades von US-Kongressmitgliedern (capitoltrades.com).";
public string Version => "1.0.0";
/// <summary>Registriert alle Modul-Services im DI-Container.</summary> // In RegisterUi gesetzt (hat den Provider); für Modul-Migrationen in StartAsync genutzt.
public void RegisterServices(IServiceCollection services) private IServiceProvider? _services;
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
{ {
// Datenbank
services.AddSingleton<CongressMigrations>(); services.AddSingleton<CongressMigrations>();
services.AddSingleton<CongressRepository>(); services.AddSingleton<CongressRepository>();
// Scraper
services.AddSingleton<CapitolTradesScraper>(); services.AddSingleton<CapitolTradesScraper>();
// Worker
services.AddSingleton<CongressHistoryImportWorker>(); services.AddSingleton<CongressHistoryImportWorker>();
services.AddSingleton<CongressScrapeWorker>(); services.AddSingleton<CongressScrapeWorker>();
// Worker als IWorker registrieren → die WorkerEngine erhält sie über IEnumerable<IWorker>.
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<CongressHistoryImportWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<CongressScrapeWorker>());
} }
/// <summary>Führt die Modul-Migrationen aus.</summary> public void RegisterUi(IModuleUiHost host, IServiceProvider services)
public async Task InitializeAsync(IServiceProvider provider)
{ {
var migrations = provider.GetRequiredService<CongressMigrations>(); _services = services;
await migrations.RunAsync();
// Einmaliger Reset (deaktiviert): löscht alten Fortschritt für sauberen Neustart. host.RegisterView(new ModuleView
// var repo = provider.GetRequiredService<CongressRepository>(); {
// await repo.ResetHistoryImportAsync(); Id = "congresstrading.main",
Title = "Congress Trading",
Group = Name,
Order = 100,
CreateForm = () => new CongressTradingForm(
services.GetRequiredService<CongressRepository>(),
services.GetRequiredService<WorkerEngine>(),
services.GetRequiredService<LoggingService>())
});
} }
/// <summary>Gibt die Modul-Worker für die WorkerEngine zurück.</summary> public async Task StartAsync(CancellationToken cancellationToken)
public IEnumerable<IWorker> GetWorkers(IServiceProvider provider) => {
[ // Modul-Migrationen laufen nach der Core-Initialisierung.
provider.GetRequiredService<CongressHistoryImportWorker>(), if (_services is null) return;
provider.GetRequiredService<CongressScrapeWorker>() var migrations = _services.GetRequiredService<CongressMigrations>();
]; await migrations.RunAsync();
}
/// <summary>Erzeugt das eigenständige Modul-Fenster.</summary> public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Form CreateWindow(IServiceProvider provider)
=> new CongressTradingForm(
provider.GetRequiredService<CongressRepository>(),
provider.GetRequiredService<WorkerEngine>(),
provider.GetRequiredService<LoggingService>());
} }
@@ -1,16 +1,15 @@
using IBKRTrader.Core.Logging; using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.UI;
namespace IBKRTrader.Modules.CongressTrading.UI; namespace IBKRTrader.Modules.CongressTrading.UI;
/// <summary> /// <summary>
/// Eigenständiges Fenster des CongressTrading-Moduls. /// Eigenständiges Fenster des CongressTrading-Moduls.
/// Phase 2: Grundgerüst mit DB-Kennzahlen und manuellem Scrape-Trigger. /// Grundgerüst mit DB-Kennzahlen und manuellem Scrape-Trigger.
/// Die vollständige Trade-/Positions-Ansicht folgt in Phase 4. /// Die vollständige Trade-/Positions-Ansicht folgt in einer späteren Phase.
/// </summary> /// </summary>
public sealed class CongressTradingForm : ModuleFormBase public sealed class CongressTradingForm : Form
{ {
private const string ScrapeWorkerName = "CT-ScrapeWorker"; private const string ScrapeWorkerName = "CT-ScrapeWorker";
@@ -25,13 +24,16 @@ public sealed class CongressTradingForm : ModuleFormBase
private readonly Label _lblStatus = new() { AutoSize = true, Location = new Point(20, 185), ForeColor = SystemColors.GrayText }; private readonly Label _lblStatus = new() { AutoSize = true, Location = new Point(20, 185), ForeColor = SystemColors.GrayText };
public CongressTradingForm(CongressRepository repo, WorkerEngine engine, LoggingService logger) public CongressTradingForm(CongressRepository repo, WorkerEngine engine, LoggingService logger)
: base(CongressTradingModule.ModuleKey)
{ {
_repo = repo; _repo = repo;
_engine = engine; _engine = engine;
_logger = logger; _logger = logger;
Text = "Congress Trading [CT]"; Text = "Congress Trading [CT]";
Width = 900;
Height = 600;
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(600, 400);
BuildLayout(); BuildLayout();
} }
@@ -86,7 +88,7 @@ public sealed class CongressTradingForm : ModuleFormBase
_lblTrades.Text = "Trades in DB: n/v"; _lblTrades.Text = "Trades in DB: n/v";
_lblMembers.Text = "Mitglieder in DB: n/v"; _lblMembers.Text = "Mitglieder in DB: n/v";
_lblStatus.Text = $"DB nicht erreichbar: {ex.Message}"; _lblStatus.Text = $"DB nicht erreichbar: {ex.Message}";
_logger.Warn(CongressTradingModule.ModuleKey, $"Kennzahlen konnten nicht geladen werden: {ex.Message}"); _logger.Warn(CongressTradingModule.LogTag, $"Kennzahlen konnten nicht geladen werden: {ex.Message}");
} }
} }
@@ -96,12 +98,12 @@ public sealed class CongressTradingForm : ModuleFormBase
{ {
_lblStatus.Text = "Scrape angestoßen..."; _lblStatus.Text = "Scrape angestoßen...";
await _engine.TriggerWorkerAsync(ScrapeWorkerName); await _engine.TriggerWorkerAsync(ScrapeWorkerName);
_logger.Info(CongressTradingModule.ModuleKey, "Scrape-Worker manuell ausgelöst (aus Modul-Fenster)."); _logger.Info(CongressTradingModule.LogTag, "Scrape-Worker manuell ausgelöst (aus Modul-Fenster).");
} }
catch (Exception ex) catch (Exception ex)
{ {
_lblStatus.Text = $"Scrape fehlgeschlagen: {ex.Message}"; _lblStatus.Text = $"Scrape fehlgeschlagen: {ex.Message}";
_logger.Error(CongressTradingModule.ModuleKey, "Manueller Scrape-Trigger fehlgeschlagen.", ex); _logger.Error(CongressTradingModule.LogTag, "Manueller Scrape-Trigger fehlgeschlagen.", ex);
} }
} }
} }
@@ -1,39 +1,49 @@
using FluentAssertions; using FluentAssertions;
using IBKRTrader.Core.Database; using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.CongressTrading; using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.CongressTrading.Scraper; using IBKRTrader.Modules.CongressTrading.Scraper;
using IBKRTrader.Modules.CongressTrading.Workers; using IBKRTrader.Modules.CongressTrading.Workers;
using IBKRTrader.UI; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace IBKRTrader.Tests.Modules; namespace IBKRTrader.Tests.Modules;
[Trait("cat", "unit")] [Trait("cat", "unit")]
public class CongressTradingModuleTests public class CongressTradingModuleTests
{ {
private sealed class CapturingUiHost : IModuleUiHost
{
private readonly List<ModuleView> _views = new();
public IReadOnlyList<ModuleView> Views => _views;
public void RegisterView(ModuleView view) => _views.Add(view);
public bool IsOpen(string viewId) => false;
public void OpenView(string viewId) { }
public void ActivateMain() { }
public void RequestShutdown() { }
public event Action? OpenStateChanged { add { } remove { } }
}
[Fact] [Fact]
public void Metadata_IsAsExpected() public void Metadata_IsAsExpected()
{ {
var module = new CongressTradingModule(); var module = new CongressTradingModule();
module.Key.Should().Be("CT"); module.Name.Should().Be("CongressTrading");
module.DisplayName.Should().NotBeNullOrWhiteSpace(); module.DbPrefix.Should().Be("ct_");
module.Version.Should().Be("1.0.0");
} }
[Fact] [Fact]
public void RegisterServices_RegistersAllModuleServices() public void RegisterServices_RegistersServicesAndWorkers()
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
new CongressTradingModule().RegisterServices(services); new CongressTradingModule().RegisterServices(services, Substitute.For<IConfiguration>());
var registered = services.Select(d => d.ServiceType).ToList(); var types = services.Select(d => d.ServiceType).ToList();
registered.Should().Contain(new[] types.Should().Contain(new[]
{ {
typeof(CongressMigrations), typeof(CongressMigrations),
typeof(CongressRepository), typeof(CongressRepository),
@@ -41,25 +51,29 @@ public class CongressTradingModuleTests
typeof(CongressHistoryImportWorker), typeof(CongressHistoryImportWorker),
typeof(CongressScrapeWorker), typeof(CongressScrapeWorker),
}); });
// Worker werden zusätzlich als IWorker registriert (für die WorkerEngine).
services.Count(d => d.ServiceType == typeof(IWorker)).Should().Be(2);
} }
[Fact] [Fact]
public void CreateWindow_ReturnsModuleForm() public void RegisterUi_RegistersMainView()
{ {
// Minimaler DI-Container mit allen von CreateWindow benötigten Abhängigkeiten. var host = new CapturingUiHost();
var services = new ServiceCollection(); // CreateForm wird hier NICHT aufgerufen daher genügt ein leerer Provider.
services.AddSingleton<SettingsService>(); var provider = new ServiceCollection().BuildServiceProvider();
services.AddSingleton<LoggingService>();
services.AddSingleton<DatabaseService>();
services.AddSingleton<IEnumerable<IWorker>>(_ => Array.Empty<IWorker>());
services.AddSingleton<WorkerEngine>();
var module = new CongressTradingModule();
module.RegisterServices(services);
using var provider = services.BuildServiceProvider(); new CongressTradingModule().RegisterUi(host, provider);
using var form = module.CreateWindow(provider);
form.Should().BeAssignableTo<ModuleFormBase>(); host.Views.Should().ContainSingle();
((ModuleFormBase)form).ModuleKey.Should().Be("CT"); host.Views[0].Id.Should().Be("congresstrading.main");
host.Views[0].Title.Should().Be("Congress Trading");
}
[Fact]
public void GetActivationBlocker_DefaultsToNull()
{
// Default-Interface-Methode → über die Interface-Referenz aufrufen.
IModule module = new CongressTradingModule();
module.GetActivationBlocker(Substitute.For<IConfiguration>()).Should().BeNull();
} }
} }
@@ -1,49 +0,0 @@
using FluentAssertions;
using IBKRTrader.Core.Modules;
using IBKRTrader.Core.Workers;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Tests.Modules;
[Trait("cat", "unit")]
public class ModuleRegistryTests
{
private sealed class FakeModule(string key) : IModule
{
public string Key => key;
public string DisplayName => $"Fake {key}";
public string Description => "test";
public string Version => "0.0.1";
public void RegisterServices(IServiceCollection services) { }
public Task InitializeAsync(IServiceProvider provider) => Task.CompletedTask;
public IEnumerable<IWorker> GetWorkers(IServiceProvider provider) => [];
public Form CreateWindow(IServiceProvider provider) => new();
}
[Fact]
public void Modules_PreservesRegistrationOrder()
{
var registry = new ModuleRegistry([new FakeModule("A"), new FakeModule("B")]);
registry.Modules.Select(m => m.Key).Should().ContainInOrder("A", "B");
}
[Fact]
public void Find_IsCaseInsensitive()
{
var registry = new ModuleRegistry([new FakeModule("CT")]);
registry.Find("ct").Should().NotBeNull();
registry.Find("CT").Should().NotBeNull();
registry.Find("XX").Should().BeNull();
}
[Fact]
public void Ctor_NullModules_Throws()
{
var act = () => new ModuleRegistry(null!);
act.Should().Throw<ArgumentNullException>();
}
}
@@ -1,100 +0,0 @@
using FluentAssertions;
using IBKRTrader.UI;
namespace IBKRTrader.Tests.UI;
[Trait("cat", "unit")]
public class WindowManagerTests
{
/// <summary>
/// Testbare Variante: unterdrückt echte Show-/Focus-Aufrufe (kein Message-Loop),
/// zählt aber die Focus-Aufrufe.
/// </summary>
private sealed class TestWindowManager : WindowManager
{
public int FocusCount;
protected override void Present(Form form) { /* kein Show im Test */ }
protected override void Focus(Form form) => FocusCount++;
}
[Fact]
public void OpenOrFocus_NewKey_InvokesFactory_AndTracks()
{
var wm = new TestWindowManager();
var created = 0;
using var form = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
created.Should().Be(1);
wm.IsOpen("CT").Should().BeTrue();
wm.OpenCount.Should().Be(1);
}
[Fact]
public void OpenOrFocus_SameKey_FocusesExisting_DoesNotRecreate()
{
var wm = new TestWindowManager();
var created = 0;
using var first = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
var second = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
created.Should().Be(1);
wm.FocusCount.Should().Be(1);
second.Should().BeSameAs(first);
wm.OpenCount.Should().Be(1);
}
[Fact]
public void OpenOrFocus_DifferentKeys_TracksSeparately()
{
var wm = new TestWindowManager();
using var a = wm.OpenOrFocus("A", () => new Form());
using var b = wm.OpenOrFocus("B", () => new Form());
wm.OpenCount.Should().Be(2);
wm.IsOpen("A").Should().BeTrue();
wm.IsOpen("B").Should().BeTrue();
}
[Fact]
public void OpenOrFocus_AfterDisposed_RecreatesWindow()
{
var wm = new TestWindowManager();
var created = 0;
var first = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
first.Dispose();
wm.IsOpen("CT").Should().BeFalse();
using var second = wm.OpenOrFocus("CT", () => { created++; return new Form(); });
created.Should().Be(2);
second.Should().NotBeSameAs(first);
}
[Fact]
public void CloseAll_RemovesAllWindows()
{
var wm = new TestWindowManager();
wm.OpenOrFocus("A", () => new Form());
wm.OpenOrFocus("B", () => new Form());
wm.CloseAll();
wm.OpenCount.Should().Be(0);
wm.IsOpen("A").Should().BeFalse();
}
[Fact]
public void OpenOrFocus_BlankKey_Throws()
{
var wm = new TestWindowManager();
var act = () => wm.OpenOrFocus(" ", () => new Form());
act.Should().Throw<ArgumentException>();
}
}