L1b/1: Betriebszeitzone statt Rechnerzeit - EU- und US-Instanzen sauber trennen
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>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
namespace IBKRTrader.Core.Time;
|
||||
|
||||
/// <summary>
|
||||
/// Betriebszeitzone dieser Instanz. Wird beim Start EINMAL aus den Einstellungen
|
||||
/// (<c>Trading.ApplicationTimeZoneId</c>) gesetzt und danach überall verwendet, wo aus einem
|
||||
/// UTC-Zeitstempel eine Ortszeit wird: Anzeige, Logdatei-Tagesgrenzen, Berichtszeitpunkte,
|
||||
/// Buchungsperioden.
|
||||
///
|
||||
/// <para><b>Warum das nötig ist:</b> Wir betreiben Instanzen in <b>zwei</b> Regionen (EU und US).
|
||||
/// Vorher hing die Ortszeit an der Zeitzone des Rechners (<c>DateTime.Now</c>,
|
||||
/// <c>DateTimeKind.Local</c>). Derselbe Code hätte auf einem Windows-Desktop mit
|
||||
/// <c>Europe/Berlin</c> und in einem Linux-Container mit <c>UTC</c> lautlos unterschiedliche
|
||||
/// Werte geliefert – ohne Fehler, nur um Stunden verschoben. Jetzt ist die Zeitzone eine
|
||||
/// ausdrückliche Einstellung und nicht mehr eine Eigenschaft des Hosts.</para>
|
||||
///
|
||||
/// <para><b>Nicht im Betrieb wechseln.</b> Die Zeitzone wird vor den ersten Trades einer Instanz
|
||||
/// festgelegt und bleibt danach unverändert: ein Wechsel verschiebt rückwirkend Tagesgrenzen von
|
||||
/// Logs, Berichten und Buchungsperioden. Eine EU-Instanz bleibt EU, eine US-Instanz bleibt US.
|
||||
/// Änderungen greifen erst nach einem Neustart.</para>
|
||||
///
|
||||
/// <para><b>Persistenz bleibt UTC.</b> Diese Klasse ändert nichts daran, dass alle Zeitstempel in
|
||||
/// der Datenbank UTC sind – nur so bleiben die Daten beider Instanzen vergleichbar. Sie rechnet
|
||||
/// ausschließlich für die Darstellung und für Zeitpläne um.</para>
|
||||
/// </summary>
|
||||
public static class AppTimeZone
|
||||
{
|
||||
/// <summary>Empfehlung für neue EU-Installationen (IANA-Schreibweise, trägt auf beiden Plattformen).</summary>
|
||||
public const string DefaultEuId = "Europe/Berlin";
|
||||
|
||||
/// <summary>Empfehlung für neue US-Installationen – die Zeitzone der US-Börsen.</summary>
|
||||
public const string DefaultUsId = "America/New_York";
|
||||
|
||||
/// <summary>Aktuelle Betriebszeitzone. Vor <see cref="Configure"/> die des Systems.</summary>
|
||||
public static TimeZoneInfo Current { get; private set; } = TimeZoneInfo.Local;
|
||||
|
||||
/// <summary>Die tatsächlich verwendete ID (kann abweichen, wenn ausgewichen werden musste).</summary>
|
||||
public static string CurrentId => Current.Id;
|
||||
|
||||
/// <summary>
|
||||
/// Setzt die Betriebszeitzone. Leere Angabe = Systemzeitzone. Meldet über
|
||||
/// <paramref name="warn"/>, wenn auf etwas anderes als das Gewünschte ausgewichen wurde.
|
||||
/// </summary>
|
||||
public static void Configure(string? timeZoneId, Action<string>? warn = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(timeZoneId))
|
||||
{
|
||||
Current = TimeZoneInfo.Local;
|
||||
warn?.Invoke($"Keine Betriebszeitzone konfiguriert – es gilt die des Systems " +
|
||||
$"(\"{TimeZoneInfo.Local.Id}\"). Für einen planbaren Betrieb sollte " +
|
||||
$"Trading.ApplicationTimeZoneId gesetzt sein, z. B. \"{DefaultEuId}\" oder \"{DefaultUsId}\".");
|
||||
return;
|
||||
}
|
||||
|
||||
var id = timeZoneId.Trim();
|
||||
if (TryFind(id, out var tz)) { Current = tz!; return; }
|
||||
|
||||
// Andere Schreibweise versuchen: dieselbe Konfiguration soll unter Windows und Linux tragen.
|
||||
if (TimeZoneInfo.TryConvertIanaIdToWindowsId(id, out var windowsId) && TryFind(windowsId!, out tz))
|
||||
{
|
||||
Current = tz!;
|
||||
warn?.Invoke($"Zeitzone \"{id}\" wurde als \"{tz!.Id}\" aufgelöst.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TimeZoneInfo.TryConvertWindowsIdToIanaId(id, out var ianaId) && TryFind(ianaId!, out tz))
|
||||
{
|
||||
Current = tz!;
|
||||
warn?.Invoke($"Zeitzone \"{id}\" wurde als \"{tz!.Id}\" aufgelöst.");
|
||||
return;
|
||||
}
|
||||
|
||||
Current = TimeZoneInfo.Local;
|
||||
warn?.Invoke($"Zeitzone \"{id}\" ist auf diesem System unbekannt – es gilt die Systemzeitzone " +
|
||||
$"(\"{TimeZoneInfo.Local.Id}\"). Empfohlen ist die IANA-Schreibweise, z. B. \"{DefaultEuId}\".");
|
||||
}
|
||||
|
||||
/// <summary>Nur für Tests: auf die Systemzeitzone zurücksetzen.</summary>
|
||||
internal static void Reset() => Current = TimeZoneInfo.Local;
|
||||
|
||||
/// <summary>Rechnet einen UTC-Zeitstempel in die Betriebszeitzone um (für Anzeige/Tagesgrenzen).</summary>
|
||||
public static DateTime ToDisplay(DateTime value) =>
|
||||
value.Kind == DateTimeKind.Utc
|
||||
? TimeZoneInfo.ConvertTimeFromUtc(value, Current)
|
||||
: TimeZoneInfo.ConvertTime(value, Current);
|
||||
|
||||
/// <summary>Aktuelle Ortszeit in der Betriebszeitzone – der Ersatz für <c>DateTime.Now</c>.</summary>
|
||||
public static DateTime Now => TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Current);
|
||||
|
||||
private static bool TryFind(string id, out TimeZoneInfo? tz)
|
||||
{
|
||||
try { tz = TimeZoneInfo.FindSystemTimeZoneById(id); return true; }
|
||||
catch (TimeZoneNotFoundException) { tz = null; return false; }
|
||||
catch (InvalidTimeZoneException) { tz = null; return false; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user