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:
Richard
2026-08-06 22:52:23 +02:00
co-authored by Claude Opus 5
parent 123f38ab6f
commit d8273c3a1e
19 changed files with 475 additions and 39 deletions
+95
View File
@@ -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; }
}
}