diff --git a/.gitignore b/.gitignore index 456afe4..ac6f044 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ $RECYCLE.BIN/ ## Application data (do NOT commit secrets or runtime data) settings.json +appsettings.Local.json Backups/ Logs/ *.log diff --git a/IBKRTrader.App.csproj b/IBKRTrader.App.csproj index 4af190a..3cae826 100644 --- a/IBKRTrader.App.csproj +++ b/IBKRTrader.App.csproj @@ -24,6 +24,7 @@ + @@ -35,6 +36,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + diff --git a/LauncherForm.Designer.cs b/LauncherForm.Designer.cs deleted file mode 100644 index a04fdb8..0000000 --- a/LauncherForm.Designer.cs +++ /dev/null @@ -1,274 +0,0 @@ -namespace IBKRTrader -{ - partial class LauncherForm - { - /// - /// Required designer variable. - /// - private System.ComponentModel.IContainer components = null; - - /// - /// Clean up any resources being used. - /// - /// true if managed resources should be disposed; otherwise, false. - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Windows Form Designer generated code - - /// - /// Required method for Designer support - do not modify - /// the contents of this method with the code editor. - /// - 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; - } -} diff --git a/LauncherForm.cs b/LauncherForm.cs index d3a0f2e..2136846 100644 --- a/LauncherForm.cs +++ b/LauncherForm.cs @@ -1,224 +1,155 @@ using IBKRTrader.Core.Database.Migrations; using IBKRTrader.Core.Logging; -using IBKRTrader.Core.Modules; +using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Workers; using IBKRTrader.UI; +using Microsoft.Extensions.DependencyInjection; namespace IBKRTrader; /// -/// Launcher – das Basis-Fenster der Anwendung. -/// Enthält die Core-Panels (Workers, Logs, Settings) und eine Modul-Liste, -/// aus der jedes Modul als eigenständiges Fenster geöffnet wird. +/// 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. /// -public partial class LauncherForm : Form +public sealed class LauncherForm : Form { - private readonly LoggingService _logger; - private readonly WorkerEngine _workerEngine; - private readonly SettingsService _settings; - private readonly CoreMigrations _migrations; - private readonly IBKRMigrations _ibkrMigrations; - private readonly ModuleRegistry _modules; - private readonly WindowManager _windows; - private readonly IServiceProvider _services; + private readonly ShellUiHost _uiHost; + private readonly IServiceProvider _services; + private readonly LoggingService _logger; + private readonly WorkerEngine _workerEngine; + private readonly IReadOnlyList _modules; - private LogPanelController? _logPanel; - - public LauncherForm( - LoggingService logger, - WorkerEngine workerEngine, - SettingsService settings, - CoreMigrations migrations, - IBKRMigrations ibkrMigrations, - ModuleRegistry modules, - WindowManager windows, - IServiceProvider services) + private readonly Dictionary _viewButtons = new(StringComparer.OrdinalIgnoreCase); + private readonly FlowLayoutPanel _buttonPanel = new() { - InitializeComponent(); + Dock = DockStyle.Fill, Padding = new Padding(16), AutoScroll = true + }; + private readonly ToolStripStatusLabel _status = new("Start..."); - _logger = logger; - _workerEngine = workerEngine; - _settings = settings; - _migrations = migrations; - _ibkrMigrations = ibkrMigrations; - _modules = modules; - _windows = windows; - _services = services; + public LauncherForm(ShellUiHost uiHost, IServiceProvider services) + { + _uiHost = uiHost; + _services = services; + _logger = services.GetRequiredService(); + _workerEngine = services.GetRequiredService(); + _modules = services.GetServices().ToList(); + + 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) { base.OnLoad(e); - InitializeLogPanel(); - InitializeWorkerList(); - InitializeSettingsGrid(); - InitializeModulePanel(); _ = StartupAsync(); } protected override void OnFormClosing(FormClosingEventArgs e) { - base.OnFormClosing(e); - // Offene Modul-Fenster schließen, dann Worker sauber beenden. - _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(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; - } - - /// Baut je Modul eine Karte mit „Fenster öffnen"-Button. - 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 + // Auch das Schließen-X läuft über die Sicherheitsabfrage. + if (!_uiHost.ShutdownConfirmed) { - Width = 320, - Height = 150, - Margin = new Padding(8), - BorderStyle = BorderStyle.FixedSingle - }; + e.Cancel = true; + BeginInvoke((Action)(() => _uiHost.RequestShutdown())); + return; + } - var title = new Label - { - 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; - } - - /// Öffnet (oder fokussiert) das eigenständige Fenster eines Moduls. - private void OpenModuleWindow(IModule module) - { + _uiHost.CloseAllViews(); try { - _windows.OpenOrFocus(module.Key, () => module.CreateWindow(_services)); + foreach (var module in _modules) + module.StopAsync(default).GetAwaiter().GetResult(); } - catch (Exception ex) - { - _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); - } - } + catch { /* Shutdown darf nicht am Modul scheitern */ } + _workerEngine.StopAllAsync().GetAwaiter().GetResult(); - // ─── Async Startup ──────────────────────────────────────────────────────── + base.OnFormClosing(e); + } private async Task StartupAsync() { _logger.Info("Core", "=== IBKRTrader startet ==="); _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().Settings.Logging.Level; + if (Enum.TryParse(levelStr, true, out var level)) + _logger.SetMinLevel(level); + + SetStatus("Migrationen..."); try { - _logger.Info("Core", "Verbinde mit Datenbank..."); - await _migrations.RunAsync(); - await _ibkrMigrations.RunAsync(); + await _services.GetRequiredService().RunAsync(); + await _services.GetRequiredService().RunAsync(); } catch (Exception ex) { _logger.Error("Core", "Core-Datenbankfehler beim Start.", ex); } - // Modul-Migrationen: über die Registry iterieren (Core kennt kein Modul). - foreach (var module in _modules.Modules) + foreach (var module in _modules) { - try - { - await module.InitializeAsync(_services); - } - catch (Exception ex) - { - _logger.Error(module.Key, $"{module.DisplayName}: Initialisierung fehlgeschlagen.", ex); - } + try { await module.StartAsync(default); } + catch (Exception ex) { _logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); } } - // Worker starten await _workerEngine.StartAllAsync(); _logger.Info("Core", "IBKRTrader bereit."); - UpdateStatusBar("Bereit"); + SetStatus("Bereit"); + UpdateButtonStates(); } - // ─── StatusBar ──────────────────────────────────────────────────────────── - - private void UpdateStatusBar(string text) + private void UpdateButtonStates() { - if (statusStrip1.InvokeRequired) - statusStrip1.BeginInvoke(() => UpdateStatusBar(text)); - else - { - // Vorhandenes Label nutzen oder neues anlegen - if (statusStrip1.Items.Count == 0) - statusStrip1.Items.Add(new ToolStripStatusLabel()); - statusStrip1.Items[0].Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}"; - } + if (IsDisposed) return; + if (InvokeRequired) { BeginInvoke((Action)UpdateButtonStates); return; } + foreach (var (id, btn) in _viewButtons) + btn.Font = new Font(btn.Font, _uiHost.IsOpen(id) ? FontStyle.Bold : FontStyle.Regular); + } + + private void SetStatus(string text) + { + if (IsDisposed) return; + if (InvokeRequired) { BeginInvoke((Action)(() => SetStatus(text))); return; } + _status.Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}"; } } diff --git a/LauncherForm.resx b/LauncherForm.resx deleted file mode 100644 index d136883..0000000 --- a/LauncherForm.resx +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - 17, 17 - - - 175, 17 - - - 320, 17 - - - 625, 17 - - - 480, 17 - - - - - iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8 - YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAEBSURBVEhL3ZIvC8JAGIf3OTTZ/A62+RXs2g2rFsGi2P0C - WlwT02wGMQjLhgVRnAMR/5bVV37CHeduf9TdFQfPDt7wPOy9GWEYkk4MvGzbJsuylAInD2BgmqZS4JQC - 99tDCcoCl+OBPKdH3qxHe3eiPuAOTFq0CxwWURYQ5QBfojfgKA5sl0Mux7pwJx8H/NOdmvPj64yKs8gM - QFqdBlQc7V/nt5HUwDq4cTnj20hiYLW7Unnsv8l/icQGKrVGojwrgpk4lwL1VpdKw40kjCMaYfclzqVA - 3z1LojSYTPwZ2Bxrzh1gsujPALBmrDt3IA2sG2vXFgBw/lFAJwh0dGLofp4xWrfOyeSKuAAAAABJRU5E - rkJggg== - - - - 625, 17 - - \ No newline at end of file diff --git a/Program.cs b/Program.cs index b0047dd..f66da72 100644 --- a/Program.cs +++ b/Program.cs @@ -4,107 +4,183 @@ using IBKRTrader.Core.Database; using IBKRTrader.Core.Database.Migrations; using IBKRTrader.Core.IBKR; using IBKRTrader.Core.Logging; -using IBKRTrader.Core.Modules; +using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Trading; using IBKRTrader.Core.Workers; using IBKRTrader.Core.Workers.BuiltIn; using IBKRTrader.Modules.CongressTrading; 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() + 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(); - // ── DI-Container aufbauen ──────────────────────────────────────────── - var services = new ServiceCollection(); + var modules = new List { new CongressTradingModule() }; - // 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(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + }) + .Build(); + + // Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine-Referenz. + AppHost.Services.GetRequiredService() + .SetEngine(AppHost.Services.GetRequiredService()); + + // Views registrieren (Core + Module), dann Launcher starten. + var uiHost = AppHost.Services.GetRequiredService(); + RegisterCoreViews(uiHost, AppHost.Services); + foreach (var module in modules) + module.RegisterUi(uiHost, AppHost.Services); + + Application.Run(AppHost.Services.GetRequiredService()); + AppHost.StopAsync().GetAwaiter().GetResult(); + } + + /// Registriert alle Core-Services im DI-Container. + private static void RegisterCoreServices(IServiceCollection services) + { + // Settings zuerst laden (eine Quelle, als Singleton weitergereicht). var settingsService = new SettingsService(); settingsService.Load(); services.AddSingleton(settingsService); - // Core: Logging services.AddSingleton(); - // Core: Database services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - // Core: IBKR Services services.AddSingleton(); services.AddSingleton(); - // Core: Other Services services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - // Core: Trading-Kern + // Trading-Kern services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - // Sicherer Standard-Broker: handelt nicht, bis der echte IBKR-Adapter - // angebunden und gegen den Paper-Gateway verifiziert ist. + // Sicherer Standard-Broker: handelt nicht, bis der echte IBKR-Adapter verifiziert ist. services.AddSingleton(); - // Core: Built-In Worker + // Core-Worker/Services services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - // ── Module registrieren ────────────────────────────────────────────── - // Der Core kennt konkrete Module nicht: Module werden hier instanziiert, - // hängen ihre Services über IModule.RegisterServices ein und werden über - // die ModuleRegistry zugänglich gemacht. - IModule[] modules = - [ - new CongressTradingModule(), - ]; - - foreach (var module in modules) - module.RegisterServices(services); - - var moduleRegistry = new ModuleRegistry(modules); - services.AddSingleton(moduleRegistry); - - // ── IWorker-Sammlung: Core + Module ────────────────────────────────── - services.AddSingleton>(sp => new IWorker[] - { - // Core-Worker - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - // IBKR Market Data Worker - sp.GetRequiredService(), - sp.GetRequiredService(), - } - // Modul-Worker dynamisch anhängen - .Concat(moduleRegistry.Modules.SelectMany(m => m.GetWorkers(sp))) - .ToArray()); + // Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable. + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); - - // UI: Launcher + Fenster-Verwaltung - services.AddSingleton(); - services.AddSingleton(); - - var provider = services.BuildServiceProvider(); - - // WebApiService bekommt Engine-Referenz (zirkuläre Abhängigkeit auflösen) - var webApi = provider.GetRequiredService(); - var engine = provider.GetRequiredService(); - webApi.SetEngine(engine); - - Application.Run(provider.GetRequiredService()); } -} \ No newline at end of file + + /// Registriert die Core-Views (Logs, Settings, Workers) bei der Shell. + 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()) + }); + uiHost.RegisterView(new ModuleView + { + Id = "core.logs", Title = "Logs", Group = "Core", Order = 20, + CreateForm = () => new LogsView(sp.GetRequiredService()) + }); + uiHost.RegisterView(new ModuleView + { + Id = "core.settings", Title = "Settings", Group = "Core", Order = 30, + CreateForm = () => new SettingsView(sp.GetRequiredService()) + }); + } + + /// + /// 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. + /// + private static int RunSmokeUi() + { + ApplicationConfiguration.Initialize(); + + var modules = new List { 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(); + services.AddSingleton(s => s.GetRequiredService()); + services.AddSingleton(); + }) + .Build(); + + host.Services.GetRequiredService() + .SetEngine(host.Services.GetRequiredService()); + + var uiHost = host.Services.GetRequiredService(); + 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(); 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; + } +} diff --git a/UI/ShellUiHost.cs b/UI/ShellUiHost.cs new file mode 100644 index 0000000..d436f31 --- /dev/null +++ b/UI/ShellUiHost.cs @@ -0,0 +1,107 @@ +using IBKRTrader.Core.Modularity; + +namespace IBKRTrader.UI; + +/// +/// 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 ShellUiHost. +/// +public sealed class ShellUiHost : IModuleUiHost +{ + private readonly List _views = new(); + private readonly Dictionary _open = new(StringComparer.OrdinalIgnoreCase); + private Form? _mainWindow; + + /// True, sobald das Beenden bestätigt wurde (der Launcher wertet das in FormClosing aus). + public bool ShutdownConfirmed { get; private set; } + + public event Action? OpenStateChanged; + + public IReadOnlyList Views => _views; + + public void RegisterView(ModuleView view) => _views.Add(view); + + /// Setzt das Hauptfenster (Launcher) – Ziel für . + 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(); + } + + /// Sicherheitsabfrage; bei Bestätigung wird die Message-Loop beendet. + 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; + } + + /// Schließt alle offenen View-Fenster (beim Herunterfahren). + public void CloseAllViews() + { + foreach (var form in _open.Values.ToList()) + if (!form.IsDisposed) + form.Close(); + _open.Clear(); + } +} diff --git a/UI/Views/LogsView.cs b/UI/Views/LogsView.cs new file mode 100644 index 0000000..adb7f14 --- /dev/null +++ b/UI/Views/LogsView.cs @@ -0,0 +1,21 @@ +using IBKRTrader.Core.Logging; + +namespace IBKRTrader.UI.Views; + +/// Core-View: Live-Log (RichTextBox), an den LoggingService gebunden. +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); + } +} diff --git a/UI/Views/SettingsView.cs b/UI/Views/SettingsView.cs new file mode 100644 index 0000000..427dc7e --- /dev/null +++ b/UI/Views/SettingsView.cs @@ -0,0 +1,23 @@ +using IBKRTrader.Core.Settings; + +namespace IBKRTrader.UI.Views; + +/// Core-View: Einstellungen (PropertyGrid auf AppSettings) mit Speichern-Button. +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); + } +} diff --git a/UI/Views/WorkersView.cs b/UI/Views/WorkersView.cs new file mode 100644 index 0000000..1113c58 --- /dev/null +++ b/UI/Views/WorkersView.cs @@ -0,0 +1,20 @@ +using IBKRTrader.Core.Workers; + +namespace IBKRTrader.UI.Views; + +/// Core-View: Worker/Services-Übersicht (DataGridView, live an die WorkerEngine gebunden). +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); + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..d09a23c --- /dev/null +++ b/appsettings.json @@ -0,0 +1,11 @@ +{ + "Database": { + "MySqlConnectionString": "" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Warning" + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5c272e7..f7773dc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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] Neue `.slnx`; ungenutztes HtmlAgilityPack entfernt; Build + **38/38 Tests grün**; App startet -### R2 – Core-Contracts + Generic Host + Shell-UI -- [ ] `IModule` (PolytraderSharp-Stil) + `ModuleView` + `IModuleUiHost` + `WindowMenu` in Core -- [ ] `Program.cs` → `Host.CreateDefaultBuilder`; `IConfiguration` (appsettings.json + appsettings.Local.json) -- [ ] `ShellUiHost` + `LauncherForm` (View-Buttons + Fenster-Menü); Core-Views registrieren -- [ ] CongressTrading auf neuen `IModule`-Vertrag; Module über `IHostedService` -- [ ] `--smoke-ui` Headless-Test +### R2 – Core-Contracts + Generic Host + Shell-UI ✅ +- [x] `IModule` (PolytraderSharp-Stil) + `ModuleView` + `IModuleUiHost` + `WindowMenu` in Core +- [x] `Program.cs` → `Host.CreateDefaultBuilder`; `IConfiguration` (appsettings.json + appsettings.Local.json, gitignored) +- [x] `ShellUiHost` + `LauncherForm` (View-Buttons + gemeinsames Fenster-Menü); Core-Views (Workers/Logs/Settings) als eigene Fenster +- [x] CongressTrading auf neuen `IModule`-Vertrag; Modul-Worker als `IWorker` registriert +- [x] `--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 - [ ] `AddCorePersistence` + `CoreDbContext` + Entities + EF-Repos (core_) diff --git a/src/IBKRTrader.Core/IBKRTrader.Core.csproj b/src/IBKRTrader.Core/IBKRTrader.Core.csproj index 24b7fb4..80374ad 100644 --- a/src/IBKRTrader.Core/IBKRTrader.Core.csproj +++ b/src/IBKRTrader.Core/IBKRTrader.Core.csproj @@ -14,6 +14,7 @@ + diff --git a/src/IBKRTrader.Core/Modularity/IModule.cs b/src/IBKRTrader.Core/Modularity/IModule.cs new file mode 100644 index 0000000..19b7d23 --- /dev/null +++ b/src/IBKRTrader.Core/Modularity/IModule.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace IBKRTrader.Core.Modularity; + +/// +/// 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 IPolyTraderModule. +/// +public interface IModule +{ + /// Eindeutiger Anzeigename des Moduls, z. B. "CongressTrading". + string Name { get; } + + /// DB-Präfix für die Objekte des Moduls (z. B. "ct_"), damit sich Module nicht überschneiden. + string DbPrefix { get; } + + /// + /// Registriert Services/Jobs des Moduls im DI-Container. Wird beim App-Start aufgerufen, + /// bevor der Host gebaut wird. + /// + void RegisterServices(IServiceCollection services, IConfiguration configuration); + + /// + /// 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. + /// + void RegisterUi(IModuleUiHost host, IServiceProvider services); + + /// + /// Optionale Vorab-Prüfung, ob das Modul aktiviert werden kann. Gibt null zurück, wenn + /// aktivierbar, sonst einen kurzen Grund (z. B. „IBKR-Gateway nicht konfiguriert"). + /// + string? GetActivationBlocker(IConfiguration configuration) => null; + + /// Wird nach der Core-Initialisierung gestartet (kein Anlaufen gegen leeren State). + Task StartAsync(CancellationToken cancellationToken); + + Task StopAsync(CancellationToken cancellationToken); +} diff --git a/src/IBKRTrader.Core/Modularity/ModuleView.cs b/src/IBKRTrader.Core/Modularity/ModuleView.cs new file mode 100644 index 0000000..5340ab8 --- /dev/null +++ b/src/IBKRTrader.Core/Modularity/ModuleView.cs @@ -0,0 +1,57 @@ +using System.Windows.Forms; + +namespace IBKRTrader.Core.Modularity; + +/// +/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die eigentliche UI ist ein +/// , das über erzeugt wird (mit DI-Abhängigkeiten). +/// Die Shell zeigt je View höchstens eine Instanz und holt ein offenes Fenster wieder nach vorne. +/// +public sealed class ModuleView +{ + /// Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View). + public string Id { get; init; } = Guid.NewGuid().ToString(); + + /// Titel (Fallback-Fenstertitel, falls das Form keinen eigenen setzt). + public string Title { get; init; } = "Fenster"; + + /// Optionale Gruppierung (z. B. "Core", "CongressTrading"). + public string? Group { get; init; } + + /// Optionale Sortierreihenfolge in Menü/Buttons. + public int Order { get; init; } = 0; + + /// Optionales Icon für Menü/Buttons. + public System.Drawing.Image? Icon { get; set; } + + /// Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung). + public Func
CreateForm { get; init; } = () => new Form(); +} + +/// +/// 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 ). +/// +public interface IModuleUiHost +{ + void RegisterView(ModuleView view); + + /// Alle registrierten Ansichten (Core + Module). + IReadOnlyList Views { get; } + + /// Ist das Fenster dieser View gerade offen? + bool IsOpen(string viewId); + + /// Öffnet die View bzw. holt ein bereits offenes Fenster in den Vordergrund. + void OpenView(string viewId); + + /// Holt das Hauptfenster (Launcher) in den Vordergrund. + void ActivateMain(); + + /// Leitet das (bestätigte) Herunterfahren der App ein. Aus jedem Fenster aufrufbar. + void RequestShutdown(); + + /// Feuert, wenn sich der Offen-Status irgendeiner View ändert. + event Action? OpenStateChanged; +} diff --git a/src/IBKRTrader.Core/Modularity/WindowMenu.cs b/src/IBKRTrader.Core/Modularity/WindowMenu.cs new file mode 100644 index 0000000..628d4d8 --- /dev/null +++ b/src/IBKRTrader.Core/Modularity/WindowMenu.cs @@ -0,0 +1,79 @@ +using System.Drawing; +using System.Windows.Forms; + +namespace IBKRTrader.Core.Modularity; + +/// +/// 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 +/// nutzt, funktioniert es auch aus Modul-Fenstern (die die App nicht kennen). +/// +public static class WindowMenu +{ + /// + /// Verdrahtet einen 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. + /// + 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; + } + + /// Baut die Menüleiste komplett neu auf (Launcher, alle Views nebeneinander, Aktion rechts). + 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); + } + } +} diff --git a/src/IBKRTrader.Core/Modules/IModule.cs b/src/IBKRTrader.Core/Modules/IModule.cs deleted file mode 100644 index cbcdc9c..0000000 --- a/src/IBKRTrader.Core/Modules/IModule.cs +++ /dev/null @@ -1,44 +0,0 @@ -using IBKRTrader.Core.Workers; -using Microsoft.Extensions.DependencyInjection; - -namespace IBKRTrader.Core.Modules; - -/// -/// 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. – vor dem Bauen des DI-Containers -/// 2. – beim App-Start (Migrationen etc.) -/// 3. – Worker werden der WorkerEngine übergeben -/// 4. – Launcher öffnet das Modul-Fenster -/// -public interface IModule -{ - /// Eindeutiges Modul-Kürzel, zugleich DB-Tabellen-Präfix (z. B. "CT"). - string Key { get; } - - /// Anzeigename im Launcher (z. B. "Congress Trading"). - string DisplayName { get; } - - /// Kurzbeschreibung der Strategie für den Launcher. - string Description { get; } - - /// Modul-Version. - string Version { get; } - - /// Registriert alle Modul-Services im DI-Container (vor dem Build). - void RegisterServices(IServiceCollection services); - - /// Führt Modul-Migrationen und einmalige Initialisierung aus (beim Start). - Task InitializeAsync(IServiceProvider provider); - - /// Liefert die Worker/Services des Moduls für die WorkerEngine. - IEnumerable GetWorkers(IServiceProvider provider); - - /// Erzeugt das eigenständige Fenster des Moduls (vom Launcher geöffnet). - Form CreateWindow(IServiceProvider provider); -} diff --git a/src/IBKRTrader.Core/Modules/ModuleRegistry.cs b/src/IBKRTrader.Core/Modules/ModuleRegistry.cs deleted file mode 100644 index df1ea50..0000000 --- a/src/IBKRTrader.Core/Modules/ModuleRegistry.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace IBKRTrader.Core.Modules; - -/// -/// 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. -/// -public class ModuleRegistry -{ - private readonly List _modules; - - public ModuleRegistry(IEnumerable modules) - => _modules = modules?.ToList() ?? throw new ArgumentNullException(nameof(modules)); - - /// Alle registrierten Module in Registrierungsreihenfolge. - public IReadOnlyList Modules => _modules; - - /// Findet ein Modul anhand seines Kürzels (case-insensitive) oder null. - public IModule? Find(string key) - => _modules.FirstOrDefault(m => string.Equals(m.Key, key, StringComparison.OrdinalIgnoreCase)); -} diff --git a/src/IBKRTrader.Core/Ui/ModuleFormBase.cs b/src/IBKRTrader.Core/Ui/ModuleFormBase.cs deleted file mode 100644 index e453768..0000000 --- a/src/IBKRTrader.Core/Ui/ModuleFormBase.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace IBKRTrader.UI; - -/// -/// 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. -/// -public class ModuleFormBase : Form -{ - /// Kürzel des zugehörigen Moduls (z. B. "CT"). - 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); - } -} diff --git a/src/IBKRTrader.Core/Ui/WindowManager.cs b/src/IBKRTrader.Core/Ui/WindowManager.cs deleted file mode 100644 index a6d8940..0000000 --- a/src/IBKRTrader.Core/Ui/WindowManager.cs +++ /dev/null @@ -1,82 +0,0 @@ -namespace IBKRTrader.UI; - -/// -/// 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. -/// -public class WindowManager -{ - private readonly Dictionary _open = new(StringComparer.OrdinalIgnoreCase); - - /// Öffnet das Fenster zum Key oder fokussiert ein bereits offenes. - public Form OpenOrFocus(string key, Func 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; - } - - /// True, wenn zum Key aktuell ein nicht-disposetes Fenster offen ist. - public bool IsOpen(string key) - => _open.TryGetValue(key, out var f) && !f.IsDisposed; - - /// Anzahl aktuell offener Fenster. - public int OpenCount => _open.Values.Count(f => !f.IsDisposed); - - /// Schließt alle offenen Fenster (z. B. beim Beenden des Launchers). - 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) ────────────────────────────── - - /// Zeigt ein neu erzeugtes Fenster an. - protected virtual void Present(Form form) => form.Show(); - - /// Bringt ein bestehendes Fenster in den Vordergrund. - 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); - } -} diff --git a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs index cbdb0ba..5da0d59 100644 --- a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs +++ b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs @@ -1,68 +1,68 @@ using IBKRTrader.Core.Logging; -using IBKRTrader.Core.Modules; +using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Workers; using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Scraper; using IBKRTrader.Modules.CongressTrading.UI; using IBKRTrader.Modules.CongressTrading.Workers; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace IBKRTrader.Modules.CongressTrading; /// -/// CongressTrading-Modul: kopiert Aktien-Trades von US-Kongressmitgliedern -/// (Quelle: capitoltrades.com). -/// -/// Architektur-Regel: Dieses Modul hängt sich ausschließlich über den -/// -Vertrag ein. Der Core kennt dieses Modul NICHT – -/// die Abhängigkeit geht nur von Modul → Core. +/// CongressTrading-Modul: kopiert Aktien-Trades von US-Kongressmitgliedern (capitoltrades.com). +/// Hängt sich ausschließlich über den -Vertrag ein (Abhängigkeit nur Modul → Core). /// public sealed class CongressTradingModule : IModule { - public const string ModuleKey = "CT"; + /// Kürzel für Logging/DB-Präfix. + public const string LogTag = "CT"; - public string Key => ModuleKey; - public string DisplayName => "Congress Trading"; - public string Description => "Kopiert Aktien-Trades von US-Kongressmitgliedern (capitoltrades.com)."; - public string Version => "1.0.0"; + public string Name => "CongressTrading"; + public string DbPrefix => "ct_"; - /// Registriert alle Modul-Services im DI-Container. - public void RegisterServices(IServiceCollection services) + // In RegisterUi gesetzt (hat den Provider); für Modul-Migrationen in StartAsync genutzt. + private IServiceProvider? _services; + + public void RegisterServices(IServiceCollection services, IConfiguration configuration) { - // Datenbank services.AddSingleton(); services.AddSingleton(); - - // Scraper services.AddSingleton(); - // Worker services.AddSingleton(); services.AddSingleton(); + + // Worker als IWorker registrieren → die WorkerEngine erhält sie über IEnumerable. + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); } - /// Führt die Modul-Migrationen aus. - public async Task InitializeAsync(IServiceProvider provider) + public void RegisterUi(IModuleUiHost host, IServiceProvider services) { - var migrations = provider.GetRequiredService(); - await migrations.RunAsync(); + _services = services; - // Einmaliger Reset (deaktiviert): löscht alten Fortschritt für sauberen Neustart. - // var repo = provider.GetRequiredService(); - // await repo.ResetHistoryImportAsync(); + host.RegisterView(new ModuleView + { + Id = "congresstrading.main", + Title = "Congress Trading", + Group = Name, + Order = 100, + CreateForm = () => new CongressTradingForm( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()) + }); } - /// Gibt die Modul-Worker für die WorkerEngine zurück. - public IEnumerable GetWorkers(IServiceProvider provider) => - [ - provider.GetRequiredService(), - provider.GetRequiredService() - ]; + public async Task StartAsync(CancellationToken cancellationToken) + { + // Modul-Migrationen laufen nach der Core-Initialisierung. + if (_services is null) return; + var migrations = _services.GetRequiredService(); + await migrations.RunAsync(); + } - /// Erzeugt das eigenständige Modul-Fenster. - public Form CreateWindow(IServiceProvider provider) - => new CongressTradingForm( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService()); + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/IBKRTrader.Modules.CongressTrading/UI/CongressTradingForm.cs b/src/IBKRTrader.Modules.CongressTrading/UI/CongressTradingForm.cs index 908ec7f..8350d58 100644 --- a/src/IBKRTrader.Modules.CongressTrading/UI/CongressTradingForm.cs +++ b/src/IBKRTrader.Modules.CongressTrading/UI/CongressTradingForm.cs @@ -1,16 +1,15 @@ using IBKRTrader.Core.Logging; using IBKRTrader.Core.Workers; using IBKRTrader.Modules.CongressTrading.Database; -using IBKRTrader.UI; namespace IBKRTrader.Modules.CongressTrading.UI; /// /// Eigenständiges Fenster des CongressTrading-Moduls. -/// Phase 2: Grundgerüst mit DB-Kennzahlen und manuellem Scrape-Trigger. -/// Die vollständige Trade-/Positions-Ansicht folgt in Phase 4. +/// Grundgerüst mit DB-Kennzahlen und manuellem Scrape-Trigger. +/// Die vollständige Trade-/Positions-Ansicht folgt in einer späteren Phase. /// -public sealed class CongressTradingForm : ModuleFormBase +public sealed class CongressTradingForm : Form { 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 }; public CongressTradingForm(CongressRepository repo, WorkerEngine engine, LoggingService logger) - : base(CongressTradingModule.ModuleKey) { _repo = repo; _engine = engine; _logger = logger; - Text = "Congress Trading [CT]"; + Text = "Congress Trading [CT]"; + Width = 900; + Height = 600; + StartPosition = FormStartPosition.CenterScreen; + MinimumSize = new Size(600, 400); BuildLayout(); } @@ -86,7 +88,7 @@ public sealed class CongressTradingForm : ModuleFormBase _lblTrades.Text = "Trades in DB: n/v"; _lblMembers.Text = "Mitglieder in DB: n/v"; _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..."; 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) { _lblStatus.Text = $"Scrape fehlgeschlagen: {ex.Message}"; - _logger.Error(CongressTradingModule.ModuleKey, "Manueller Scrape-Trigger fehlgeschlagen.", ex); + _logger.Error(CongressTradingModule.LogTag, "Manueller Scrape-Trigger fehlgeschlagen.", ex); } } } diff --git a/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs b/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs index 2821764..bd6f39d 100644 --- a/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs +++ b/tests/IBKRTrader.Tests/Modules/CongressTradingModuleTests.cs @@ -1,39 +1,49 @@ using FluentAssertions; -using IBKRTrader.Core.Database; -using IBKRTrader.Core.Logging; -using IBKRTrader.Core.Settings; +using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Workers; using IBKRTrader.Modules.CongressTrading; using IBKRTrader.Modules.CongressTrading.Database; using IBKRTrader.Modules.CongressTrading.Scraper; using IBKRTrader.Modules.CongressTrading.Workers; -using IBKRTrader.UI; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; namespace IBKRTrader.Tests.Modules; [Trait("cat", "unit")] public class CongressTradingModuleTests { + private sealed class CapturingUiHost : IModuleUiHost + { + private readonly List _views = new(); + public IReadOnlyList 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] public void Metadata_IsAsExpected() { var module = new CongressTradingModule(); - module.Key.Should().Be("CT"); - module.DisplayName.Should().NotBeNullOrWhiteSpace(); - module.Version.Should().Be("1.0.0"); + module.Name.Should().Be("CongressTrading"); + module.DbPrefix.Should().Be("ct_"); } [Fact] - public void RegisterServices_RegistersAllModuleServices() + public void RegisterServices_RegistersServicesAndWorkers() { var services = new ServiceCollection(); - new CongressTradingModule().RegisterServices(services); + new CongressTradingModule().RegisterServices(services, Substitute.For()); - var registered = services.Select(d => d.ServiceType).ToList(); - registered.Should().Contain(new[] + var types = services.Select(d => d.ServiceType).ToList(); + types.Should().Contain(new[] { typeof(CongressMigrations), typeof(CongressRepository), @@ -41,25 +51,29 @@ public class CongressTradingModuleTests typeof(CongressHistoryImportWorker), typeof(CongressScrapeWorker), }); + // Worker werden zusätzlich als IWorker registriert (für die WorkerEngine). + services.Count(d => d.ServiceType == typeof(IWorker)).Should().Be(2); } [Fact] - public void CreateWindow_ReturnsModuleForm() + public void RegisterUi_RegistersMainView() { - // Minimaler DI-Container mit allen von CreateWindow benötigten Abhängigkeiten. - var services = new ServiceCollection(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton>(_ => Array.Empty()); - services.AddSingleton(); - var module = new CongressTradingModule(); - module.RegisterServices(services); + var host = new CapturingUiHost(); + // CreateForm wird hier NICHT aufgerufen – daher genügt ein leerer Provider. + var provider = new ServiceCollection().BuildServiceProvider(); - using var provider = services.BuildServiceProvider(); - using var form = module.CreateWindow(provider); + new CongressTradingModule().RegisterUi(host, provider); - form.Should().BeAssignableTo(); - ((ModuleFormBase)form).ModuleKey.Should().Be("CT"); + host.Views.Should().ContainSingle(); + 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()).Should().BeNull(); } } diff --git a/tests/IBKRTrader.Tests/Modules/ModuleRegistryTests.cs b/tests/IBKRTrader.Tests/Modules/ModuleRegistryTests.cs deleted file mode 100644 index e53c085..0000000 --- a/tests/IBKRTrader.Tests/Modules/ModuleRegistryTests.cs +++ /dev/null @@ -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 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(); - } -} diff --git a/tests/IBKRTrader.Tests/UI/WindowManagerTests.cs b/tests/IBKRTrader.Tests/UI/WindowManagerTests.cs deleted file mode 100644 index b05a685..0000000 --- a/tests/IBKRTrader.Tests/UI/WindowManagerTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using FluentAssertions; -using IBKRTrader.UI; - -namespace IBKRTrader.Tests.UI; - -[Trait("cat", "unit")] -public class WindowManagerTests -{ - /// - /// Testbare Variante: unterdrückt echte Show-/Focus-Aufrufe (kein Message-Loop), - /// zählt aber die Focus-Aufrufe. - /// - 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(); - } -}