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:
@@ -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 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)
|
## 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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user