Orders, die beim Broker liegen, gelten nicht mehr als gescheitert
Am geschlossenen Markt gemessen: IBKR nimmt eine Order an und merkt sie fuer die naechste Eroeffnung vor. Dazu schickt es Code 399 - eine Mitteilung, deren Text mit "Warning" beginnt. Der Adapter las jede Meldung aus dem Fehler-Callback als Ablehnung, meldete "Order abgelehnt", und das Kapitalmodell gab die Bindung frei. Auf dem Testkonto lagen dadurch 67 Orders ueber 306 BAS, 140 SAP und 43 ALV beim Broker, vorgemerkt fuer Montag 09:00, ohne eine einzige Reservierung dahinter. OrderResult kannte nur "erfolgreich" und "gescheitert". Der haeufigste Fall passte in keines von beiden. Es gibt jetzt einen dritten Ausgang - Working: abgesendet, Ausgang offen. Die Trennlinie ist, ob BEWIESEN ist, dass beim Broker nichts liegt; ohne Beweis bleibt das Kapital gebunden. Eine Bindung, die zu lange steht, kostet Spielraum und faellt auf. Eine, die zu frueh verschwindet, kostet Geld und faellt nicht auf. - 399/404 sind Ordermeldungen, keine Ablehnungen; nur 201/202/203 u. a. beenden eine Order beweisbar. Ein Verbindungsabbruch beendet unsere Sicht, nicht die Order. - IsDeadStatus ist enger als IsTerminalStatus: "Inactive" ist kein Beweis. - ExecutionService haelt die Bindung bei Working (MarkSubmittedAsync statt CancelReservationAsync) und journalisiert Pending/OrderWorking statt Failed. - Neu lesbar: das Orderbuch des Brokers (reqAllOpenOrders) samt Storno. --open-orders stellt Orderbuch und eigene Bindungen gegenueber, --cancel-order <ID>|all --confirm nimmt zurueck (Sammelstorno nur im Paper-Modus). - Der Wiederanlauf-Abgleich lief bisher gegen eine leere Orderliste und haette ab jetzt jede gesunde Bindung in Quarantaene gestellt. Er sitzt jetzt im ReservationWatchdogWorker, hinter dem Beweis, dass der Broker antwortet. 457 Tests gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
// Portfolio-Abruf ist ein kontoweites Abonnement, keine reqId-Anfrage – daher nur ein Slot.
|
||||
private PortfolioSlot? _portfolio;
|
||||
|
||||
// Dasselbe gilt fuer das Orderbuch: reqAllOpenOrders traegt keine reqId.
|
||||
private OpenOrderSlot? _openOrders;
|
||||
|
||||
private TaskCompletionSource<bool> _handshake = NewTcs();
|
||||
private TaskCompletionSource<long>? _serverTime;
|
||||
|
||||
@@ -628,26 +631,37 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
{
|
||||
_socket.placeOrder(orderId, contract, IbkrMapping.BuildOrder(request, orderId, _account));
|
||||
|
||||
// Ab hier ist die Order abgesendet. Alles Weitere entscheidet nur noch darüber, ob wir
|
||||
// BEWEISEN können, dass beim Broker nichts liegt. Ohne Beweis lautet die Antwort
|
||||
// "Working" – und gebundenes Kapital bleibt gebunden.
|
||||
var id = orderId.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
|
||||
{
|
||||
// Die Order ist übermittelt und liegt womöglich aktiv bei IBKR. Sie hier als
|
||||
// reinen Fehlschlag zu buchen wäre falsch – deshalb Order-ID und letzter Status
|
||||
// in die Meldung, damit der Betreiber sie in TWS wiederfindet.
|
||||
var status = string.IsNullOrEmpty(slot.Status) ? "keine Rückmeldung" : slot.Status;
|
||||
return OrderResult.Fail(
|
||||
return OrderResult.Working(id, status,
|
||||
$"Order {orderId} ({request.Side} {request.Quantity}x {request.Symbol}) wurde übermittelt, " +
|
||||
$"blieb aber {timeout.TotalSeconds:F0}s ohne Endstatus (zuletzt: {status}). " +
|
||||
"Sie kann bei IBKR weiterhin aktiv sein und muss dort geprüft werden.");
|
||||
"Sie kann bei IBKR weiterhin aktiv sein.");
|
||||
}
|
||||
|
||||
if (slot.Error is not null)
|
||||
return OrderResult.Fail($"Order {orderId} abgelehnt: {slot.Error}");
|
||||
return slot.Rejected
|
||||
? OrderResult.Fail($"Order {orderId} abgelehnt: {slot.Error}")
|
||||
// Eine Meldung, die die Order nicht beweisbar beendet, etwa ein
|
||||
// Verbindungsabbruch: Unsere Sicht ist weg, die Order womoeglich nicht.
|
||||
: OrderResult.Working(id, slot.Status,
|
||||
$"Order {orderId}: {slot.Error} – ob sie beim Broker liegt, ist damit nicht entschieden.");
|
||||
|
||||
if (slot.Status == "Filled" && slot.Filled > 0)
|
||||
return OrderResult.Filled(orderId.ToString(CultureInfo.InvariantCulture),
|
||||
slot.Filled, (decimal)slot.AvgFillPrice);
|
||||
return OrderResult.Filled(id, slot.Filled, (decimal)slot.AvgFillPrice);
|
||||
|
||||
return OrderResult.Fail($"Order {orderId} endete ohne Ausführung (Status {slot.Status}).");
|
||||
if (IbkrMapping.IsDeadStatus(slot.Status))
|
||||
return OrderResult.Fail($"Order {orderId} endete ohne Ausführung (Status {slot.Status}).");
|
||||
|
||||
return OrderResult.Working(id, slot.Status,
|
||||
$"Order {orderId} steht auf {slot.Status}" +
|
||||
(slot.Notice is null ? "" : $" – {slot.Notice}") + ".");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -655,7 +669,88 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EWrapper-Callbacks (laufen auf dem Reader-Thread) ────────────────────
|
||||
/// <summary>
|
||||
/// Fragt das Orderbuch des Kontos ab – alle offenen Orders, auch die anderer Client-IDs und die
|
||||
/// aus früheren Sitzungen.
|
||||
///
|
||||
/// <para><c>reqAllOpenOrders</c> statt <c>reqOpenOrders</c>: Letzteres liefert nur, was <b>dieser</b>
|
||||
/// Client selbst abgesetzt hat. Eine Order, die der Dienst vor einem Neustart platziert hat,
|
||||
/// fände man damit nicht – und genau die ist die gefährliche.</para>
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BrokerOrder>> RequestOpenOrdersAsync(TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
var slot = new OpenOrderSlot();
|
||||
_openOrders = slot;
|
||||
try
|
||||
{
|
||||
_socket.reqAllOpenOrders();
|
||||
|
||||
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
|
||||
{
|
||||
_logger.Warn(LogModule,
|
||||
"Zeitüberschreitung bei der Abfrage der offenen Orders. Das Ergebnis ist unvollständig " +
|
||||
"und darf nicht als \"keine offenen Orders\" gelesen werden.");
|
||||
return Array.Empty<BrokerOrder>();
|
||||
}
|
||||
|
||||
lock (slot.Orders) return slot.Orders.ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_openOrders = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nimmt eine Order zurück und wartet auf die Bestätigung des Brokers.
|
||||
///
|
||||
/// <para>Ohne das Warten wäre die Rückmeldung wertlos: <c>cancelOrder</c> ist ein Zuruf, keine
|
||||
/// Zusage. Bestätigt der Broker nicht, liegt die Order weiter – und der Aufrufer muss das
|
||||
/// erfahren, statt sie für erledigt zu halten.</para>
|
||||
/// </summary>
|
||||
public async Task<bool> CancelOrderAsync(int orderId, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
var slot = new OrderSlot();
|
||||
_orders[orderId] = slot;
|
||||
try
|
||||
{
|
||||
_socket.cancelOrder(orderId);
|
||||
|
||||
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
|
||||
{
|
||||
_logger.Warn(LogModule, $"Order {orderId}: keine Bestätigung der Stornierung.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var cancelled = IbkrMapping.IsDeadStatus(slot.Status) || slot.Rejected;
|
||||
_logger.Info(LogModule, cancelled
|
||||
? $"Order {orderId} storniert."
|
||||
: $"Order {orderId} nicht storniert (Status {slot.Status}).");
|
||||
return cancelled;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_orders.TryRemove(orderId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EWrapper-Callbacks (laufen auf dem Reader-Thread)
|
||||
|
||||
public override void openOrder(int orderId, Contract contract, Order order, OrderState state)
|
||||
{
|
||||
var slot = _openOrders;
|
||||
if (slot is null) return;
|
||||
|
||||
var mapped = IbkrMapping.ToBrokerOrder(orderId, contract, order, state);
|
||||
lock (slot.Orders)
|
||||
{
|
||||
// TWS wiederholt dieselbe Order gelegentlich innerhalb eines Abrufs.
|
||||
if (!slot.Orders.Any(o => o.OrderId == mapped.OrderId && o.PermId == mapped.PermId))
|
||||
slot.Orders.Add(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
public override void openOrderEnd() => _openOrders?.Complete();
|
||||
|
||||
public override void nextValidId(int orderId)
|
||||
{
|
||||
@@ -851,6 +946,23 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
double avgFillPrice, int permId, int parentId, double lastFillPrice, int clientId,
|
||||
string whyHeld, double mktCapPrice)
|
||||
{
|
||||
// reqAllOpenOrders liefert zu jeder Order beide Callbacks. openOrder kennt die Fuellmenge
|
||||
// nicht - sie kommt hier nach und wird in die laufende Abfrage nachgetragen.
|
||||
if (_openOrders is { } book)
|
||||
{
|
||||
lock (book.Orders)
|
||||
{
|
||||
var index = book.Orders.FindIndex(o => o.OrderId == orderId);
|
||||
if (index >= 0)
|
||||
book.Orders[index] = book.Orders[index] with
|
||||
{
|
||||
Status = status,
|
||||
Filled = (int)filled,
|
||||
Remaining = (int)remaining
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!_orders.TryGetValue(orderId, out var slot)) return;
|
||||
|
||||
slot.Status = status;
|
||||
@@ -869,6 +981,24 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
}
|
||||
|
||||
var text = $"[{errorCode}] {errorMsg}";
|
||||
|
||||
// Ordermeldungen zuerst und getrennt: Ob eine Meldung eine Order beendet, entscheidet der
|
||||
// Code – nicht die Tatsache, dass sie über den Fehler-Callback kam. IBKR schickt über
|
||||
// denselben Weg auch Hinweise zu angenommenen Orders (#489).
|
||||
if (id >= 0 && _orders.TryGetValue(id, out var order))
|
||||
{
|
||||
if (IbkrMapping.IsOrderNotice(errorCode))
|
||||
{
|
||||
order.Notice = errorMsg;
|
||||
_logger.Info(LogModule, $"Order {id} angenommen, Hinweis des Brokers: {text}");
|
||||
return;
|
||||
}
|
||||
|
||||
order.Rejected = IbkrMapping.IsOrderRejection(errorCode);
|
||||
order.Fail(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (id >= 0 && FailPending(id, text)) return;
|
||||
|
||||
_logger.Warn(LogModule, text);
|
||||
@@ -911,7 +1041,8 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
if (_quotes .TryGetValue(id, out var q)) { q.Fail(error); return true; }
|
||||
if (_accounts .TryGetValue(id, out var a)) { a.Fail(error); return true; }
|
||||
if (_contracts .TryGetValue(id, out var c)) { c.Fail(error); return true; }
|
||||
if (_orders .TryGetValue(id, out var o)) { o.Fail(error); return true; }
|
||||
// _orders fehlt hier absichtlich: siehe error(int, int, string) – dort wird unterschieden,
|
||||
// ob eine Meldung die Order beendet oder nur begleitet.
|
||||
if (_executions.TryGetValue(id, out var e)) { e.Fail(error); return true; }
|
||||
return false;
|
||||
}
|
||||
@@ -1008,9 +1139,27 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
|
||||
private sealed class OrderSlot : Slot
|
||||
{
|
||||
public string Status = "";
|
||||
public int Filled;
|
||||
public double AvgFillPrice;
|
||||
public string Status = "";
|
||||
public int Filled;
|
||||
public double AvgFillPrice;
|
||||
|
||||
/// <summary>
|
||||
/// Steht fest, dass beim Broker nichts liegt? Nur dann darf gebundenes Kapital zurück.
|
||||
/// Vorgabe <c>false</c>: Ein Verbindungsabbruch beendet unsere Sicht auf die Order, nicht
|
||||
/// die Order.
|
||||
/// </summary>
|
||||
public bool Rejected;
|
||||
|
||||
/// <summary>Hinweis des Brokers zu einer angenommenen Order (etwa 399).</summary>
|
||||
public string? Notice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Das Orderbuch ist – wie das Portfolio – ein kontoweites Abonnement ohne eigene reqId.
|
||||
/// </summary>
|
||||
private sealed class OpenOrderSlot : Slot
|
||||
{
|
||||
public readonly List<BrokerOrder> Orders = new();
|
||||
}
|
||||
|
||||
private sealed class PortfolioSlot : Slot
|
||||
|
||||
Reference in New Issue
Block a user