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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-22 10:44:41 +02:00
co-authored by Claude Opus 5
parent c176b05ea1
commit 87194bfc48
4 changed files with 342 additions and 12 deletions
+100 -1
View File
@@ -68,7 +68,9 @@ der Adapter ab und bleibt inaktiv, statt auf dem falschen Konto zu handeln.
## 7. Verifikation ## 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 - [ ] 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. 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 - Einstellungsänderungen im API-Dialog immer mit **Übernehmen/OK** abschließen; solange der
Dialog offen ist, gelten sie nicht. 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 35 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) ## Unterschiede Live-Betrieb (später)
| Punkt | Paper | Live | | Punkt | Paper | Live |
@@ -41,6 +41,13 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
private PortfolioSlot? _portfolio; private PortfolioSlot? _portfolio;
private TaskCompletionSource<bool> _handshake = NewTcs(); private TaskCompletionSource<bool> _handshake = NewTcs();
private TaskCompletionSource<long>? _serverTime;
/// <summary>Ab diesem Versatz zur TWS-Serverzeit gilt die Uhr des Hosts als verstellt.</summary>
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 _ready;
private volatile bool _disposed; private volatile bool _disposed;
private int _nextRequestId = 1000; private int _nextRequestId = 1000;
@@ -125,12 +132,104 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
} }
_ready = true; _ready = true;
_zoneWarningIssued = false;
_socket.reqMarketDataType(_marketDataType); _socket.reqMarketDataType(_marketDataType);
_logger.Info(LogModule, _logger.Info(LogModule,
$"Verbunden mit {_host}:{_port} (Client {_clientId}), Konto {_account ?? "unbekannt"}."); $"Verbunden mit {_host}:{_port} (Client {_clientId}), Konto {_account ?? "unbekannt"}.");
await LogTimeContextAsync(ct).ConfigureAwait(false);
return true; return true;
} }
/// <summary>
/// Schreibt den vollständigen Zeitkontext einer Verbindung ins Log: Betriebszeitzone,
/// Systemzeitzone und den Versatz zur Uhr des TWS-Servers.
///
/// <para><b>Wozu:</b> 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.</para>
/// </summary>
private async Task LogTimeContextAsync(CancellationToken ct)
{
var context = $"Zeitkontext: Betriebszeitzone {AppTimeZone.CurrentId}, " +
$"Systemzeitzone {TimeZoneInfo.Local.Id}";
var pending = NewTcs<long>();
_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;
}
}
/// <summary>
/// Fasst nach jedem Abruf zusammen, wie viele Zeitstempel TWS mit Zonenangabe gemeldet hat und
/// wie viele über die Betriebszeitzone <b>angenommen</b> wurden. Die angenommenen sind die
/// Stelle, an der ein Wechsel zwischen EU- und US-Host lautlos danebenliegt.
/// </summary>
private void LogTimeProvenance(IReadOnlyList<IbkrMapping.ExecutionTimestamp> 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() private void StartReader()
{ {
var reader = new EReader(_socket, _signal); 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); await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
LogTimeProvenance(slot.Timestamps);
return slot.Items return slot.Items
.Select(e => slot.Commissions.TryGetValue(e.ExecId, out var c) .Select(e => slot.Commissions.TryGetValue(e.ExecId, out var c)
? e with { Commission = c.Amount, CommissionCurrency = c.Currency } ? 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(); public override void accountDownloadEnd(string account) => _portfolio?.Complete();
/// <summary>Antwort auf <c>reqCurrentTime</c> Sekunden seit Epoch, Basis des Uhrenvergleichs.</summary>
public override void currentTime(long time) => _serverTime?.TrySetResult(time);
public override void execDetails(int reqId, Contract contract, Execution execution) public override void execDetails(int reqId, Contract contract, Execution execution)
{ {
if (!_executions.TryGetValue(reqId, out var slot)) return; 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 slot.Items.Add(new BrokerExecution
{ {
ExecId = execution.ExecId, ExecId = execution.ExecId,
Time = IbkrMapping.ParseExecutionTime(execution.Time, AppTimeZone.Current) ?? DateTime.MinValue, Time = stamp.Utc ?? DateTime.MinValue,
Symbol = contract.Symbol, Symbol = contract.Symbol,
SecType = contract.SecType, SecType = contract.SecType,
Side = IbkrMapping.ParseSide(execution.Side), Side = IbkrMapping.ParseSide(execution.Side),
@@ -569,7 +676,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
? parsed ? parsed
: 0m; : 0m;
private static TaskCompletionSource<bool> NewTcs() => private static TaskCompletionSource<bool> NewTcs() => NewTcs<bool>();
private static TaskCompletionSource<T> NewTcs<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously); new(TaskCreationOptions.RunContinuationsAsynchronously);
private static async Task<bool> WaitAsync(Task task, TimeSpan timeout, CancellationToken ct) private static async Task<bool> WaitAsync(Task task, TimeSpan timeout, CancellationToken ct)
@@ -637,5 +746,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
{ {
public readonly List<BrokerExecution> Items = new(); public readonly List<BrokerExecution> Items = new();
public readonly ConcurrentDictionary<string, (decimal Amount, string Currency)> Commissions = new(); public readonly ConcurrentDictionary<string, (decimal Amount, string Currency)> 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<IbkrMapping.ExecutionTimestamp> Timestamps = new();
} }
} }
@@ -89,6 +89,43 @@ internal static class IbkrMapping
(decimal)(ask > 0 ? ask : price)); (decimal)(ask > 0 ? ask : price));
} }
/// <summary>
/// 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.
/// </summary>
public enum ExecutionTimeSource
{
/// <summary>TWS hat eine Zone gemeldet und sie war auflösbar der verlässliche Fall.</summary>
ReportedZone,
/// <summary>TWS meldete keine Zone; es galt die Betriebszeitzone der Instanz.</summary>
FallbackZone,
/// <summary>TWS meldete eine Zone, die dieses System nicht kennt; es galt die Betriebszeitzone.</summary>
UnknownZoneFallback,
/// <summary>Der Zeitstempel war nicht lesbar.</summary>
Unparsable
}
/// <summary>
/// Ergebnis der Zeitauflösung samt Herkunft die Grundlage für die Beobachtung im Log.
/// </summary>
/// <param name="Utc">Zeitpunkt in UTC, oder <c>null</c> wenn nicht lesbar.</param>
/// <param name="Source">Woher die verwendete Zone stammt.</param>
/// <param name="ZoneUsed">Die tatsächlich zur Umrechnung benutzte Zone.</param>
/// <param name="ReportedZone">Was TWS gemeldet hat, oder <c>null</c> bei fehlender Angabe.</param>
public readonly record struct ExecutionTimestamp(
DateTime? Utc,
ExecutionTimeSource Source,
string ZoneUsed,
string? ReportedZone)
{
/// <summary>true, wenn der Zeitpunkt auf einer Annahme statt auf einer Meldung von TWS beruht.</summary>
public bool IsAssumed => Source is ExecutionTimeSource.FallbackZone or ExecutionTimeSource.UnknownZoneFallback;
}
/// <summary>Ausführungsseite laut TWS: "BOT" = gekauft, "SLD" = verkauft.</summary> /// <summary>Ausführungsseite laut TWS: "BOT" = gekauft, "SLD" = verkauft.</summary>
public static TradeSide ParseSide(string side) => public static TradeSide ParseSide(string side) =>
side.Trim().ToUpperInvariant() is "SLD" or "SELL" ? TradeSide.Sell : TradeSide.Buy; side.Trim().ToUpperInvariant() is "SLD" or "SELL" ? TradeSide.Sell : TradeSide.Buy;
@@ -109,32 +146,48 @@ internal static class IbkrMapping
/// <param name="fallbackZone"> /// <param name="fallbackZone">
/// Zeitzone für Meldungen ohne Zonenangabe die Betriebszeitzone (<c>AppTimeZone.Current</c>). /// Zeitzone für Meldungen ohne Zonenangabe die Betriebszeitzone (<c>AppTimeZone.Current</c>).
/// </param> /// </param>
public static DateTime? ParseExecutionTime(string? raw, TimeZoneInfo fallbackZone) public static DateTime? ParseExecutionTime(string? raw, TimeZoneInfo fallbackZone) =>
ResolveExecutionTime(raw, fallbackZone).Utc;
/// <summary>
/// Wie <see cref="ParseExecutionTime"/>, liefert aber zusätzlich die <b>Herkunft</b> der
/// verwendeten Zeitzone. Nur damit lässt sich im Nachhinein unterscheiden, ob ein
/// Buchungszeitpunkt von TWS gemeldet oder von uns angenommen wurde.
/// </summary>
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 // Datum und Uhrzeit trennen TWS je nach Aufruf per Leerzeichen oder Bindestrich
// ("20260804-17:52:56" ist das Format, das auch der Anfragefilter nutzt). // ("20260804-17:52:56" ist das Format, das auch der Anfragefilter nutzt).
var parts = raw.Replace('-', ' ') var parts = raw.Replace('-', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); .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)) 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. // 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); local = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
// Bei der Zeitumstellung kann die Ortszeit ungültig (Vorstellen) oder doppelt (Zurückstellen) // 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 // sein. ConvertTimeToUtc würde bei ungültigen Werten werfen eine Ausführung darf daran
// nicht verlorengehen, deshalb der ausdrückliche Versatz. // nicht verlorengehen, deshalb der ausdrückliche Versatz.
if (zone.IsInvalidTime(local)) var utc = zone.IsInvalidTime(local)
return DateTime.SpecifyKind(local - zone.BaseUtcOffset, DateTimeKind.Utc); ? DateTime.SpecifyKind(local - zone.BaseUtcOffset, DateTimeKind.Utc)
: TimeZoneInfo.ConvertTimeToUtc(local, zone);
return TimeZoneInfo.ConvertTimeToUtc(local, zone); return new ExecutionTimestamp(utc, source, zone.Id, reported);
} }
/// <summary> /// <summary>
@@ -165,6 +165,71 @@ public class IbkrMappingTests
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); 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] [Fact]
public void ParseExecutionTime_OhneZone_RechnetGegenDieBetriebszeitzone() public void ParseExecutionTime_OhneZone_RechnetGegenDieBetriebszeitzone()
{ {