Wir betreiben Instanzen in zwei Regionen. Bisher hing jede Ortszeit an der Zeitzone des Rechners (DateTime.Now, DateTimeKind.Local): derselbe Code haette auf einem Windows-Desktop mit Europe/Berlin und in einem Linux-Container mit UTC lautlos unterschiedliche Werte geliefert - ohne Fehler, nur um Stunden verschoben, mitten in Buchungszeitstempeln. AppTimeZone (Core/Time): Betriebszeitzone der Instanz, einmalig aus Trading.ApplicationTimeZoneId gesetzt, IANA- und Windows-Schreibweise tragen beide, unbekannter Wert weicht auf die Systemzone aus und warnt. Wird laut Festlegung vor den ersten Trades gesetzt und danach nie gewechselt - ein Wechsel verschoebe rueckwirkend alle Tagesgrenzen. Persistenz bleibt UTC, damit die Daten beider Instanzen vergleichbar sind. IbkrMapping.ParseExecutionTime verwirft die von TWS gemeldete Zeitzone nicht mehr, sondern rechnet gegen sie nach UTC; ohne Zonenangabe gilt die Betriebszeitzone. Das ist der Kern: eine NYSE-Ausfuehrung darf nicht mit demselben nackten Zeitwert in die Buecher wie eine an der Eurex. Rueckgabe ist jetzt immer Kind=Utc. DailyReportService.NextRun -> NextRunUtc(nowUtc, hour, zone): der Bericht laeuft zu einer festen ORTSZEIT. Sommerzeitumstellung wird behandelt - bei der uebersprungenen Stunde weicht er aus, statt den Tag ausfallen zu lassen. LoggingService fuehrt Anzeigezeit und UTC getrennt: Dateinamen und Anzeige in Ortszeit (Tagesgrenzen gehoeren zur Instanz, der Supervisor liest die JSONL-Dateien ueber diese Namen), das ts-Feld im JSONL in UTC. Beides musste getrennt werden, weil die umgerechnete Ortszeit Kind=Unspecified traegt und ein ToUniversalTime() darauf sie als Zeit des HOSTS gedeutet haette. Verbleibende DateTime.Now in Worker-Zeitplaenen und Statuszeilen ebenfalls auf AppTimeZone.Now umgestellt. Verifiziert: 183 Tests gruen (+20), darunter EU/US-Versatz, Winter-/Sommerzeit, unbekannte Zone und die uebersprungene Stunde bei der Zeitumstellung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
6.0 KiB
C#
164 lines
6.0 KiB
C#
using IBKRTrader.Core.Time;
|
||
using IBKRTrader.Core.Logging;
|
||
using IBKRTrader.Core.Modularity;
|
||
using IBKRTrader.Core.Settings;
|
||
using IBKRTrader.Core.Workers;
|
||
using IBKRTrader.UI;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
|
||
namespace IBKRTrader;
|
||
|
||
/// <summary>
|
||
/// Launcher – das Basis-Fenster (Shell). Zeigt je registrierter View einen Button, führt beim Start
|
||
/// Migrationen aus, startet Module und die WorkerEngine, und trägt das gemeinsame Fenster-Menü.
|
||
/// Die inhaltlichen Ansichten (Logs, Settings, Workers, Module) sind eigenständige Fenster.
|
||
/// </summary>
|
||
public sealed class LauncherForm : Form
|
||
{
|
||
private readonly ShellUiHost _uiHost;
|
||
private readonly IServiceProvider _services;
|
||
private readonly LoggingService _logger;
|
||
private readonly WorkerEngine _workerEngine;
|
||
private readonly IReadOnlyList<IModule> _modules;
|
||
|
||
private readonly Dictionary<string, ToolStripButton> _viewButtons = new(StringComparer.OrdinalIgnoreCase);
|
||
private readonly ToolStripStatusLabel _status = new("Start...");
|
||
|
||
public LauncherForm(ShellUiHost uiHost, IServiceProvider services)
|
||
{
|
||
_uiHost = uiHost;
|
||
_services = services;
|
||
_logger = services.GetRequiredService<LoggingService>();
|
||
_workerEngine = services.GetRequiredService<WorkerEngine>();
|
||
_modules = services.GetServices<IModule>().ToList();
|
||
|
||
Text = "IBKRTrader — Launcher";
|
||
Width = 720;
|
||
Height = 540;
|
||
StartPosition = FormStartPosition.CenterScreen;
|
||
|
||
_uiHost.SetMainWindow(this);
|
||
|
||
BuildUi();
|
||
_uiHost.OpenStateChanged += UpdateButtonStates;
|
||
}
|
||
|
||
private void BuildUi()
|
||
{
|
||
var menu = new MenuStrip { Dock = DockStyle.Top, ImageScalingSize = new Size(24, 24) };
|
||
WindowMenu.Wire(menu, _uiHost, null);
|
||
|
||
// Fenster-Buttons in einem ToolStrip (Icon über Text) – wie im PolytraderSharp-Launcher.
|
||
var toolstrip = new ToolStrip
|
||
{
|
||
Dock = DockStyle.Top,
|
||
GripStyle = ToolStripGripStyle.Hidden,
|
||
ImageScalingSize = new Size(32, 32),
|
||
AutoSize = true,
|
||
Padding = new Padding(4)
|
||
};
|
||
|
||
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||
{
|
||
var id = view.Id;
|
||
var btn = new ToolStripButton(view.Title, ViewIcons.Resolve(view.IconKey))
|
||
{
|
||
DisplayStyle = ToolStripItemDisplayStyle.ImageAndText,
|
||
ImageScaling = ToolStripItemImageScaling.None,
|
||
TextImageRelation = TextImageRelation.ImageAboveText,
|
||
AutoSize = true,
|
||
Padding = new Padding(6, 2, 6, 2)
|
||
};
|
||
btn.Click += (_, _) => _uiHost.OpenView(id);
|
||
_viewButtons[id] = btn;
|
||
toolstrip.Items.Add(btn);
|
||
}
|
||
|
||
var content = new Panel { Dock = DockStyle.Fill, BackColor = SystemColors.ControlLightLight };
|
||
content.Controls.Add(new Label
|
||
{
|
||
Text = "IBKRTrader — Launcher\nFenster über die Leiste oben öffnen.",
|
||
Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleCenter,
|
||
ForeColor = SystemColors.GrayText, Font = new Font(Font.FontFamily, 11f)
|
||
});
|
||
|
||
var statusStrip = new StatusStrip();
|
||
statusStrip.Items.Add(_status);
|
||
|
||
// Dock-Stacking: zuletzt hinzugefügtes Top-Control liegt oben → Menü über ToolStrip.
|
||
Controls.Add(content);
|
||
Controls.Add(statusStrip);
|
||
Controls.Add(toolstrip);
|
||
Controls.Add(menu);
|
||
MainMenuStrip = menu;
|
||
}
|
||
|
||
protected override void OnLoad(EventArgs e)
|
||
{
|
||
base.OnLoad(e);
|
||
_ = StartupAsync();
|
||
}
|
||
|
||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||
{
|
||
// Auch das Schließen-X läuft über die Sicherheitsabfrage.
|
||
if (!_uiHost.ShutdownConfirmed)
|
||
{
|
||
e.Cancel = true;
|
||
BeginInvoke((Action)(() => _uiHost.RequestShutdown()));
|
||
return;
|
||
}
|
||
|
||
_uiHost.CloseAllViews();
|
||
try
|
||
{
|
||
foreach (var module in _modules)
|
||
module.StopAsync(default).GetAwaiter().GetResult();
|
||
}
|
||
catch { /* Shutdown darf nicht am Modul scheitern */ }
|
||
// Worker/Services stoppt der Host in Program.Main via AppHost.StopAsync() nach Application.Run.
|
||
|
||
base.OnFormClosing(e);
|
||
}
|
||
|
||
private async Task StartupAsync()
|
||
{
|
||
_logger.Info("Core", "=== IBKRTrader startet ===");
|
||
_logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}");
|
||
|
||
// Log-Level aus Settings.
|
||
var levelStr = _services.GetRequiredService<SettingsService>().Settings.Logging.Level;
|
||
if (Enum.TryParse<AppLogLevel>(levelStr, true, out var level))
|
||
_logger.SetMinLevel(level);
|
||
|
||
// Das gesamte Schema (core_ + ct_) läuft über EF-Migrationen, extern via
|
||
// `dotnet ef database update` angewendet – keine Laufzeit-Migration mehr.
|
||
|
||
// Worker/Services laufen bereits (Generic Host, AppHost.Start()). Hier nur noch Modul-Start.
|
||
foreach (var module in _modules)
|
||
{
|
||
try { await module.StartAsync(default); }
|
||
catch (Exception ex) { _logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
|
||
}
|
||
|
||
_logger.Info("Core", "IBKRTrader bereit.");
|
||
SetStatus("Bereit");
|
||
UpdateButtonStates();
|
||
}
|
||
|
||
private void UpdateButtonStates()
|
||
{
|
||
if (IsDisposed) return;
|
||
if (InvokeRequired) { BeginInvoke((Action)UpdateButtonStates); return; }
|
||
foreach (var (id, btn) in _viewButtons)
|
||
btn.Checked = _uiHost.IsOpen(id);
|
||
}
|
||
|
||
private void SetStatus(string text)
|
||
{
|
||
if (IsDisposed) return;
|
||
if (InvokeRequired) { BeginInvoke((Action)(() => SetStatus(text))); return; }
|
||
_status.Text = $"Status: {text} | {AppTimeZone.Now:HH:mm:ss}";
|
||
}
|
||
}
|