diff --git a/LauncherForm.cs b/LauncherForm.cs index 0354360..6af25b7 100644 --- a/LauncherForm.cs +++ b/LauncherForm.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Time; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Settings; @@ -157,6 +158,6 @@ public sealed class LauncherForm : Form { if (IsDisposed) return; if (InvokeRequired) { BeginInvoke((Action)(() => SetStatus(text))); return; } - _status.Text = $"Status: {text} | {DateTime.Now:HH:mm:ss}"; + _status.Text = $"Status: {text} | {AppTimeZone.Now:HH:mm:ss}"; } } diff --git a/Program.cs b/Program.cs index d223fae..767e816 100644 --- a/Program.cs +++ b/Program.cs @@ -9,6 +9,7 @@ using IBKRTrader.Core.Persistence; using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Security; using IBKRTrader.Core.Settings; +using IBKRTrader.Core.Time; using IBKRTrader.Core.Trading; using IBKRTrader.Core.Trading.Ibkr; using IBKRTrader.Core.Workers; @@ -67,8 +68,12 @@ internal static class Program }) .Build(); - // Sicherheit: Master-Key laden (VOR jeder Entschlüsselung) und DB-TLS prüfen. + // Betriebszeitzone VOR dem ersten Logeintrag setzen – sie bestimmt die Tagesgrenzen der + // Logdateien. Wird sie später gesetzt, landen die ersten Zeilen in der falschen Datei. var startupLog = AppHost.Services.GetRequiredService(); + ConfigureAppTimeZone(AppHost.Services, startupLog); + + // Sicherheit: Master-Key laden (VOR jeder Entschlüsselung) und DB-TLS prüfen. ConfigureSecretProtection(startupLog); WarnIfDbTlsNotEnforced(AppHost.Services, startupLog); @@ -194,6 +199,22 @@ internal static class Program }); } + /// + /// Setzt die Betriebszeitzone dieser Instanz aus den Einstellungen. Sie gilt für Anzeige, + /// Logdatei-Tagesgrenzen, Berichtszeiten und Buchungsperioden; gespeichert wird weiterhin UTC. + /// + /// Wir betreiben Instanzen in EU und US. Ohne diese Festlegung hinge die Ortszeit an der + /// Zeitzone des Rechners – auf einem UTC-Container also woanders als auf dem Windows-Desktop, + /// lautlos und ohne Fehlermeldung. Die Zeitzone wird vor den ersten Trades gesetzt und danach + /// nicht mehr gewechselt. + /// + private static void ConfigureAppTimeZone(IServiceProvider services, LoggingService logger) + { + var configured = services.GetRequiredService().Settings.Trading.ApplicationTimeZoneId; + AppTimeZone.Configure(configured, msg => logger.Warn("Core", msg)); + logger.Info("Core", $"Betriebszeitzone: {AppTimeZone.CurrentId} (Persistenz bleibt UTC)."); + } + /// /// Lädt den Master-Key (env IBKRTRADER_MASTER_KEY, sonst gitignorierte master.key) und aktiviert die /// at-rest-Verschlüsselung. Ohne Key läuft die App mit Klartext – mit deutlicher Warnung. @@ -275,6 +296,8 @@ internal static class Program }) .Build(); + ConfigureAppTimeZone(host.Services, host.Services.GetRequiredService()); + host.Services.GetRequiredService() .SetEngine(host.Services.GetRequiredService()); diff --git a/UI/Views/DashboardView.cs b/UI/Views/DashboardView.cs index 401a64c..908985b 100644 --- a/UI/Views/DashboardView.cs +++ b/UI/Views/DashboardView.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Time; using IBKRTrader.Core.Modularity; using IBKRTrader.Core.Settings; using IBKRTrader.Core.Trading; @@ -87,7 +88,7 @@ public sealed class DashboardView : Form var snap = await _dashboard.GetSnapshotAsync(); _lblStats.Text = $"Offene Positionen: {snap.OpenPositions} | Exposure: {snap.TotalExposure:N2} | " + $"Trades gesamt: {snap.TotalTrades} | Worker/Services: {_workerCount}"; - _lblStatus.Text = $"Aktualisiert: {DateTime.Now:HH:mm:ss}"; + _lblStatus.Text = $"Aktualisiert: {AppTimeZone.Now:HH:mm:ss}"; } catch (Exception ex) { diff --git a/UI/Views/Modules/AccountingMainForm.cs b/UI/Views/Modules/AccountingMainForm.cs index f983753..52b9721 100644 --- a/UI/Views/Modules/AccountingMainForm.cs +++ b/UI/Views/Modules/AccountingMainForm.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Time; using IBKRTrader.Core.Logging; using IBKRTrader.Modules.Accounting.Logic; using IBKRTrader.Modules.Accounting.Persistence; @@ -290,7 +291,7 @@ public sealed class AccountingMainForm : Form { _status.Text = backfill ? "Backfill läuft…" : "Inkrementeller Abruf läuft…"; await _ingest.IngestAllAsync(backfill, CancellationToken.None); - _status.Text = $"Abruf abgeschlossen ({DateTime.Now:HH:mm:ss})."; + _status.Text = $"Abruf abgeschlossen ({AppTimeZone.Now:HH:mm:ss})."; LoadRuns(); } catch (Exception ex) diff --git a/UI/Views/Modules/CongressTradingForm.cs b/UI/Views/Modules/CongressTradingForm.cs index 18a96aa..7cf7487 100644 --- a/UI/Views/Modules/CongressTradingForm.cs +++ b/UI/Views/Modules/CongressTradingForm.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Time; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Trading; using IBKRTrader.Core.Workers; @@ -102,7 +103,7 @@ public sealed class CongressTradingForm : Form .Select(p => new { p.Symbol, Stück = p.Quantity, Ø_Kurs = p.AvgPrice, Wert = p.Notional }) .ToList(); - _lblStatus.Text = $"Aktualisiert: {DateTime.Now:HH:mm:ss}"; + _lblStatus.Text = $"Aktualisiert: {AppTimeZone.Now:HH:mm:ss}"; } catch (Exception ex) { diff --git a/src/IBKRTrader.Core/Logging/LoggingService.cs b/src/IBKRTrader.Core/Logging/LoggingService.cs index 4c6f004..2489aa1 100644 --- a/src/IBKRTrader.Core/Logging/LoggingService.cs +++ b/src/IBKRTrader.Core/Logging/LoggingService.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Time; using System.Runtime.CompilerServices; namespace IBKRTrader.Core.Logging; @@ -58,9 +59,20 @@ public class LoggingService public void Write(AppLogLevel level, string module, string message, Exception? ex = null, string? cid = null) { if (level < _minLevel) return; - var entry = new LogEntry(DateTime.Now, level, module, message, ex); + + // Zwei Zeitformen, bewusst getrennt: + // • entry.Timestamp = Betriebszeitzone → Anzeige und Dateinamen (…-dd-MM-yy.txt, + // {yyyy-MM-dd}.jsonl). Die Tagesgrenzen sollen an der Instanz hängen, nicht am Host – + // ein UTC-Container hätte sonst andere Grenzen als der Windows-Desktop, und der + // Supervisor liest die JSONL-Dateien über genau diese Namen. + // • utc = maschinenlesbares Feld im JSONL. Muss separat mitgeführt werden, weil die + // umgerechnete Ortszeit Kind=Unspecified trägt: ein ToUniversalTime() darauf würde sie + // als Zeit des HOSTS deuten und bei abweichender Rechnerzeitzone falsch verschieben. + var utc = DateTime.UtcNow; + var entry = new LogEntry(AppTimeZone.ToDisplay(utc), level, module, message, ex); + WriteToFile(entry); - WriteToJsonl(entry, cid); + WriteToJsonl(entry, utc, cid); NotifySinks(entry); } @@ -95,15 +107,16 @@ public class LoggingService /// /// Schreibt zusätzlich eine JSON-Zeile nach Logs\{yyyy-MM-dd}.jsonl (Dual-Sink). Zeilenweise /// filter-/parsebar (Datum/Level/Quelle/Text/CorrelationId) – Grundlage für Log Viewer + Supervisor. + /// Dateiname nach Ortszeit (Tagesgrenze), ts-Feld in UTC (maschinenlesbar). /// - private void WriteToJsonl(LogEntry e, string? cid) + private void WriteToJsonl(LogEntry e, DateTime utc, string? cid) { try { Directory.CreateDirectory(LogBaseDir); var file = Path.Combine(LogBaseDir, $"{e.Timestamp:yyyy-MM-dd}.jsonl"); var message = e.Exception != null ? $"{e.Message} | {e.Exception.Message}" : e.Message; - var json = LogJson.WriteLine(e.Timestamp, e.Level, e.Module, message, cid); + var json = LogJson.WriteLine(utc, e.Level, e.Module, message, cid); lock (_jsonlLock) File.AppendAllText(file, json + "\n"); diff --git a/src/IBKRTrader.Core/Settings/AppSettings.cs b/src/IBKRTrader.Core/Settings/AppSettings.cs index c0886a0..71221bc 100644 --- a/src/IBKRTrader.Core/Settings/AppSettings.cs +++ b/src/IBKRTrader.Core/Settings/AppSettings.cs @@ -211,6 +211,15 @@ public class TradingSettings [Description("Handelsmodus: Paper (Test-Account, Port 4002) oder Live (Port 4001)")] public string Mode { get; set; } = "Paper"; + [Category("Trading")] + [DisplayName("Betriebszeitzone")] + [Description("Zeitzone dieser Instanz in IANA-Schreibweise, z. B. \"Europe/Berlin\" (EU) oder " + + "\"America/New_York\" (US). Gilt für Anzeige, Logdatei-Tagesgrenzen, Berichtszeiten " + + "und Buchungsperioden; gespeichert wird immer UTC. VOR den ersten Trades festlegen " + + "und danach nicht mehr ändern – ein Wechsel verschiebt rückwirkend alle Tagesgrenzen. " + + "Leer = Zeitzone des Systems (nicht empfohlen). Wirkt erst nach einem Neustart.")] + public string ApplicationTimeZoneId { get; set; } = "Europe/Berlin"; + [Category("Trading")] [DisplayName("Trading aktiv")] [Description("Globaler Hauptschalter. Nur wenn aktiv werden Orders ausgeführt.")] diff --git a/src/IBKRTrader.Core/Time/AppTimeZone.cs b/src/IBKRTrader.Core/Time/AppTimeZone.cs new file mode 100644 index 0000000..027f1ba --- /dev/null +++ b/src/IBKRTrader.Core/Time/AppTimeZone.cs @@ -0,0 +1,95 @@ +namespace IBKRTrader.Core.Time; + +/// +/// Betriebszeitzone dieser Instanz. Wird beim Start EINMAL aus den Einstellungen +/// (Trading.ApplicationTimeZoneId) gesetzt und danach überall verwendet, wo aus einem +/// UTC-Zeitstempel eine Ortszeit wird: Anzeige, Logdatei-Tagesgrenzen, Berichtszeitpunkte, +/// Buchungsperioden. +/// +/// Warum das nötig ist: Wir betreiben Instanzen in zwei Regionen (EU und US). +/// Vorher hing die Ortszeit an der Zeitzone des Rechners (DateTime.Now, +/// DateTimeKind.Local). Derselbe Code hätte auf einem Windows-Desktop mit +/// Europe/Berlin und in einem Linux-Container mit UTC lautlos unterschiedliche +/// Werte geliefert – ohne Fehler, nur um Stunden verschoben. Jetzt ist die Zeitzone eine +/// ausdrückliche Einstellung und nicht mehr eine Eigenschaft des Hosts. +/// +/// Nicht im Betrieb wechseln. Die Zeitzone wird vor den ersten Trades einer Instanz +/// festgelegt und bleibt danach unverändert: ein Wechsel verschiebt rückwirkend Tagesgrenzen von +/// Logs, Berichten und Buchungsperioden. Eine EU-Instanz bleibt EU, eine US-Instanz bleibt US. +/// Änderungen greifen erst nach einem Neustart. +/// +/// Persistenz bleibt UTC. Diese Klasse ändert nichts daran, dass alle Zeitstempel in +/// der Datenbank UTC sind – nur so bleiben die Daten beider Instanzen vergleichbar. Sie rechnet +/// ausschließlich für die Darstellung und für Zeitpläne um. +/// +public static class AppTimeZone +{ + /// Empfehlung für neue EU-Installationen (IANA-Schreibweise, trägt auf beiden Plattformen). + public const string DefaultEuId = "Europe/Berlin"; + + /// Empfehlung für neue US-Installationen – die Zeitzone der US-Börsen. + public const string DefaultUsId = "America/New_York"; + + /// Aktuelle Betriebszeitzone. Vor die des Systems. + public static TimeZoneInfo Current { get; private set; } = TimeZoneInfo.Local; + + /// Die tatsächlich verwendete ID (kann abweichen, wenn ausgewichen werden musste). + public static string CurrentId => Current.Id; + + /// + /// Setzt die Betriebszeitzone. Leere Angabe = Systemzeitzone. Meldet über + /// , wenn auf etwas anderes als das Gewünschte ausgewichen wurde. + /// + public static void Configure(string? timeZoneId, Action? warn = null) + { + if (string.IsNullOrWhiteSpace(timeZoneId)) + { + Current = TimeZoneInfo.Local; + warn?.Invoke($"Keine Betriebszeitzone konfiguriert – es gilt die des Systems " + + $"(\"{TimeZoneInfo.Local.Id}\"). Für einen planbaren Betrieb sollte " + + $"Trading.ApplicationTimeZoneId gesetzt sein, z. B. \"{DefaultEuId}\" oder \"{DefaultUsId}\"."); + return; + } + + var id = timeZoneId.Trim(); + if (TryFind(id, out var tz)) { Current = tz!; return; } + + // Andere Schreibweise versuchen: dieselbe Konfiguration soll unter Windows und Linux tragen. + if (TimeZoneInfo.TryConvertIanaIdToWindowsId(id, out var windowsId) && TryFind(windowsId!, out tz)) + { + Current = tz!; + warn?.Invoke($"Zeitzone \"{id}\" wurde als \"{tz!.Id}\" aufgelöst."); + return; + } + + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(id, out var ianaId) && TryFind(ianaId!, out tz)) + { + Current = tz!; + warn?.Invoke($"Zeitzone \"{id}\" wurde als \"{tz!.Id}\" aufgelöst."); + return; + } + + Current = TimeZoneInfo.Local; + warn?.Invoke($"Zeitzone \"{id}\" ist auf diesem System unbekannt – es gilt die Systemzeitzone " + + $"(\"{TimeZoneInfo.Local.Id}\"). Empfohlen ist die IANA-Schreibweise, z. B. \"{DefaultEuId}\"."); + } + + /// Nur für Tests: auf die Systemzeitzone zurücksetzen. + internal static void Reset() => Current = TimeZoneInfo.Local; + + /// Rechnet einen UTC-Zeitstempel in die Betriebszeitzone um (für Anzeige/Tagesgrenzen). + public static DateTime ToDisplay(DateTime value) => + value.Kind == DateTimeKind.Utc + ? TimeZoneInfo.ConvertTimeFromUtc(value, Current) + : TimeZoneInfo.ConvertTime(value, Current); + + /// Aktuelle Ortszeit in der Betriebszeitzone – der Ersatz für DateTime.Now. + public static DateTime Now => TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Current); + + private static bool TryFind(string id, out TimeZoneInfo? tz) + { + try { tz = TimeZoneInfo.FindSystemTimeZoneById(id); return true; } + catch (TimeZoneNotFoundException) { tz = null; return false; } + catch (InvalidTimeZoneException) { tz = null; return false; } + } +} diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs index 9d93dd7..f9d4cbc 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Globalization; using IBApi; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Time; namespace IBKRTrader.Core.Trading.Ibkr; @@ -458,7 +459,7 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable slot.Items.Add(new BrokerExecution { ExecId = execution.ExecId, - Time = IbkrMapping.ParseExecutionTime(execution.Time) ?? DateTime.MinValue, + Time = IbkrMapping.ParseExecutionTime(execution.Time, AppTimeZone.Current) ?? DateTime.MinValue, Symbol = contract.Symbol, SecType = contract.SecType, Side = IbkrMapping.ParseSide(execution.Side), diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs index d20c135..7dc0c0b 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs @@ -94,21 +94,64 @@ internal static class IbkrMapping side.Trim().ToUpperInvariant() is "SLD" or "SELL" ? TradeSide.Sell : TradeSide.Buy; /// - /// Zeitstempel einer Ausführung. TWS liefert je nach Aufruf "yyyyMMdd HH:mm:ss" (mit doppeltem - /// Leerzeichen) oder zusätzlich eine Zeitzone ("20260804 17:52:56 Europe/Berlin"). Die Zeitzone - /// wird verworfen – der Wert bleibt Ortszeit der Börse, wie ihn TWS meldet. + /// Zeitstempel einer Ausführung, immer als UTC (). + /// + /// TWS liefert je nach Aufruf "20260804 17:39:18" (doppeltes Leerzeichen, ohne Zone) oder + /// "20260804 17:52:56 Europe/Berlin" (mit IANA-Zone) – beide Formen sind gegen das Paper-Gateway + /// gemessen. Ist eine Zone angegeben, wird gegen sie nach UTC gerechnet; sonst gilt + /// , also die Betriebszeitzone der Instanz. + /// + /// Warum nicht mehr „Zone verwerfen": wir betreiben Instanzen in EU und US. Würde + /// die gemeldete Zone weggeworfen, bekäme eine Ausführung an der NYSE denselben nackten + /// Zeitwert wie eine an der Eurex – und läge in den Büchern um Stunden daneben, ohne dass + /// irgendwo ein Fehler auftaucht. /// - public static DateTime? ParseExecutionTime(string? raw) + /// + /// Zeitzone für Meldungen ohne Zonenangabe – die Betriebszeitzone (AppTimeZone.Current). + /// + public static DateTime? ParseExecutionTime(string? raw, TimeZoneInfo fallbackZone) { if (string.IsNullOrWhiteSpace(raw)) return null; - var parts = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + // Datum und Uhrzeit trennen TWS je nach Aufruf per Leerzeichen oder Bindestrich + // ("20260804-17:52:56" ist das Format, das auch der Anfragefilter nutzt). + var parts = raw.Replace('-', ' ') + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length < 2) return null; - return DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss", - CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) - ? parsed - : null; + if (!DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss", + CultureInfo.InvariantCulture, DateTimeStyles.None, out var local)) + return null; + + // Dritter Teil, falls vorhanden, ist die Zeitzone der Börse. + var zone = parts.Length >= 3 ? ResolveZone(parts[2]) ?? fallbackZone : fallbackZone; + + local = DateTime.SpecifyKind(local, DateTimeKind.Unspecified); + + // Bei der Zeitumstellung kann die Ortszeit ungültig (Vorstellen) oder doppelt (Zurückstellen) + // sein. ConvertTimeToUtc würde bei ungültigen Werten werfen – eine Ausführung darf daran + // nicht verlorengehen, deshalb der ausdrückliche Versatz. + if (zone.IsInvalidTime(local)) + return DateTime.SpecifyKind(local - zone.BaseUtcOffset, DateTimeKind.Utc); + + return TimeZoneInfo.ConvertTimeToUtc(local, zone); + } + + /// + /// Löst die von TWS gemeldete Zonenangabe auf. TWS liefert IANA-Schreibweise + /// ("Europe/Berlin", "US/Eastern"); unter Windows braucht es dafür die Umrechnung. + /// Unbekannte Angabe = null, damit der Aufrufer auf die Betriebszeitzone ausweichen kann. + /// + private static TimeZoneInfo? ResolveZone(string id) + { + try { return TimeZoneInfo.FindSystemTimeZoneById(id); } + catch (TimeZoneNotFoundException) { } + catch (InvalidTimeZoneException) { } + + if (TimeZoneInfo.TryConvertIanaIdToWindowsId(id, out var windowsId)) + try { return TimeZoneInfo.FindSystemTimeZoneById(windowsId!); } catch { /* unbekannt */ } + + return null; } /// diff --git a/src/IBKRTrader.Core/Trading/TradingModels.cs b/src/IBKRTrader.Core/Trading/TradingModels.cs index 9aa3fdd..ec3b4a9 100644 --- a/src/IBKRTrader.Core/Trading/TradingModels.cs +++ b/src/IBKRTrader.Core/Trading/TradingModels.cs @@ -111,6 +111,11 @@ public sealed record BrokerExecution { /// Eindeutige IBKR-Ausführungs-ID – geeignet als Idempotenzschlüssel beim Import. public required string ExecId { get; init; } + /// + /// Ausführungszeit in UTC. TWS meldet Börsen-Ortszeit (teils mit Zonenangabe); die + /// Umrechnung erfolgt in IbkrMapping.ParseExecutionTime. UTC ist Pflicht, weil wir + /// Instanzen in EU und US betreiben und die Daten vergleichbar bleiben müssen. + /// public required DateTime Time { get; init; } public required string Symbol { get; init; } public required string SecType { get; init; } diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs b/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs index 85668ab..5dcfca3 100644 --- a/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs +++ b/src/IBKRTrader.Core/Workers/BuiltIn/BackupWorker.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Time; using IBKRTrader.Core.Persistence.Ef; using Microsoft.EntityFrameworkCore; using IBKRTrader.Core.Logging; @@ -31,7 +32,7 @@ public class BackupWorker : WorkerBase protected override async Task ExecuteAsync(CancellationToken ct) { - var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm"); + var timestamp = AppTimeZone.Now.ToString("yyyy-MM-dd_HH-mm"); var backupRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Backups"); var dbDir = Path.Combine(backupRoot, "DB"); var logsDir = Path.Combine(backupRoot, "Logs"); diff --git a/src/IBKRTrader.Core/Workers/WorkerBase.cs b/src/IBKRTrader.Core/Workers/WorkerBase.cs index 32217a1..22e2df6 100644 --- a/src/IBKRTrader.Core/Workers/WorkerBase.cs +++ b/src/IBKRTrader.Core/Workers/WorkerBase.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Time; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Persistence.Entities; @@ -111,11 +112,12 @@ public abstract class WorkerBase : IWorker, IHostedService if (Interval == null) break; // Service: nur einmal - var next = DateTime.Now.Add(Interval.Value); + // Anzeige-/Zeitplanwerte in der Betriebszeitzone, damit die UI dasselbe zeigt wie die Logs. + var next = AppTimeZone.Now.Add(Interval.Value); Info.NextRuntime = next; // Warte auf Interval ODER manuellen Trigger - var remaining = next - DateTime.Now; + var remaining = next - AppTimeZone.Now; if (remaining > TimeSpan.Zero) { try @@ -143,7 +145,7 @@ public abstract class WorkerBase : IWorker, IHostedService await ExecuteAsync(ct); await EndRunLogAsync(logId, true); - Info.LastRuntime = DateTime.Now; + Info.LastRuntime = AppTimeZone.Now; Info.Status = WorkerStatus.Idle; Info.Info = $"OK – {Info.LastRuntime:HH:mm:ss}"; } diff --git a/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs b/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs index 5289444..039284d 100644 --- a/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs +++ b/src/IBKRTrader.Modules.Accounting/Logic/PdfExporter.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Time; using System.Security.Cryptography; using System.Text; using IBKRTrader.Modules.Accounting.Models; @@ -48,7 +49,7 @@ public static class PdfExporter meta.AddLineBreak(); meta.AddText($"Währung: {currencyCode} ({currencyNote})"); meta.AddLineBreak(); - meta.AddText($"Erstellt: {DateTime.Now:yyyy-MM-dd HH:mm}"); + meta.AddText($"Erstellt: {AppTimeZone.Now:yyyy-MM-dd HH:mm}"); // ---- Aggregat ---- AddSectionTitle(section, "Zusammenfassung"); diff --git a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs index da28a60..24de838 100644 --- a/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs +++ b/src/IBKRTrader.Modules.CongressTrading/Workers/CongressScrapeWorker.cs @@ -1,3 +1,4 @@ +using IBKRTrader.Core.Time; using IBKRTrader.Core.Persistence.Ef; using Microsoft.EntityFrameworkCore; using IBKRTrader.Core.Logging; @@ -50,7 +51,7 @@ public class CongressScrapeWorker : WorkerBase if (trades.Count == 0) { Logger.Warn(Module, "Scrape: Keine Trades auf Seite 1 gefunden."); - Info.Info = $"Letzter Lauf {DateTime.Now:HH:mm}: 0 Trades"; + Info.Info = $"Letzter Lauf {AppTimeZone.Now:HH:mm}: 0 Trades"; return; } @@ -86,6 +87,6 @@ public class CongressScrapeWorker : WorkerBase var summary = $"{newTrades} neue Trades, {newMembers} neue Mitglieder"; Logger.Info(Module, $"Scrape abgeschlossen: {summary}"); - Info.Info = $"Letzter Lauf {DateTime.Now:HH:mm}: {summary}"; + Info.Info = $"Letzter Lauf {AppTimeZone.Now:HH:mm}: {summary}"; } } diff --git a/src/IBKRTrader.Modules.Supervisor/Services/DailyReportService.cs b/src/IBKRTrader.Modules.Supervisor/Services/DailyReportService.cs index eff6e55..b0df79d 100644 --- a/src/IBKRTrader.Modules.Supervisor/Services/DailyReportService.cs +++ b/src/IBKRTrader.Modules.Supervisor/Services/DailyReportService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using IBKRTrader.Core.Logging; +using IBKRTrader.Core.Time; using IBKRTrader.Modules.Supervisor.Agent; using IBKRTrader.Modules.Supervisor.Persistence; using Microsoft.Extensions.Hosting; @@ -38,10 +39,12 @@ public sealed class DailyReportService : BackgroundService return; } - _logger.Info("Supervisor", $"Tagesbericht aktiv: täglich um {hour:00}:00 Uhr."); + _logger.Info("Supervisor", + $"Tagesbericht aktiv: täglich um {hour:00}:00 Uhr ({AppTimeZone.CurrentId})."); while (!stoppingToken.IsCancellationRequested) { - var delay = NextRun(DateTime.Now, hour) - DateTime.Now; + var now = DateTime.UtcNow; + var delay = NextRunUtc(now, hour, AppTimeZone.Current) - now; try { await Task.Delay(delay, stoppingToken); } catch (OperationCanceledException) { break; } @@ -51,10 +54,37 @@ public sealed class DailyReportService : BackgroundService } } - internal static DateTime NextRun(DateTime now, int hour) + /// + /// Nächster Ausführungszeitpunkt (UTC) für „täglich um Uhr Ortszeit". + /// + /// Rechnet ausdrücklich gegen statt gegen + /// : sonst hinge die Berichtszeit an der Zeitzone des Rechners. + /// Eine US-Instanz auf einem UTC-Container hätte den „18-Uhr-Bericht" um 13 Uhr Ortszeit + /// erzeugt – über eine Zeitgrenze hinweg, die im Bericht selbst nicht sichtbar ist. + /// + internal static DateTime NextRunUtc(DateTime nowUtc, int hour, TimeZoneInfo zone) { - var candidate = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0, DateTimeKind.Local); - return candidate <= now ? candidate.AddDays(1) : candidate; + var localNow = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, zone); + + // Zwei Tage reichen: der heutige Termin liegt entweder noch vor uns oder der morgige. + for (int addDays = 0; addDays <= 2; addDays++) + { + var candidate = localNow.Date.AddDays(addDays).AddHours(hour); + + // Beim Vorstellen der Uhr existiert die Stunde nicht – dann auf die nächste gültige + // ausweichen, statt den Bericht des Tages ausfallen zu lassen. + int guard = 0; + while (zone.IsInvalidTime(candidate) && guard++ < 4) + candidate = candidate.AddHours(1); + if (zone.IsInvalidTime(candidate)) continue; + + // Beim Zurückstellen ist die Stunde doppelt; ConvertTimeToUtc nimmt die Normalzeit – gewollt. + var utc = TimeZoneInfo.ConvertTimeToUtc( + DateTime.SpecifyKind(candidate, DateTimeKind.Unspecified), zone); + if (utc > nowUtc) return utc; + } + + return nowUtc.AddDays(1); } private async Task RunOnceAsync(CancellationToken ct) diff --git a/tests/IBKRTrader.Tests/Modules/Supervisor/DailyReportScheduleTests.cs b/tests/IBKRTrader.Tests/Modules/Supervisor/DailyReportScheduleTests.cs new file mode 100644 index 0000000..a9d5f56 --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/Supervisor/DailyReportScheduleTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; +using IBKRTrader.Modules.Supervisor.Services; + +namespace IBKRTrader.Tests.Modules.Supervisor; + +/// +/// Der Tagesbericht soll um eine feste ORTSZEIT laufen – unabhängig davon, in welcher Zeitzone der +/// Rechner steht. Vorher rechnete er gegen DateTimeKind.Local: eine US-Instanz auf einem +/// UTC-Container hätte den „18-Uhr-Bericht" um 13 Uhr Ortszeit erzeugt, über eine Zeitgrenze +/// hinweg, die im Bericht selbst nicht sichtbar ist. +/// +[Trait("cat", "unit")] +public class DailyReportScheduleTests +{ + private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); + private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + + [Fact] + public void NaechsterLauf_LiegtHeute_WennDieStundeNochBevorsteht() + { + // 08:00 UTC = 10:00 Berliner Sommerzeit. 18 Uhr Berlin steht heute noch bevor = 16:00 UTC. + var now = new DateTime(2026, 7, 15, 8, 0, 0, DateTimeKind.Utc); + + DailyReportService.NextRunUtc(now, 18, Berlin) + .Should().Be(new DateTime(2026, 7, 15, 16, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + public void NaechsterLauf_RutschtAufMorgen_WennDieStundeVorbeiIst() + { + // 20:00 UTC = 22:00 Berlin. 18 Uhr ist durch, also morgen 18 Uhr Berlin = 16:00 UTC. + var now = new DateTime(2026, 7, 15, 20, 0, 0, DateTimeKind.Utc); + + DailyReportService.NextRunUtc(now, 18, Berlin) + .Should().Be(new DateTime(2026, 7, 16, 16, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + public void DieselbeStunde_ErgibtJeZoneEinenAnderenUtcZeitpunkt() + { + var now = new DateTime(2026, 7, 15, 6, 0, 0, DateTimeKind.Utc); + + // 18 Uhr Ortszeit: Berlin (UTC+2) = 16:00 UTC, New York (UTC-4) = 22:00 UTC. + DailyReportService.NextRunUtc(now, 18, Berlin).Hour.Should().Be(16); + DailyReportService.NextRunUtc(now, 18, NewYork).Hour.Should().Be(22); + } + + [Fact] + public void Winterzeit_NutztDenRichtigenVersatz() + { + // Januar: Berlin ist UTC+1, 18 Uhr Ortszeit = 17:00 UTC. + var now = new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc); + + DailyReportService.NextRunUtc(now, 18, Berlin) + .Should().Be(new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + public void Zeitumstellung_LaesstDenBerichtNichtAusfallen() + { + // 29.03.2026: Berlin stellt um 02:00 auf 03:00 vor – die Stunde 02:00 existiert nicht. + // Der Bericht muss trotzdem laufen (auf die naechste gueltige Stunde ausweichen), + // statt den Tag zu ueberspringen. + var now = new DateTime(2026, 3, 28, 12, 0, 0, DateTimeKind.Utc); + + var next = DailyReportService.NextRunUtc(now, 2, Berlin); + + next.Should().BeAfter(now); + next.Should().BeBefore(now.AddDays(2), "der Bericht darf hoechstens einen Tag spaeter kommen"); + } + + [Fact] + public void Ergebnis_IstImmerUtc() + { + var now = new DateTime(2026, 7, 15, 8, 0, 0, DateTimeKind.Utc); + + DailyReportService.NextRunUtc(now, 18, Berlin).Kind.Should().Be(DateTimeKind.Utc); + } +} diff --git a/tests/IBKRTrader.Tests/Time/AppTimeZoneTests.cs b/tests/IBKRTrader.Tests/Time/AppTimeZoneTests.cs new file mode 100644 index 0000000..32dea66 --- /dev/null +++ b/tests/IBKRTrader.Tests/Time/AppTimeZoneTests.cs @@ -0,0 +1,87 @@ +using FluentAssertions; +using IBKRTrader.Core.Time; + +namespace IBKRTrader.Tests.Time; + +/// +/// Sicherheitsnetz für die Betriebszeitzone. Wir betreiben Instanzen in EU und US; die Zeitzone +/// darf deshalb nicht mehr an der Zeitzone des Rechners hängen. Diese Tests halten fest, dass +/// beide Schreibweisen tragen (Windows-IDs unter Linux, IANA-IDs unter Windows) und dass ein +/// unbekannter Wert niemals fatal ist, sondern gemeldet wird. +/// +[Trait("cat", "unit")] +public class AppTimeZoneTests : IDisposable +{ + public void Dispose() => AppTimeZone.Reset(); + + [Theory] + [InlineData("Europe/Berlin")] // IANA – Empfehlung, trägt auf beiden Plattformen + [InlineData("W. Europe Standard Time")] // Windows – eine Altkonfiguration muss weiter tragen + public void BekannteIds_ErgebenDieselbeZone(string id) + { + AppTimeZone.Configure(id); + + // Der Bezeichner unterscheidet sich je Plattform; entscheidend ist der Versatz. + AppTimeZone.Current.GetUtcOffset(new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)) + .Should().Be(TimeSpan.FromHours(1)); + AppTimeZone.Current.GetUtcOffset(new DateTime(2026, 7, 15, 12, 0, 0, DateTimeKind.Utc)) + .Should().Be(TimeSpan.FromHours(2)); + } + + [Theory] + [InlineData("America/New_York")] + [InlineData("Eastern Standard Time")] + public void UsZone_TraegtEbenfallsInBeidenSchreibweisen(string id) + { + AppTimeZone.Configure(id); + + AppTimeZone.Current.GetUtcOffset(new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)) + .Should().Be(TimeSpan.FromHours(-5)); + AppTimeZone.Current.GetUtcOffset(new DateTime(2026, 7, 15, 12, 0, 0, DateTimeKind.Utc)) + .Should().Be(TimeSpan.FromHours(-4)); + } + + [Fact] + public void UnbekannteId_WeichtAufDieSystemzoneAus_UndWarnt() + { + string? warning = null; + AppTimeZone.Configure("Gibt/EsNicht", w => warning = w); + + AppTimeZone.Current.Id.Should().Be(TimeZoneInfo.Local.Id); + warning.Should().NotBeNull().And.Contain("Gibt/EsNicht"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void LeereAngabe_BedeutetSystemzone_UndWarnt(string? id) + { + string? warning = null; + AppTimeZone.Configure(id, w => warning = w); + + AppTimeZone.Current.Id.Should().Be(TimeZoneInfo.Local.Id); + warning.Should().NotBeNull("eine unbestimmte Betriebszeitzone ist ein Betriebsrisiko"); + } + + [Fact] + public void ToDisplay_RechnetUtcInDieKonfigurierteZone() + { + AppTimeZone.Configure("Europe/Berlin"); + + // 12:00 UTC im Januar = 13:00 Berliner Zeit (MEZ). + AppTimeZone.ToDisplay(new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)).Hour.Should().Be(13); + } + + [Fact] + public void ToDisplay_TrenntEuUndUs() + { + var utc = new DateTime(2026, 7, 15, 16, 0, 0, DateTimeKind.Utc); + + AppTimeZone.Configure("Europe/Berlin"); + AppTimeZone.ToDisplay(utc).Hour.Should().Be(18); // UTC+2 + + AppTimeZone.Configure("America/New_York"); + AppTimeZone.ToDisplay(utc).Hour.Should().Be(12); // UTC-4 + } +} diff --git a/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs b/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs index b36c724..b6e7f83 100644 --- a/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs +++ b/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs @@ -161,19 +161,61 @@ public class IbkrMappingTests public void ParseSide_MapsTwsExecutionSides(string raw, TradeSide expected) => IbkrMapping.ParseSide(raw).Should().Be(expected); + // Die beiden Zonen, in denen wir Instanzen betreiben. + private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); + private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + [Fact] - public void ParseExecutionTime_HandlesDoubleSpaceFormat() + public void ParseExecutionTime_OhneZone_RechnetGegenDieBetriebszeitzone() { - // So liefert TWS es bei execDetails. - IbkrMapping.ParseExecutionTime("20260804 17:39:18") - .Should().Be(new DateTime(2026, 8, 4, 17, 39, 18)); + // So liefert TWS es bei execDetails (doppeltes Leerzeichen, keine Zonenangabe). + // 17:39:18 Berliner Sommerzeit (UTC+2) = 15:39:18 UTC. + var parsed = IbkrMapping.ParseExecutionTime("20260804 17:39:18", Berlin); + + parsed.Should().Be(new DateTime(2026, 8, 4, 15, 39, 18, DateTimeKind.Utc)); + parsed!.Value.Kind.Should().Be(DateTimeKind.Utc); } [Fact] - public void ParseExecutionTime_IgnoresTrailingTimeZone() + public void ParseExecutionTime_NutztDieGemeldeteZone_StattSieZuVerwerfen() { - IbkrMapping.ParseExecutionTime("20260804 17:52:56 Europe/Berlin") - .Should().Be(new DateTime(2026, 8, 4, 17, 52, 56)); + // Der Kern der Sache: eine US-Instanz darf eine NYSE-Ausfuehrung nicht als Berliner Zeit + // buchen. Gemeldet wird Europe/Berlin, also gilt die - unabhaengig von der Instanzzone. + var parsed = IbkrMapping.ParseExecutionTime("20260804 17:52:56 Europe/Berlin", NewYork); + + parsed.Should().Be(new DateTime(2026, 8, 4, 15, 52, 56, DateTimeKind.Utc)); + } + + [Fact] + public void ParseExecutionTime_UsBoerse_LiefertKorrektesUtc() + { + // 09:30 New Yorker Sommerzeit (UTC-4) = 13:30 UTC - Handelsbeginn NYSE. + IbkrMapping.ParseExecutionTime("20260804 09:30:00 America/New_York", Berlin) + .Should().Be(new DateTime(2026, 8, 4, 13, 30, 0, DateTimeKind.Utc)); + } + + [Fact] + public void ParseExecutionTime_UnbekannteZone_WeichtAufDieBetriebszeitzoneAus() + { + // Lieber die Instanzzone als gar kein Zeitstempel - die Ausfuehrung darf nicht verlorengehen. + IbkrMapping.ParseExecutionTime("20260804 17:39:18 Gibt/EsNicht", Berlin) + .Should().Be(new DateTime(2026, 8, 4, 15, 39, 18, DateTimeKind.Utc)); + } + + [Fact] + public void ParseExecutionTime_AkzeptiertAuchDasBindestrichFormat() + { + // Dasselbe Format, das FormatExecutionFilterTime erzeugt. + IbkrMapping.ParseExecutionTime("20260804-17:39:18", Berlin) + .Should().Be(new DateTime(2026, 8, 4, 15, 39, 18, DateTimeKind.Utc)); + } + + [Fact] + public void ParseExecutionTime_Winterzeit_NutztDenRichtigenVersatz() + { + // Januar: Berlin ist UTC+1, nicht UTC+2. Faengt ein fest verdrahtetes Offset ab. + IbkrMapping.ParseExecutionTime("20260115 17:39:18", Berlin) + .Should().Be(new DateTime(2026, 1, 15, 16, 39, 18, DateTimeKind.Utc)); } [Theory] @@ -182,7 +224,7 @@ public class IbkrMappingTests [InlineData("20260804")] [InlineData("Unsinn")] public void ParseExecutionTime_ReturnsNullForUnusableInput(string raw) => - IbkrMapping.ParseExecutionTime(raw).Should().BeNull(); + IbkrMapping.ParseExecutionTime(raw, Berlin).Should().BeNull(); [Fact] public void FormatExecutionFilterTime_UsesUtcWithExplicitFormat()