From 87194bfc484fadee85f3f872fcbd947ab428bbb1 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 22 Aug 2026 10:44:41 +0200 Subject: [PATCH] IBKR: Zeitkontext einer Verbindung nachvollziehbar machen ResolveExecutionTime liefert neben dem UTC-Zeitpunkt jetzt die Herkunft der verwendeten Zeitzone (gemeldet / angenommen / unbekannt / unlesbar). Bisher war im Nachhinein nicht unterscheidbar, ob ein Buchungszeitpunkt von TWS stammte oder eine Annahme war - genau der Fehler, der beim Umzug zwischen EU- und US-Host lautlos entsteht. IbkrConnection schreibt beim Verbinden einmalig Betriebszeitzone, Systemzeitzone und den Versatz zur TWS-Serverzeit ins Log; ab 5 s Abweichung gilt die Uhr des Hosts als verstellt. TWS-Setup-Checkliste um den Linux-Abschnitt ergaenzt (Betrieb und Umgebung unterscheiden sich, das Protokoll nicht). Co-Authored-By: Claude Opus 5 --- docs/TWS-Setup-Checkliste.md | 101 ++++++++++++++- .../Trading/Ibkr/IbkrConnection.cs | 117 +++++++++++++++++- .../Trading/Ibkr/IbkrMapping.cs | 71 +++++++++-- .../Trading/IbkrMappingTests.cs | 65 ++++++++++ 4 files changed, 342 insertions(+), 12 deletions(-) diff --git a/docs/TWS-Setup-Checkliste.md b/docs/TWS-Setup-Checkliste.md index a7f6302..d39a135 100644 --- a/docs/TWS-Setup-Checkliste.md +++ b/docs/TWS-Setup-Checkliste.md @@ -68,7 +68,9 @@ der Adapter ab und bleibt inaktiv, statt auf dem falschen Konto zu handeln. ## 7. Verifikation -- [ ] Port erreichbar? `Test-NetConnection 127.0.0.1 -Port 4002` → `TcpTestSucceeded: True` +- [ ] Port erreichbar? + Windows: `Test-NetConnection 127.0.0.1 -Port 4002` → `TcpTestSucceeded: True` + Linux: `ss -ltn '( sport = :4002 )'` bzw. `nc -zv 127.0.0.1 4002` - [ ] API-Verbindung: Verbindungstest ausführen (Konto-ID, NetLiquidation, Positionen müssen kommen). Hängt der Handshake > 10 s → Trusted IP fehlt (Punkt 3) oder Popup offen. @@ -108,6 +110,103 @@ bei jeder Kursanfrage der Normalfall, die Kurse kommen danach trotzdem. - Einstellungsänderungen im API-Dialog immer mit **Übernehmen/OK** abschließen; solange der Dialog offen ist, gelten sie nicht. +## Unterschiede unter Linux + +Gilt für TWS bzw. IB Gateway auf einem Linux-Host. **Am Protokoll ändert sich nichts**: dieselbe +Java-Anwendung, dieselben Ports, derselbe Einstellungsdialog, dieselbe API-Version. Der Adapter +(`IBKRTrader.Core/Trading/Ibkr/`) braucht keine Anpassung – das TWS-API-Paket referenziert nur +`mscorlib`, `System` und `System.Core`, keine Windows-Assembly, und der Core baut fehlerfrei für +`linux-x64` (geprüft 2026-08-04). + +Anders sind Betrieb und Umgebung: + +| Thema | Windows | Linux | +|---|---|---| +| Installationsverzeichnis | `C:\Jts` | `~/Jts` | +| Einstellungen (inkl. Trusted IPs) | verschleiertes Unterverzeichnis je Login | genauso, unter `~/Jts` | +| Start | Desktop-Sitzung vorhanden | **X-Server nötig** – headless: `Xvfb` | +| Auto-Login/Neustart | IBC als geplanter Task | IBC als **systemd**-Unit (der besser unterstützte Weg) | +| Port prüfen | `Test-NetConnection` | `ss -ltn` / `nc -zv` | + +### Worauf konkret zu achten ist + +- [x] **Grafische Sitzung.** TWS ist eine GUI-Anwendung und startet ohne Display nicht. + **Unser Aufbau:** Ubuntu-Desktop-VM mit gespiegelter Bildschirmfreigabe per RDP – also eine + echte, dauerhaft laufende X-Sitzung. Damit entfällt die Xvfb-Frage, und der Einstellungsdialog + (Punkt 3) ist jederzeit erreichbar. **Wichtig:** gespiegelte Freigabe, **keine** + RDP-Remoteanmeldung – eine eigene Anmeldesitzung startet einen zweiten Desktop, in dem das + laufende TWS nicht sichtbar ist und die Sitzung beim Abmelden mitgeht. + (Nur für einen echten headless Server wäre `Xvfb` + `x11vnc` nötig.) +- [ ] **Schriftarten installieren** (`fontconfig` plus z. B. `dejavu`). Fehlen sie, startet die + Java-Oberfläche gar nicht oder rendert leer – die häufigste Stolperfalle bei schlanken Images. +- [ ] **Einstellungen neu setzen, nicht kopieren.** Die API-Einstellungen hängen am Login-Profil + unter `~/Jts`. Auf dem neuen Host Punkte 3–5 dieser Checkliste einmal komplett durchgehen. +- [ ] **Offline-Installer bevorzugen.** Der selbstaktualisierende Installer kann TWS unbemerkt auf + eine neue Version heben, die eine andere API-Serverversion spricht. +- [ ] **Zeitzone je Instanz setzen.** `Trading.ApplicationTimeZoneId` steuert die Betriebszeitzone + und ist **unabhängig** von der des Hosts. EU-Instanzen `Europe/Berlin`, US-Instanzen + `America/New_York`. TWS meldet Ausführungszeiten teils mit, teils ohne Zonenangabe – ohne + Angabe greift dieser Wert als Rückfall. Wie sich das im Betrieb beobachten lässt, steht + unten unter „Zeitverhalten beobachten". +- [ ] **ICU im Image sicherstellen** (`libicu` / `icu-data-full`). Die Zonenauflösung nutzt + IANA-IDs (`US/Eastern`); ohne ICU wirft `FindSystemTimeZoneById`. Die Projekte setzen + deshalb bewusst `InvariantGlobalization=false` – siehe `IBKRTrader.Daemon.csproj`. +- [ ] **Schreibrechte des Dienstbenutzers** auf `~/Jts` prüfen. Bei systemd mit eigenem `User=` + braucht dieser ein echtes Home-Verzeichnis. Für die App selbst regelt `AppPaths` die + Ablage bereits FHS-konform. +- [ ] **Java-Heap** in `tws.vmoptions` (im Installationsverzeichnis) prüfen, wenn viele + Instrumente abonniert werden – gleiche Datei wie unter Windows, anderer Pfad. +- [ ] **Wenn TWS und App auf verschiedenen Rechnern laufen:** „Nur Verbindungen vom lokalen Host“ + aus, IP der App-Maschine als Trusted IP eintragen – und weil der API-Socket **unverschlüsselt** + ist, nur über VPN oder SSH-Tunnel, nie offen übers Netz. + +> Diese Liste beruht auf Erfahrungswerten zum TWS-Betrieb, **nicht** auf einer Messung auf einem +> Linux-Host – anders als der Rest dieser Checkliste. Beim ersten Aufsetzen entsprechend prüfen +> und die Punkte hier korrigieren. + +## Zeitverhalten beobachten (Paper-Phase) + +Zwischen einer EU- und einer US-Instanz ist die Zeitzone die gefährlichste Stelle: Ein falscher +Wert wirft keinen Fehler, er verschiebt nur Buchungszeiten. Damit das während der Paper-Phase +auffällt statt später im Echtbetrieb, schreibt der Adapter drei Dinge mit (Modul `IBKR`). + +**1. Zeitkontext bei jedem Verbindungsaufbau** – eine Info-Zeile, die den gesamten Rahmen festhält: + +``` +Zeitkontext: Betriebszeitzone Europe/Berlin, Systemzeitzone Europe/Berlin, +TWS-Serverzeit 2026-08-04 15:39:18Z, Uhrenversatz +0.2 s. +``` + +Damit lässt sich jeder spätere Zeitfehler an einer Zeile aufklären, statt im Nachhinein zu raten, +wie die Instanz konfiguriert war. + +**2. Uhrenversatz gegen den TWS-Server.** Mehr als 5 s Abweichung ergeben eine **Warnung**. In +virtuellen Maschinen ist eine driftende Uhr ein häufiger Fehler – besonders nach Snapshots oder +Pausieren der VM. + +**3. Herkunft jedes Ausführungs-Zeitstempels.** Nach jedem Abruf steht im Log, wie viele Zeitpunkte +TWS **mit** Zonenangabe gemeldet hat und wie viele über die Betriebszeitzone **angenommen** wurden: + +``` +Zeitstempel von 2 Ausführung(en): 0 mit gemeldeter Zone, 2 über die Betriebszeitzone Europe/Berlin. +``` + +Beobachtungsstand 2026-08-04: `execDetails` lieferte die Zeit **ohne** Zonenangabe +(`"20260804 17:39:18"`), `reqCompletedOrders` dagegen **mit** (`"... Europe/Berlin"`). Der +Normalfall beim Ausführungsabruf ist also die Annahme – genau deshalb wird sie gezählt. + +### Worauf zu achten ist + +| Logmeldung | Bedeutung | Reaktion | +|---|---|---| +| `Uhrenversatz` über 5 s | Host-Uhr läuft auseinander | Zeitsynchronisation der VM prüfen | +| `... wurden gegen die Betriebszeitzone X gerechnet, das System läuft aber auf Y` | Betriebs- und Systemzeitzone gehen auseinander | Prüfen, gegen welche Uhr TWS meldet; einmal mit dem TWS-Fenster gegenlesen | +| `Ausführung(en) mit unlesbarem Zeitstempel` | TWS-Format hat sich geändert | Defekt – `IbkrMapping.ResolveExecutionTime` anpassen | + +**Gegenprobe beim Aufsetzen einer Instanz:** Eine Ausführung im TWS-Fenster ansehen und die dort +angezeigte Uhrzeit mit der gebuchten vergleichen. Stimmen beide, ist die Zeitzone richtig gesetzt. +Das kostet zwei Minuten und ist die einzige verlässliche Probe – alles andere ist Papier. + ## Unterschiede Live-Betrieb (später) | Punkt | Paper | Live | diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs index f9d4cbc..c63bdfc 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs @@ -41,6 +41,13 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable private PortfolioSlot? _portfolio; private TaskCompletionSource _handshake = NewTcs(); + private TaskCompletionSource? _serverTime; + + /// Ab diesem Versatz zur TWS-Serverzeit gilt die Uhr des Hosts als verstellt. + private const double MaxClockSkewSeconds = 5; + + // Der Hinweis auf angenommene Zeitzonen soll einmal je Verbindung kommen, nicht je Abruf. + private bool _zoneWarningIssued; private volatile bool _ready; private volatile bool _disposed; private int _nextRequestId = 1000; @@ -125,12 +132,104 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable } _ready = true; + _zoneWarningIssued = false; _socket.reqMarketDataType(_marketDataType); _logger.Info(LogModule, $"Verbunden mit {_host}:{_port} (Client {_clientId}), Konto {_account ?? "unbekannt"}."); + + await LogTimeContextAsync(ct).ConfigureAwait(false); return true; } + /// + /// Schreibt den vollständigen Zeitkontext einer Verbindung ins Log: Betriebszeitzone, + /// Systemzeitzone und den Versatz zur Uhr des TWS-Servers. + /// + /// Wozu: Wir betreiben Instanzen in EU und US, künftig auf Linux-VMs. Weicht die + /// Betriebszeitzone von der des Hosts ab oder geht die VM-Uhr nach, verschieben sich + /// Buchungszeiten – ohne dass irgendwo ein Fehler auftaucht. Steht der Kontext am Anfang jeder + /// Verbindung im Log, lässt sich das im Nachhinein an einer Zeile ablesen statt zu raten. + /// + private async Task LogTimeContextAsync(CancellationToken ct) + { + var context = $"Zeitkontext: Betriebszeitzone {AppTimeZone.CurrentId}, " + + $"Systemzeitzone {TimeZoneInfo.Local.Id}"; + + var pending = NewTcs(); + _serverTime = pending; + try + { + _socket.reqCurrentTime(); + + if (!await WaitAsync(pending.Task, TimeSpan.FromSeconds(5), ct).ConfigureAwait(false)) + { + _logger.Info(LogModule, context + ", TWS-Serverzeit nicht ermittelbar."); + return; + } + + var serverUtc = DateTimeOffset.FromUnixTimeSeconds(pending.Task.Result).UtcDateTime; + var skew = (serverUtc - DateTime.UtcNow).TotalSeconds; + + context += $", TWS-Serverzeit {serverUtc:yyyy-MM-dd HH:mm:ss}Z, " + + $"Uhrenversatz {skew.ToString("+0.0;-0.0;0", CultureInfo.InvariantCulture)} s"; + + if (Math.Abs(skew) > MaxClockSkewSeconds) + _logger.Warn(LogModule, context + + " – die Uhren laufen auseinander. In virtuellen Maschinen ist das ein häufiger " + + "Fehler; die Zeitsynchronisation des Hosts prüfen, sonst wandern Buchungszeiten."); + else + _logger.Info(LogModule, context + "."); + } + finally + { + _serverTime = null; + } + } + + /// + /// Fasst nach jedem Abruf zusammen, wie viele Zeitstempel TWS mit Zonenangabe gemeldet hat und + /// wie viele über die Betriebszeitzone angenommen wurden. Die angenommenen sind die + /// Stelle, an der ein Wechsel zwischen EU- und US-Host lautlos danebenliegt. + /// + private void LogTimeProvenance(IReadOnlyList stamps) + { + if (stamps.Count == 0) return; + + var reported = stamps.Count(t => t.Source == IbkrMapping.ExecutionTimeSource.ReportedZone); + var assumed = stamps.Count(t => t.IsAssumed); + var unreadable = stamps.Count(t => t.Source == IbkrMapping.ExecutionTimeSource.Unparsable); + + var zones = string.Join(", ", stamps.Where(t => t.ReportedZone is not null) + .Select(t => t.ReportedZone!) + .Distinct()); + + var text = $"Zeitstempel von {stamps.Count} Ausführung(en): {reported} mit gemeldeter Zone" + + (zones.Length > 0 ? $" ({zones})" : "") + + $", {assumed} über die Betriebszeitzone {AppTimeZone.CurrentId}" + + (unreadable > 0 ? $", {unreadable} unlesbar" : "") + "."; + + _logger.Info(LogModule, text); + + // Unlesbare Zeitstempel sind immer ein Defekt – die Ausführung landet sonst auf DateTime.MinValue. + if (unreadable > 0) + _logger.Warn(LogModule, + $"{unreadable} Ausführung(en) mit unlesbarem Zeitstempel – das Format von TWS hat sich " + + "vermutlich geändert. IbkrMapping.ResolveExecutionTime prüfen."); + + // Angenommene Zonen sind nur dann heikel, wenn Betriebs- und Systemzeitzone auseinandergehen: + // dann ist nicht mehr offensichtlich, gegen welche Uhr TWS die Zeit gemeldet hat. + if (assumed > 0 && !_zoneWarningIssued && + !string.Equals(AppTimeZone.CurrentId, TimeZoneInfo.Local.Id, StringComparison.OrdinalIgnoreCase)) + { + _zoneWarningIssued = true; + _logger.Warn(LogModule, + $"{assumed} Zeitstempel ohne Zonenangabe wurden gegen die Betriebszeitzone " + + $"{AppTimeZone.CurrentId} gerechnet, das System läuft aber auf {TimeZoneInfo.Local.Id}. " + + "Stimmt Trading.ApplicationTimeZoneId nicht mit der Zeitzone des TWS-Hosts überein, " + + "liegen die Buchungszeiten daneben. Einmal gegen TWS gegenprüfen."); + } + } + private void StartReader() { var reader = new EReader(_socket, _signal); @@ -327,6 +426,8 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); + LogTimeProvenance(slot.Timestamps); + return slot.Items .Select(e => slot.Commissions.TryGetValue(e.ExecId, out var c) ? e with { Commission = c.Amount, CommissionCurrency = c.Currency } @@ -452,14 +553,20 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable public override void accountDownloadEnd(string account) => _portfolio?.Complete(); + /// Antwort auf reqCurrentTime – Sekunden seit Epoch, Basis des Uhrenvergleichs. + public override void currentTime(long time) => _serverTime?.TrySetResult(time); + public override void execDetails(int reqId, Contract contract, Execution execution) { if (!_executions.TryGetValue(reqId, out var slot)) return; + var stamp = IbkrMapping.ResolveExecutionTime(execution.Time, AppTimeZone.Current); + slot.Timestamps.Add(stamp); + slot.Items.Add(new BrokerExecution { ExecId = execution.ExecId, - Time = IbkrMapping.ParseExecutionTime(execution.Time, AppTimeZone.Current) ?? DateTime.MinValue, + Time = stamp.Utc ?? DateTime.MinValue, Symbol = contract.Symbol, SecType = contract.SecType, Side = IbkrMapping.ParseSide(execution.Side), @@ -569,7 +676,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable ? parsed : 0m; - private static TaskCompletionSource NewTcs() => + private static TaskCompletionSource NewTcs() => NewTcs(); + + private static TaskCompletionSource NewTcs() => new(TaskCreationOptions.RunContinuationsAsynchronously); private static async Task WaitAsync(Task task, TimeSpan timeout, CancellationToken ct) @@ -637,5 +746,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable { public readonly List Items = new(); public readonly ConcurrentDictionary Commissions = new(); + + // Herkunft der Zeitangaben, damit nach dem Abruf zusammengefasst werden kann, wie viele + // Zeitpunkte TWS gemeldet und wie viele wir angenommen haben. + public readonly List Timestamps = new(); } } diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs index 7dc0c0b..6b7817e 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs @@ -89,6 +89,43 @@ internal static class IbkrMapping (decimal)(ask > 0 ? ask : price)); } + /// + /// Woher die Zeitzone einer Ausführung stammt. Ohne diese Angabe lässt sich im Nachhinein + /// nicht mehr feststellen, ob ein Buchungszeitpunkt belastbar ist oder auf einer Annahme beruht – + /// und genau das ist der Fehler, der bei einem Umzug zwischen EU- und US-Host lautlos entsteht. + /// + public enum ExecutionTimeSource + { + /// TWS hat eine Zone gemeldet und sie war auflösbar – der verlässliche Fall. + ReportedZone, + + /// TWS meldete keine Zone; es galt die Betriebszeitzone der Instanz. + FallbackZone, + + /// TWS meldete eine Zone, die dieses System nicht kennt; es galt die Betriebszeitzone. + UnknownZoneFallback, + + /// Der Zeitstempel war nicht lesbar. + Unparsable + } + + /// + /// Ergebnis der Zeitauflösung samt Herkunft – die Grundlage für die Beobachtung im Log. + /// + /// Zeitpunkt in UTC, oder null wenn nicht lesbar. + /// Woher die verwendete Zone stammt. + /// Die tatsächlich zur Umrechnung benutzte Zone. + /// Was TWS gemeldet hat, oder null bei fehlender Angabe. + public readonly record struct ExecutionTimestamp( + DateTime? Utc, + ExecutionTimeSource Source, + string ZoneUsed, + string? ReportedZone) + { + /// true, wenn der Zeitpunkt auf einer Annahme statt auf einer Meldung von TWS beruht. + public bool IsAssumed => Source is ExecutionTimeSource.FallbackZone or ExecutionTimeSource.UnknownZoneFallback; + } + /// Ausführungsseite laut TWS: "BOT" = gekauft, "SLD" = verkauft. public static TradeSide ParseSide(string side) => side.Trim().ToUpperInvariant() is "SLD" or "SELL" ? TradeSide.Sell : TradeSide.Buy; @@ -109,32 +146,48 @@ internal static class IbkrMapping /// /// Zeitzone für Meldungen ohne Zonenangabe – die Betriebszeitzone (AppTimeZone.Current). /// - public static DateTime? ParseExecutionTime(string? raw, TimeZoneInfo fallbackZone) + public static DateTime? ParseExecutionTime(string? raw, TimeZoneInfo fallbackZone) => + ResolveExecutionTime(raw, fallbackZone).Utc; + + /// + /// Wie , liefert aber zusätzlich die Herkunft der + /// verwendeten Zeitzone. Nur damit lässt sich im Nachhinein unterscheiden, ob ein + /// Buchungszeitpunkt von TWS gemeldet oder von uns angenommen wurde. + /// + public static ExecutionTimestamp ResolveExecutionTime(string? raw, TimeZoneInfo fallbackZone) { - if (string.IsNullOrWhiteSpace(raw)) return null; + if (string.IsNullOrWhiteSpace(raw)) + return new ExecutionTimestamp(null, ExecutionTimeSource.Unparsable, fallbackZone.Id, null); // 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; - if (!DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss", + if (parts.Length < 2 || + !DateTime.TryParseExact($"{parts[0]} {parts[1]}", "yyyyMMdd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var local)) - return null; + return new ExecutionTimestamp(null, ExecutionTimeSource.Unparsable, fallbackZone.Id, null); // Dritter Teil, falls vorhanden, ist die Zeitzone der Börse. - var zone = parts.Length >= 3 ? ResolveZone(parts[2]) ?? fallbackZone : fallbackZone; + var reported = parts.Length >= 3 ? parts[2] : null; + var resolved = reported is null ? null : ResolveZone(reported); + + var zone = resolved ?? fallbackZone; + var source = reported is null ? ExecutionTimeSource.FallbackZone + : resolved is null ? ExecutionTimeSource.UnknownZoneFallback + : ExecutionTimeSource.ReportedZone; 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); + var utc = zone.IsInvalidTime(local) + ? DateTime.SpecifyKind(local - zone.BaseUtcOffset, DateTimeKind.Utc) + : TimeZoneInfo.ConvertTimeToUtc(local, zone); - return TimeZoneInfo.ConvertTimeToUtc(local, zone); + return new ExecutionTimestamp(utc, source, zone.Id, reported); } /// diff --git a/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs b/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs index b6e7f83..b5cc96c 100644 --- a/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs +++ b/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs @@ -165,6 +165,71 @@ public class IbkrMappingTests private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + // ─── Herkunft der Zeitangabe (Beobachtbarkeit) ──────────────────────────── + // + // Ohne die Herkunft lässt sich später nicht mehr unterscheiden, ob ein Buchungszeitpunkt von + // TWS gemeldet oder von uns angenommen wurde. Genau daran hängt die Diagnose, wenn eine + // Instanz von einem EU- auf einen US-Host umzieht. + + [Fact] + public void ResolveExecutionTime_MitZone_MeldetGemeldeteHerkunft() + { + var stamp = IbkrMapping.ResolveExecutionTime("20260804 09:30:00 America/New_York", Berlin); + + stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.ReportedZone); + stamp.IsAssumed.Should().BeFalse(); + stamp.ReportedZone.Should().Be("America/New_York"); + stamp.ZoneUsed.Should().Contain("New_York"); + stamp.Utc.Should().Be(new DateTime(2026, 8, 4, 13, 30, 0, DateTimeKind.Utc)); + } + + [Fact] + public void ResolveExecutionTime_OhneZone_MeldetAnnahme() + { + // Der Normalfall bei execDetails - und damit die Stelle, die beobachtet werden muss. + var stamp = IbkrMapping.ResolveExecutionTime("20260804 17:39:18", Berlin); + + stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.FallbackZone); + stamp.IsAssumed.Should().BeTrue(); + stamp.ReportedZone.Should().BeNull(); + stamp.ZoneUsed.Should().Be(Berlin.Id); + } + + [Fact] + public void ResolveExecutionTime_UnbekannteZone_IstAlsAnnahmeErkennbar() + { + // Weicht auf die Betriebszeitzone aus - aber unterscheidbar von "TWS meldete nichts", + // denn hier hat TWS etwas gemeldet, das dieses System nicht kennt. + var stamp = IbkrMapping.ResolveExecutionTime("20260804 17:39:18 Gibt/EsNicht", Berlin); + + stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.UnknownZoneFallback); + stamp.IsAssumed.Should().BeTrue(); + stamp.ReportedZone.Should().Be("Gibt/EsNicht"); + stamp.ZoneUsed.Should().Be(Berlin.Id); + } + + [Fact] + public void ResolveExecutionTime_Unlesbar_IstAlsDefektErkennbar() + { + var stamp = IbkrMapping.ResolveExecutionTime("Unsinn", Berlin); + + stamp.Source.Should().Be(IbkrMapping.ExecutionTimeSource.Unparsable); + stamp.Utc.Should().BeNull(); + // Unlesbar ist keine Annahme, sondern ein Defekt - die Warnung im Log ist eine andere. + stamp.IsAssumed.Should().BeFalse(); + } + + [Fact] + public void ResolveExecutionTime_UsHost_LiefertDenselbenZeitpunktWieEuHost() + { + // Der eigentliche Regressionsschutz fuer den geplanten Umzug: Meldet TWS die Zone, darf die + // Betriebszeitzone der Instanz das Ergebnis NICHT mehr veraendern. + const string raw = "20260804 09:30:00 America/New_York"; + + IbkrMapping.ResolveExecutionTime(raw, Berlin).Utc + .Should().Be(IbkrMapping.ResolveExecutionTime(raw, NewYork).Utc); + } + [Fact] public void ParseExecutionTime_OhneZone_RechnetGegenDieBetriebszeitzone() {