diff --git a/src/IBKRTrader.Core/Capital/CapitalService.cs b/src/IBKRTrader.Core/Capital/CapitalService.cs
index cfc860c..f32a082 100644
--- a/src/IBKRTrader.Core/Capital/CapitalService.cs
+++ b/src/IBKRTrader.Core/Capital/CapitalService.cs
@@ -293,6 +293,10 @@ public sealed class CapitalService : ICapitalService
CancellationToken ct = default) =>
_reservations.SettleAsync(reservationId, filledQuantity, actualAmount, brokerOrderId, ct);
+ ///
+ public Task MarkSubmittedAsync(long reservationId, string? brokerOrderId, CancellationToken ct = default) =>
+ _reservations.MarkSubmittedAsync(reservationId, brokerOrderId, ct);
+
///
public Task CancelReservationAsync(long reservationId, string reason, CancellationToken ct = default) =>
_reservations.CancelAsync(reservationId, reason, ct);
diff --git a/src/IBKRTrader.Core/Capital/ICapitalService.cs b/src/IBKRTrader.Core/Capital/ICapitalService.cs
index fa043b6..1bda16e 100644
--- a/src/IBKRTrader.Core/Capital/ICapitalService.cs
+++ b/src/IBKRTrader.Core/Capital/ICapitalService.cs
@@ -37,6 +37,15 @@ public interface ICapitalService
long reservationId, int filledQuantity, decimal actualAmount, string? brokerOrderId = null,
CancellationToken ct = default);
+ ///
+ /// Vermerkt, dass die Order beim Broker liegt – die Bindung bleibt bestehen.
+ ///
+ /// Der Gegenspieler von . Bis #489 gab es zu
+ /// einer nicht ausgeführten Order nur das Freigeben. Eine Order, die beim Broker wartet, wurde
+ /// damit wie eine gescheiterte behandelt: Das Geld war wieder frei, die Order lebte weiter.
+ ///
+ Task MarkSubmittedAsync(long reservationId, string? brokerOrderId, CancellationToken ct = default);
+
/// Gibt eine Bindung frei, ohne dass ausgeführt wurde.
Task CancelReservationAsync(long reservationId, string reason, CancellationToken ct = default);
}
diff --git a/src/IBKRTrader.Core/Capital/ReservationService.cs b/src/IBKRTrader.Core/Capital/ReservationService.cs
index 8b7f2f2..fa3b729 100644
--- a/src/IBKRTrader.Core/Capital/ReservationService.cs
+++ b/src/IBKRTrader.Core/Capital/ReservationService.cs
@@ -325,6 +325,18 @@ public sealed class ReservationService
return overdue;
}
+ ///
+ /// Wie viele Bindungen warten noch auf ein Ergebnis? Reine Auskunft – ändert nichts.
+ ///
+ public async Task CountPendingAsync(CancellationToken ct = default)
+ {
+ await using var db = await _dbf.CreateDbContextAsync(ct);
+ return await db.Reservations.CountAsync(
+ r => r.State == ReservationState.Reserved ||
+ r.State == ReservationState.Submitted ||
+ r.State == ReservationState.PartiallyFilled, ct);
+ }
+
///
/// Abgleich beim Start: jede nicht abgeschlossene Bindung gegen die offenen Orders des Brokers.
///
diff --git a/src/IBKRTrader.Core/Persistence/Entities/AnalysisEntities.cs b/src/IBKRTrader.Core/Persistence/Entities/AnalysisEntities.cs
index e8926a3..7f26b33 100644
--- a/src/IBKRTrader.Core/Persistence/Entities/AnalysisEntities.cs
+++ b/src/IBKRTrader.Core/Persistence/Entities/AnalysisEntities.cs
@@ -6,7 +6,13 @@ public enum TradeDecision
Executed, // Aktion ausgeführt (Order platziert)
Rejected, // aktiv abgelehnt (Risiko-/Plausibilitätsregel)
Skipped, // bewusst übersprungen (z. B. Trading global aus, kein Kurs)
- Failed // versucht, aber fehlgeschlagen (z. B. Order-Fehler)
+ Failed, // versucht, aber fehlgeschlagen (z. B. Order-Fehler)
+
+ ///
+ /// Abgesendet, Ausgang offen – die Order liegt beim Broker (#489). Bewusst weder "Executed"
+ /// noch "Failed": Beides wäre eine Behauptung über etwas, das noch nicht entschieden ist.
+ ///
+ Pending
}
///
@@ -31,7 +37,8 @@ public enum DecisionReason
// ----- Ausführung -----
OrderPlaced, // Order erfolgreich platziert
- OrderFailed // Broker/Order-Fehler beim Platzieren
+ OrderFailed, // Broker/Order-Fehler beim Platzieren
+ OrderWorking // Order liegt beim Broker, noch nicht ausgeführt (#489)
}
/// Art eines Order-Lifecycle-Ereignisses (als String persistiert – erweiterbar).
diff --git a/src/IBKRTrader.Core/Trading/BrokerOrders.cs b/src/IBKRTrader.Core/Trading/BrokerOrders.cs
new file mode 100644
index 0000000..787636e
--- /dev/null
+++ b/src/IBKRTrader.Core/Trading/BrokerOrders.cs
@@ -0,0 +1,97 @@
+namespace IBKRTrader.Core.Trading;
+
+///
+/// Wie eine Order beim Broker ausgegangen ist – drei Ausgänge, nicht zwei.
+///
+/// Warum das der Kern von #489 ist. Bis dahin kannte nur
+/// „erfolgreich" und „fehlgeschlagen". Damit ließ sich der häufigste Fall am Wochenende gar nicht
+/// ausdrücken: Die Order ist beim Broker angenommen und wartet auf die Börseneröffnung.
+/// Sie als Fehlschlag zu buchen, gibt das gebundene Kapital frei, während die Order weiterläuft –
+/// dasselbe Geld wird ein zweites Mal vergeben, und am Montag entsteht eine Position, die niemand
+/// gebucht hat.
+///
+public enum OrderOutcome
+{
+ /// Ausgeführt. Menge und Preis stehen fest.
+ Filled,
+
+ ///
+ /// Endgültig nicht zustande gekommen. Nur zulässig, wenn feststeht, dass beim Broker
+ /// nichts liegt: nicht abgesendet, oder ein Endstatus bzw. eine Ablehnung ist eingetroffen.
+ ///
+ Rejected,
+
+ ///
+ /// Abgesendet, Ausgang offen. Die Order kann beim Broker aktiv sein – gebundenes Kapital
+ /// bleibt gebunden, bis ihr Schicksal feststeht.
+ ///
+ /// Das ist die Vorgabe im Zweifel: Eine Bindung, die zu lange steht, kostet Spielraum
+ /// und fällt auf. Eine Bindung, die zu früh verschwindet, kostet Geld und fällt nicht auf.
+ ///
+ Working
+}
+
+///
+/// Eine beim Broker liegende Order, so wie er sie führt – nicht, wie wir sie meinen.
+///
+/// Grundlage jeder Aussage über hängende Orders: Ohne diese Abfrage ist „die Order wurde
+/// abgelehnt" eine Vermutung, keine Feststellung.
+///
+/// Order-ID des Brokers (permanent über Neustarts: ).
+/// Dauerhafte ID des Brokers – überlebt Verbindungsabbrüche und Client-Wechsel.
+/// Betroffenes Instrument, soweit auflösbar.
+/// Kauf oder Verkauf.
+/// Gesamtmenge des Auftrags.
+/// Bereits ausgeführte Menge.
+/// Noch offene Menge.
+/// Ordertyp laut Broker ("MKT", "LMT", …).
+/// Limitpreis, falls gesetzt.
+/// Status laut Broker ("PreSubmitted", "Submitted", …).
+/// Konto, auf dem sie liegt.
+public sealed record BrokerOrder(
+ int OrderId,
+ int PermId,
+ InstrumentRef? Instrument,
+ TradeSide Side,
+ int Quantity,
+ int Filled,
+ int Remaining,
+ string OrderType,
+ decimal? LimitPrice,
+ string Status,
+ string? Account)
+{
+ ///
+ /// Wartet die Order noch auf Ausführung? „PreSubmitted" ist der Zustand einer Order, die der
+ /// Broker angenommen hat, die die Börse aber noch nicht gesehen hat – am Wochenende der
+ /// Normalfall.
+ ///
+ public bool IsWorking =>
+ Status is "PreSubmitted" or "Submitted" or "PendingSubmit" or "ApiPending" or "PendingCancel";
+
+ public override string ToString() =>
+ $"#{OrderId} {Side} {Quantity}x {Instrument?.Key ?? "?"} {OrderType}" +
+ (LimitPrice is { } lp ? $" @ {lp}" : "") +
+ $" – {Status} (gefüllt {Filled}, offen {Remaining})";
+}
+
+///
+/// Lesender Zugriff auf das Orderbuch des Brokers und das Zurücknehmen einer Order.
+///
+/// Getrennt von aus demselben Grund wie
+/// : Wer abstimmen will, was tatsächlich liegt, braucht nicht
+/// die Fähigkeit, neue Orders auszulösen. Das Stornieren gehört trotzdem hierher – es ist die
+/// einzige Handlung, die eine hängende Order beenden kann, und sie verringert Risiko.
+///
+public interface IBrokerOrderBook
+{
+ ///
+ /// Alle offenen Orders des Kontos. Leere Liste, wenn der Broker nicht erreichbar ist –
+ /// Achtung: „leer" heißt dann nicht „keine offenen Orders". Wer daraus schließt, eine
+ /// Bindung sei gegenstandslos, irrt bei jeder Verbindungsstörung.
+ ///
+ Task> GetOpenOrdersAsync(CancellationToken ct = default);
+
+ /// Nimmt eine Order zurück. true, wenn der Broker die Stornierung bestätigt hat.
+ Task CancelOrderAsync(int orderId, CancellationToken ct = default);
+}
diff --git a/src/IBKRTrader.Core/Trading/ExecutionService.cs b/src/IBKRTrader.Core/Trading/ExecutionService.cs
index c64dda5..3b59efe 100644
--- a/src/IBKRTrader.Core/Trading/ExecutionService.cs
+++ b/src/IBKRTrader.Core/Trading/ExecutionService.cs
@@ -135,6 +135,28 @@ public sealed class ExecutionService : IExecutionService
signal.Instrument, signal.Side, decision.Quantity, orderType, signal.LimitPrice);
var result = await _broker.PlaceOrderAsync(order, ct);
+
+ // Drei Ausgänge, drei Wege – und der gefährliche ist der mittlere (#489). Eine Order, die
+ // beim Broker liegt, ist WEDER ausgeführt NOCH gescheitert. Sie wie einen Fehlschlag zu
+ // behandeln, gibt das gebundene Kapital frei, während die Order weiterläuft: Dasselbe Geld
+ // wird ein zweites Mal vergeben, und die spätere Ausführung erzeugt eine Position, die
+ // niemand gebucht hat.
+ if (result.Outcome == OrderOutcome.Working)
+ {
+ var pending = result.Error ?? "Order liegt beim Broker.";
+
+ // Die Bindung wird NICHT freigegeben, sondern umgeschrieben. Ab hier ist die
+ // Order-Verfolgung dafür zuständig, sie zu einem Ende zu bringen.
+ await _capital.MarkSubmittedAsync(reservationId, result.OrderId, ct);
+
+ OrderEvent(signal, OrderEventType.Placed, sideStr, order, quote.Last, pending,
+ new { orderId = result.OrderId, status = result.Status });
+ Journal(signal, TradeDecision.Pending, DecisionReason.OrderWorking, pending,
+ new { orderId = result.OrderId, status = result.Status, book = book.Value });
+
+ return Log(module, ExecutionResult.Pending(pending, result));
+ }
+
if (!result.Success)
{
await _capital.CancelReservationAsync(
@@ -203,8 +225,13 @@ public sealed class ExecutionService : IExecutionService
private ExecutionResult Log(string module, ExecutionResult result)
{
var text = $"[{result.Action}] {result.Reason}";
- if (result.Action == "ERROR") _logger.Error(module, text);
- else _logger.Info(module, text);
+
+ // PENDING ist eine Warnung, kein Fehler: Es ist nichts schiefgegangen, aber es ist auch
+ // nichts fertig - und das Kapital bleibt so lange gebunden.
+ if (result.Action == "ERROR") _logger.Error(module, text);
+ else if (result.Action == "PENDING") _logger.Warn(module, text);
+ else _logger.Info(module, text);
+
return result;
}
}
diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
index 324920c..36b7edb 100644
--- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
+++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
@@ -15,7 +15,8 @@ namespace IBKRTrader.Core.Trading.Ibkr;
/// fehlgeschlagene Order). Ein Konto mit Wert 0 lässt die Risikoprüfung jedes Signal ablehnen –
/// die sichere Richtung, wenn der Broker nicht erreichbar ist.
///
-public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData, IQuoteSource, IDisposable
+public sealed class IbkrBrokerClient
+ : IBrokerClient, IBrokerPortfolioReader, IBrokerOrderBook, IOptionMarketData, IQuoteSource, IDisposable
{
private const string LogModule = "IBKR";
@@ -85,6 +86,11 @@ public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IO
_logger.Info(LogModule,
$"Order {result.OrderId} ausgeführt: {request.Side} {result.FilledQuantity}x {request.Symbol} " +
$"@ {result.AvgFillPrice:F2}.");
+ else if (result.Outcome == OrderOutcome.Working)
+ // Warnung, nicht Fehler: Es ist nichts schiefgegangen. Die Order liegt beim Broker,
+ // und genau das muss sichtbar sein – als Fehler gemeldet, sucht jemand eine Ursache,
+ // die es nicht gibt.
+ _logger.Warn(LogModule, result.Error ?? "Order liegt beim Broker.");
else
_logger.Error(LogModule, result.Error ?? "Order fehlgeschlagen.");
@@ -106,6 +112,24 @@ public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IO
return await _connection!.RequestExecutionsAsync(since, _requestTimeout, ct).ConfigureAwait(false);
}
+ public async Task> GetOpenOrdersAsync(CancellationToken ct = default)
+ {
+ if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty();
+
+ return await _connection!.RequestOpenOrdersAsync(_requestTimeout, ct).ConfigureAwait(false);
+ }
+
+ public async Task CancelOrderAsync(int orderId, CancellationToken ct = default)
+ {
+ if (!await IsReadyAsync(ct).ConfigureAwait(false))
+ {
+ _logger.Warn(LogModule, $"Order {orderId} nicht storniert: keine Verbindung zum Broker.");
+ return false;
+ }
+
+ return await _connection!.CancelOrderAsync(orderId, _orderTimeout, ct).ConfigureAwait(false);
+ }
+
private static AccountState Empty => new(0m, 0m);
private async Task IsReadyAsync(CancellationToken ct)
diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs
index e66b943..752477b 100644
--- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs
+++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs
@@ -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 _handshake = NewTcs();
private TaskCompletionSource? _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) ────────────────────
+ ///
+ /// Fragt das Orderbuch des Kontos ab – alle offenen Orders, auch die anderer Client-IDs und die
+ /// aus früheren Sitzungen.
+ ///
+ /// reqAllOpenOrders statt reqOpenOrders: Letzteres liefert nur, was dieser
+ /// 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.
+ ///
+ public async Task> 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();
+ }
+
+ lock (slot.Orders) return slot.Orders.ToList();
+ }
+ finally
+ {
+ _openOrders = null;
+ }
+ }
+
+ ///
+ /// Nimmt eine Order zurück und wartet auf die Bestätigung des Brokers.
+ ///
+ /// Ohne das Warten wäre die Rückmeldung wertlos: cancelOrder 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.
+ ///
+ public async Task 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;
+
+ ///
+ /// Steht fest, dass beim Broker nichts liegt? Nur dann darf gebundenes Kapital zurück.
+ /// Vorgabe false: Ein Verbindungsabbruch beendet unsere Sicht auf die Order, nicht
+ /// die Order.
+ ///
+ public bool Rejected;
+
+ /// Hinweis des Brokers zu einer angenommenen Order (etwa 399).
+ public string? Notice;
+ }
+
+ ///
+ /// Das Orderbuch ist – wie das Portfolio – ein kontoweites Abonnement ohne eigene reqId.
+ ///
+ private sealed class OpenOrderSlot : Slot
+ {
+ public readonly List Orders = new();
}
private sealed class PortfolioSlot : Slot
diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs
index f2a2754..1ab5e8e 100644
--- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs
+++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrMapping.cs
@@ -194,6 +194,39 @@ internal static class IbkrMapping
public bool IsAssumed => Source is ExecutionTimeSource.FallbackZone or ExecutionTimeSource.UnknownZoneFallback;
}
+ ///
+ /// Übersetzt eine offene Order des Brokers in unser Bild davon.
+ ///
+ /// Das Instrument darf null bleiben: Eine Order auf einen Kontrakt, den wir nicht
+ /// deuten können, ist trotzdem eine Order, die Geld bewegt. Sie zu verschweigen, weil das
+ /// Mapping sie nicht kennt, wäre der schlimmere Fehler – Symbol und Status stehen ohnehin da.
+ ///
+ /// Ausgeführte Menge ist hier immer 0. Sie steht nicht im openOrder-Callback,
+ /// sondern kommt über orderStatus nach; der Adapter trägt sie dort nach. Ein geratener
+ /// Wert wäre schlimmer als eine offene 0.
+ ///
+ public static BrokerOrder ToBrokerOrder(int orderId, Contract contract, Order order, OrderState? state)
+ {
+ var total = (int)Math.Round(order.TotalQuantity);
+ var status = string.IsNullOrWhiteSpace(state?.Status) ? "Unbekannt" : state!.Status;
+ decimal? limit = order.OrderType == "LMT" && order.LmtPrice is > 0 and < double.MaxValue
+ ? (decimal)order.LmtPrice
+ : null;
+
+ return new BrokerOrder(
+ OrderId: orderId,
+ PermId: order.PermId,
+ Instrument: ToInstrument(contract),
+ Side: ParseSide(order.Action),
+ Quantity: total,
+ Filled: 0,
+ Remaining: total,
+ OrderType: order.OrderType ?? "",
+ LimitPrice: limit,
+ Status: status,
+ Account: order.Account);
+ }
+
/// 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;
@@ -307,6 +340,35 @@ internal static class IbkrMapping
public static bool IsTerminalStatus(string status) =>
status is "Filled" or "Cancelled" or "ApiCancelled" or "Inactive";
+ ///
+ /// Ein Status, aus dem bewiesen folgt, dass beim Broker nichts mehr liegt.
+ ///
+ /// "Inactive" fehlt hier bewusst, obwohl es ein Endstatus ist: IBKR verwendet ihn auch
+ /// für Orders, die später von selbst wieder aktiv werden (etwa außerhalb der Handelszeit). Ihn
+ /// als Beweis für "tot" zu nehmen, gäbe gebundenes Kapital frei, während die Order lebt.
+ ///
+ public static bool IsDeadStatus(string status) =>
+ status is "Cancelled" or "ApiCancelled";
+
+ ///
+ /// Meldungen, die zu einer Order gehören, sie aber nicht ablehnen.
+ ///
+ /// 399 ist der Grund, aus dem es diese Liste gibt (#489): IBKR schickt darunter
+ /// Hinweise zu einer angenommenen Order – am Wochenende regelmäßig "Your order will not
+ /// be placed at the exchange until Monday 09:00". Als Fehler gelesen, meldete der Adapter
+ /// "Order abgelehnt", das Kapitalmodell gab die Bindung frei, und am Montag füllte eine Order,
+ /// von der niemand mehr wusste. Der Text sagt "Warning" – wir haben ihn nur nicht gelesen.
+ ///
+ public static bool IsOrderNotice(int errorCode) =>
+ errorCode is 399 or 404;
+
+ ///
+ /// Codes, die eine Order beweisbar beenden. Alles andere ist im Zweifel eine Störung
+ /// unserer Sicht, nicht das Ende der Order.
+ ///
+ public static bool IsOrderRejection(int errorCode) =>
+ errorCode is 201 or 202 or 203 or 321 or 382 or 383;
+
/// Kein Marktdaten-Abo, TWS liefert stattdessen verzögerte Kurse.
public const int DelayedDataNotice = 10167;
diff --git a/src/IBKRTrader.Core/Trading/NullBrokerClient.cs b/src/IBKRTrader.Core/Trading/NullBrokerClient.cs
index 4e3319b..2c48557 100644
--- a/src/IBKRTrader.Core/Trading/NullBrokerClient.cs
+++ b/src/IBKRTrader.Core/Trading/NullBrokerClient.cs
@@ -7,7 +7,8 @@ namespace IBKRTrader.Core.Trading;
/// Wird registriert, bis der echte IBKR-Adapter angebunden und gegen den
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
///
-public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData, IQuoteSource
+public sealed class NullBrokerClient
+ : IBrokerClient, IBrokerPortfolioReader, IBrokerOrderBook, IOptionMarketData, IQuoteSource
{
private readonly LoggingService _logger;
@@ -19,6 +20,14 @@ public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader, IO
public Task> GetExecutionsAsync(DateTime? since = null, CancellationToken ct = default)
=> Task.FromResult>(Array.Empty());
+ // Ohne Broker gibt es kein Orderbuch. Das ist hier auch die Wahrheit und keine Annahme: Der
+ // NullBroker hat nie eine Order abgesetzt, es KANN keine offene geben.
+ public Task> GetOpenOrdersAsync(CancellationToken ct = default)
+ => Task.FromResult>(Array.Empty());
+
+ public Task CancelOrderAsync(int orderId, CancellationToken ct = default)
+ => Task.FromResult(false);
+
public Task GetQuoteAsync(InstrumentRef instrument, CancellationToken ct = default)
=> Task.FromResult(null);
diff --git a/src/IBKRTrader.Core/Trading/TradingModels.cs b/src/IBKRTrader.Core/Trading/TradingModels.cs
index c128da5..239bdc3 100644
--- a/src/IBKRTrader.Core/Trading/TradingModels.cs
+++ b/src/IBKRTrader.Core/Trading/TradingModels.cs
@@ -112,17 +112,39 @@ public sealed record OrderRequest
/// Ergebnis einer Order-Platzierung.
public sealed record OrderResult
{
- public bool Success { get; init; }
+ /// Wie die Order ausgegangen ist. Maßgeblich; leitet sich daraus ab.
+ public OrderOutcome Outcome { get; init; } = OrderOutcome.Rejected;
+
+ ///
+ /// Ausgeführt – und nur das. Ein ist kein Erfolg,
+ /// aber auch kein Fehlschlag; wer nur dieses Flag prüft, behandelt beides gleich.
+ ///
+ public bool Success => Outcome == OrderOutcome.Filled;
+
public string? OrderId { get; init; }
public int FilledQuantity { get; init; }
public decimal AvgFillPrice { get; init; }
public string? Error { get; init; }
- public static OrderResult Filled(string orderId, int qty, decimal price) =>
- new() { Success = true, OrderId = orderId, FilledQuantity = qty, AvgFillPrice = price };
+ /// Letzter vom Broker gemeldeter Status – nur bei belegt.
+ public string? Status { get; init; }
+ public static OrderResult Filled(string orderId, int qty, decimal price) =>
+ new() { Outcome = OrderOutcome.Filled, OrderId = orderId, FilledQuantity = qty, AvgFillPrice = price };
+
+ ///
+ /// Endgültig nicht zustande gekommen. Nur verwenden, wenn feststeht, dass beim Broker nichts
+ /// liegt – im Zweifel .
+ ///
public static OrderResult Fail(string error) =>
- new() { Success = false, Error = error };
+ new() { Outcome = OrderOutcome.Rejected, Error = error };
+
+ ///
+ /// Abgesendet, Ausgang offen. Gebundenes Kapital bleibt gebunden, bis das Schicksal der Order
+ /// feststeht.
+ ///
+ public static OrderResult Working(string orderId, string status, string message) =>
+ new() { Outcome = OrderOutcome.Working, OrderId = orderId, Status = status, Error = message };
}
/// Momentaufnahme eines Kurses.
@@ -397,14 +419,25 @@ public sealed record RiskDecision
/// Ergebnis einer Signal-Ausführung durch den .
public sealed record ExecutionResult
{
- /// "EXECUTE", "SKIP" oder "ERROR".
+ /// "EXECUTE", "PENDING", "SKIP" oder "ERROR".
public required string Action { get; init; }
public string Reason { get; init; } = "";
public OrderResult? Order { get; init; }
+ /// Ausgeführt und verbucht. Ein ist das ausdrücklich nicht.
public bool Executed => Action == "EXECUTE";
+ ///
+ /// Die Order liegt beim Broker, ihr Ausgang steht noch aus. Für ein Modul heißt das:
+ /// nicht erneut senden – anders als bei SKIP oder ERROR.
+ ///
+ public bool IsPending => Action == "PENDING";
+
public static ExecutionResult Skip(string reason) => new() { Action = "SKIP", Reason = reason };
public static ExecutionResult Error(string reason, OrderResult? order = null) => new() { Action = "ERROR", Reason = reason, Order = order };
public static ExecutionResult Execute(OrderResult order) => new() { Action = "EXECUTE", Reason = "OK", Order = order };
+
+ /// Abgesendet, Ausgang offen – gebundenes Kapital bleibt gebunden.
+ public static ExecutionResult Pending(string reason, OrderResult order) =>
+ new() { Action = "PENDING", Reason = reason, Order = order };
}
diff --git a/src/IBKRTrader.Core/Workers/BuiltIn/ReservationWatchdogWorker.cs b/src/IBKRTrader.Core/Workers/BuiltIn/ReservationWatchdogWorker.cs
index 092b26d..3ba2e39 100644
--- a/src/IBKRTrader.Core/Workers/BuiltIn/ReservationWatchdogWorker.cs
+++ b/src/IBKRTrader.Core/Workers/BuiltIn/ReservationWatchdogWorker.cs
@@ -18,12 +18,21 @@ namespace IBKRTrader.Core.Workers.BuiltIn;
/// globaler Kaufstopp – und niemals ein automatischer Verkauf zur Heilung. Eine Software,
/// die in einen fallenden Markt hinein Positionen schließt, weil eine Kennzahl unterschritten
/// ist, richtet mehr Schaden an als der Zustand, den sie beheben soll.
+/// - Die offenen Bindungen gegen das Orderbuch des Brokers halten (#489). Eine Bindung, zu der
+/// keine Order mehr liegt, kommt in Quarantäne statt in die Freigabe – es gibt zwei Erklärungen
+/// dafür, und sie führen zu entgegengesetzten Handlungen.
///
+///
+/// Die Reihenfolge ist Absicht: Der Orderbuch-Abgleich läuft erst, nachdem der
+/// Kontostand bewiesen hat, dass die Verbindung steht. Sonst hieße eine leere Antwort des Brokers
+/// „keine offenen Orders“, obwohl sie „keine Verbindung“ bedeutet – und jede lebende Bindung käme
+/// zu Unrecht in Quarantäne.
///
public class ReservationWatchdogWorker : WorkerBase
{
private readonly ReservationService _reservations;
private readonly IBrokerClient _broker;
+ private readonly IBrokerOrderBook _orderBook;
private readonly Settings.SettingsService _settings;
public override string Name => "ReservationWatchdogWorker";
@@ -37,11 +46,13 @@ public class ReservationWatchdogWorker : WorkerBase
IDbContextFactory db,
ReservationService reservations,
IBrokerClient broker,
+ IBrokerOrderBook orderBook,
Settings.SettingsService settings)
: base(logger, db)
{
_reservations = reservations;
_broker = broker;
+ _orderBook = orderBook;
_settings = settings;
}
@@ -72,5 +83,21 @@ public class ReservationWatchdogWorker : WorkerBase
if (overdue.Count > 0)
Logger.Warn(Module, $"{overdue.Count} überfällige Reservierung(en) gemeldet.");
+
+ // Ab hier steht fest, dass die Verbindung trägt (der Kontostand kam an). Erst jetzt ist eine
+ // leere Orderliste eine Aussage.
+ var open = await _orderBook.GetOpenOrdersAsync(ct);
+ var orphans = await _reservations.RecoverAsync(
+ [.. open.Select(o => o.OrderId.ToString(System.Globalization.CultureInfo.InvariantCulture))], ct);
+
+ if (orphans.Count > 0)
+ Logger.Error(Module,
+ $"{orphans.Count} Kapitalbindung(en) haben keine offene Order mehr beim Broker und stehen " +
+ $"in Quarantäne. Entweder wurde nie gesendet – dann darf das Kapital zurück – oder es " +
+ $"wurde ausgeführt, und es gibt eine Position, die wir nicht kennen. Das entscheidet " +
+ $"der Bestandsabgleich oder ein Mensch, nicht dieser Worker.");
+
+ Info.Info = $"{open.Count} offene Order(s) beim Broker" +
+ (orphans.Count > 0 ? $", {orphans.Count} Bindung(en) in Quarantäne" : "") + ".";
}
}
diff --git a/src/IBKRTrader.Daemon/OrderBookTool.cs b/src/IBKRTrader.Daemon/OrderBookTool.cs
new file mode 100644
index 0000000..e3ba2a9
--- /dev/null
+++ b/src/IBKRTrader.Daemon/OrderBookTool.cs
@@ -0,0 +1,178 @@
+using System.Globalization;
+using IBKRTrader.Core.Persistence.Ef;
+using IBKRTrader.Core.Persistence.Entities;
+using IBKRTrader.Core.Settings;
+using IBKRTrader.Core.Trading;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace IBKRTrader.Daemon;
+
+///
+/// Der Blick ins Orderbuch des Brokers – und der Abgleich mit dem, was wir selbst gebucht haben
+/// (#489).
+///
+/// Wozu es das gibt. Am 2026-09-06 stellte sich heraus, dass vier Orders bei
+/// geschlossener Börse angenommen und für Montag vorgemerkt wurden, während der Adapter sie als
+/// „abgelehnt" meldete und das Kapitalmodell die Bindungen freigab. Aufgefallen ist das nur, weil
+/// jemand nachgesehen hat. Damit das nicht vom Zufall abhängt, ist der Gegencheck ein
+/// Werkzeug.
+///
+/// Die Aussage, die dieser Schalter nicht macht: Eine leere Liste heißt nicht „keine
+/// offenen Orders". Sie heißt auch bei jeder Verbindungsstörung so. Deshalb wird die Verbindung
+/// vorher ausdrücklich geprüft und ein Fehlschlag als solcher gemeldet.
+///
+internal static class OrderBookTool
+{
+ public static async Task ListAsync(IServiceProvider services)
+ {
+ var inv = CultureInfo.InvariantCulture;
+ var book = services.GetRequiredService();
+ var dbf = services.GetRequiredService>();
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
+
+ var orders = await book.GetOpenOrdersAsync(cts.Token).ConfigureAwait(false);
+
+ Console.WriteLine();
+ Console.WriteLine("=== Offene Orders beim Broker ===");
+
+ if (orders.Count == 0)
+ {
+ Console.WriteLine("Keine gemeldet.");
+ Console.WriteLine("ACHTUNG: Das heißt nur dann „keine offenen Orders\", wenn oben eine");
+ Console.WriteLine("Verbindung zustande kam. Bei einer Störung sieht das Ergebnis gleich aus.");
+ }
+ else
+ {
+ foreach (var o in orders)
+ Console.WriteLine($" {o}");
+ }
+
+ // Der eigentliche Punkt: Was sagt unsere Buchführung dazu?
+ await using var db = await dbf.CreateDbContextAsync(cts.Token);
+
+ var bound = await db.Reservations
+ .Where(r => r.State == ReservationState.Reserved || r.State == ReservationState.Submitted)
+ .OrderBy(r => r.Id)
+ .ToListAsync(cts.Token);
+
+ Console.WriteLine();
+ Console.WriteLine("=== Eigene Bindungen, die noch auf ein Ergebnis warten ===");
+ if (bound.Count == 0)
+ Console.WriteLine("Keine.");
+ else
+ foreach (var r in bound)
+ Console.WriteLine(
+ $" #{r.Id} {r.Book} {r.Side} {r.Quantity}x {r.ContractKey} – " +
+ $"{r.Amount.ToString("N2", inv)} {r.Currency}, Zustand {r.State}, " +
+ $"Order {r.BrokerOrderId ?? "(keine ID)"}");
+
+ // Die beiden Richtungen, in denen es auseinanderlaufen kann. Beide sind Befunde, keine
+ // Formalien: links Geld, das gebunden ist, ohne dass etwas liegt; rechts eine Order, die
+ // liegt, ohne dass Geld dafür gebunden wäre.
+ var knownIds = bound
+ .Where(r => !string.IsNullOrWhiteSpace(r.BrokerOrderId))
+ .Select(r => r.BrokerOrderId!)
+ .ToHashSet(StringComparer.Ordinal);
+
+ var unbacked = orders
+ .Where(o => !knownIds.Contains(o.OrderId.ToString(inv)))
+ .ToList();
+
+ Console.WriteLine();
+ Console.WriteLine("=== Befund ===");
+
+ if (unbacked.Count > 0)
+ {
+ Console.WriteLine($"{unbacked.Count} Order(s) liegen beim Broker, ohne dass eine Bindung sie kennt:");
+ foreach (var o in unbacked)
+ Console.WriteLine($" {o}");
+ Console.WriteLine();
+ Console.WriteLine("Das ist der gefährliche Fall: Führt eine davon aus, entsteht eine Position,");
+ Console.WriteLine("für die kein Kapital reserviert war. Zurücknehmen mit");
+ Console.WriteLine(" --cancel-order --confirm");
+ return 2;
+ }
+
+ Console.WriteLine("Orderbuch und Bindungen decken sich.");
+ return 0;
+ }
+
+ public static async Task CancelAsync(IServiceProvider services, string idText, bool confirm)
+ {
+ if (string.Equals(idText, "all", StringComparison.OrdinalIgnoreCase))
+ return await CancelAllAsync(services, confirm).ConfigureAwait(false);
+
+ if (!int.TryParse(idText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var orderId))
+ {
+ Console.Error.WriteLine($"ABBRUCH: \"{idText}\" ist keine Order-ID.");
+ return 1;
+ }
+
+ if (!confirm)
+ {
+ Console.WriteLine($"TROCKENLAUF: Order {orderId} würde zurückgenommen. " +
+ "Zum Ausführen denselben Aufruf mit --confirm wiederholen.");
+ return 0;
+ }
+
+ var book = services.GetRequiredService();
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
+
+ var ok = await book.CancelOrderAsync(orderId, cts.Token).ConfigureAwait(false);
+
+ Console.WriteLine(ok
+ ? $"Order {orderId} zurückgenommen."
+ : $"Order {orderId} NICHT bestätigt zurückgenommen – sie kann weiterhin liegen. " +
+ "Mit --open-orders nachsehen.");
+
+ return ok ? 0 : 1;
+ }
+
+ ///
+ /// Nimmt alle offenen Orders zurück – das Aufräumwerkzeug nach einem Testlauf.
+ ///
+ /// Nur im Paper-Modus. Das ist keine Formalie: Ein Schalter, der auf Zuruf jede
+ /// Order eines Live-Kontos löscht, gehört nicht in ein Kommandozeilenwerkzeug. Wer auf einem
+ /// Live-Konto aufräumen will, tut es in TWS, wo er sieht, was er wegwirft.
+ ///
+ private static async Task CancelAllAsync(IServiceProvider services, bool confirm)
+ {
+ var trading = services.GetRequiredService().Settings.Trading;
+ if (trading.ParsedMode != TradingMode.Paper ||
+ !string.Equals(trading.Mode?.Trim(), nameof(TradingMode.Paper), StringComparison.OrdinalIgnoreCase))
+ {
+ Console.Error.WriteLine($"ABBRUCH: Handelsmodus \"{trading.Mode}\" ist nicht eindeutig Paper. " +
+ "Ein Sammelstorno gibt es nur auf dem Papierkonto.");
+ return 1;
+ }
+
+ var book = services.GetRequiredService();
+ using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
+
+ var orders = await book.GetOpenOrdersAsync(cts.Token).ConfigureAwait(false);
+ if (orders.Count == 0)
+ {
+ Console.WriteLine("Keine offenen Orders gemeldet – nichts zurückzunehmen.");
+ return 0;
+ }
+
+ Console.WriteLine($"{orders.Count} offene Order(s) beim Broker.");
+ if (!confirm)
+ {
+ Console.WriteLine("TROCKENLAUF – nichts zurückgenommen. Zum Ausführen mit --confirm wiederholen.");
+ return 0;
+ }
+
+ var done = 0;
+ foreach (var o in orders)
+ {
+ if (await book.CancelOrderAsync(o.OrderId, cts.Token).ConfigureAwait(false)) done++;
+ }
+
+ Console.WriteLine($"{done} von {orders.Count} zurückgenommen.");
+ Console.WriteLine("Mit --open-orders gegenprüfen – eine Bestätigung ist keine Feststellung.");
+ return done == orders.Count ? 0 : 1;
+ }
+}
diff --git a/src/IBKRTrader.Daemon/Program.cs b/src/IBKRTrader.Daemon/Program.cs
index bcd76a6..a15a20a 100644
--- a/src/IBKRTrader.Daemon/Program.cs
+++ b/src/IBKRTrader.Daemon/Program.cs
@@ -56,6 +56,13 @@ internal static class Program
// Optionskette und Bewertungsgrößen eines Basiswerts ansehen. Liest nur.
var chainSymbol = ArgAfter(args, "--option-chain");
+ // Was liegt tatsächlich beim Broker? Der Gegencheck zur eigenen Buchführung (#489).
+ // Ohne ihn ist "die Order wurde abgelehnt" eine Vermutung, keine Feststellung.
+ var openOrders = HasFlag(args, "--open-orders");
+
+ // Eine hängende Order zurücknehmen. Braucht --confirm: Stornieren ist ein Eingriff.
+ var cancelOrder = ArgAfter(args, "--cancel-order");
+
var modules = AppHostBuilder.CreateModules();
using var host = AppHostBuilder.Build(modules);
@@ -87,6 +94,13 @@ internal static class Program
if (reconcile)
return await RunReconcileAsync(host.Services, !HasFlag(args, "--dry-run")).ConfigureAwait(false);
+ if (openOrders)
+ return await OrderBookTool.ListAsync(host.Services).ConfigureAwait(false);
+
+ if (cancelOrder is not null)
+ return await OrderBookTool.CancelAsync(
+ host.Services, cancelOrder, HasFlag(args, "--confirm")).ConfigureAwait(false);
+
if (chainSymbol is not null)
return await RunOptionChainAsync(host.Services, chainSymbol,
ArgAfter(args, "--currency") ?? Currencies.Default).ConfigureAwait(false);
diff --git a/src/IBKRTrader.Daemon/VerifyOrder.cs b/src/IBKRTrader.Daemon/VerifyOrder.cs
index 324503f..49e4122 100644
--- a/src/IBKRTrader.Daemon/VerifyOrder.cs
+++ b/src/IBKRTrader.Daemon/VerifyOrder.cs
@@ -156,12 +156,25 @@ internal static class VerifyOrder
Console.WriteLine($"Order: {order.OrderId}, gefüllt {order.FilledQuantity} " +
$"@ {order.AvgFillPrice.ToString("N2", inv)}");
+ if (result.IsPending)
+ {
+ // Kein Fehlschlag. Bei geschlossener Börse ist das der richtige Ausgang: Die Order liegt
+ // beim Broker, das Kapital bleibt gebunden, und die Bindung trägt die Order-ID.
+ Console.WriteLine();
+ Console.WriteLine("=== Order liegt beim Broker – Ausgang offen ===");
+ Console.WriteLine("Das ist bei geschlossener Börse der erwartete Ausgang und ausdrücklich KEIN");
+ Console.WriteLine("Fehlschlag: Die Kapitalbindung bleibt bestehen, bis die Order ausgeführt oder");
+ Console.WriteLine("zurückgenommen ist.");
+ Console.WriteLine();
+ Console.WriteLine("Gegenprobe: --open-orders");
+ Console.WriteLine("Zurücknehmen: --cancel-order --confirm");
+ return 0;
+ }
+
if (!result.Executed)
{
Console.Error.WriteLine("=== Order-Nachweis FEHLGESCHLAGEN ===");
- Console.Error.WriteLine("Die Kette ist bis zur obigen Stelle gekommen. Bei einer Limit-Order, " +
- "die im Buch liegt, ist das der bekannte Fall aus #294: sie gilt uns " +
- "als Fehlschlag, ist bei IBKR aber weiterhin aktiv.");
+ Console.Error.WriteLine("Die Order ist beweisbar nicht zustande gekommen – der Grund steht oben.");
return 1;
}
diff --git a/src/IBKRTrader.Hosting/AppHostBuilder.cs b/src/IBKRTrader.Hosting/AppHostBuilder.cs
index dac6817..7b086fa 100644
--- a/src/IBKRTrader.Hosting/AppHostBuilder.cs
+++ b/src/IBKRTrader.Hosting/AppHostBuilder.cs
@@ -178,18 +178,25 @@ public static class AppHostBuilder
try
{
- // Offene Broker-Orders sind heute nicht abrufbar - die asynchrone Order-Verfolgung
- // (#294) fehlt noch. Bis dahin laeuft die Wiederherstellung gegen eine leere Liste:
- // jede ueberlebende Bindung kommt damit in Quarantaene und wird gemeldet, statt
- // stillschweigend freigegeben zu werden. Das ist die sichere Richtung - lieber ein
- // Alarm zu viel als Kapital, das zweimal vergeben wird.
+ // Hier wird nur GEMELDET, nicht entschieden.
+ //
+ // Bis #489 lief an dieser Stelle RecoverAsync gegen eine leere Orderliste, weil offene
+ // Broker-Orders nicht abrufbar waren - jede ueberlebende Bindung landete damit sofort in
+ // Quarantaene. Das war vertretbar, solange nie eine Bindung den Zustand "Submitted"
+ // erreichte. Seit eine wartende Order ihre Bindung behaelt, waere es falsch: Eine voellig
+ // gesunde Bindung zu einer lebenden Order kaeme bei jedem Neustart in Quarantaene.
+ //
+ // Der Abgleich gehoert dorthin, wo bewiesen ist, dass der Broker antwortet - in den
+ // ReservationWatchdogWorker. Der laeuft alle fuenf Minuten und heilt sich selbst, wenn
+ // die Verbindung erst spaeter steht. Ein Start darf nicht davon abhaengen, ob TWS schon
+ // wach ist.
var reservations = services.GetRequiredService();
- var orphans = reservations.RecoverAsync(Array.Empty()).GetAwaiter().GetResult();
- if (orphans.Count > 0)
- logger.Error("Kapital",
- $"{orphans.Count} Kapitalbindung(en) aus einem früheren Lauf konnten keiner offenen " +
- $"Broker-Order zugeordnet werden und stehen in Quarantäne. Sie binden weiterhin " +
- $"Kapital, bis sie geklärt sind.");
+ var pending = reservations.CountPendingAsync().GetAwaiter().GetResult();
+ if (pending > 0)
+ logger.Warn("Kapital",
+ $"{pending} Kapitalbindung(en) aus einem früheren Lauf sind noch offen. Der " +
+ $"Watchdog gleicht sie gegen das Orderbuch des Brokers ab, sobald dieser " +
+ $"erreichbar ist.");
}
catch (Exception ex)
{
@@ -271,6 +278,7 @@ public static class AppHostBuilder
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
}
@@ -279,6 +287,7 @@ public static class AppHostBuilder
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
}
diff --git a/tests/IBKRTrader.Tests/Trading/ExecutionServiceTests.cs b/tests/IBKRTrader.Tests/Trading/ExecutionServiceTests.cs
index 1ad68b6..0c4ea4c 100644
--- a/tests/IBKRTrader.Tests/Trading/ExecutionServiceTests.cs
+++ b/tests/IBKRTrader.Tests/Trading/ExecutionServiceTests.cs
@@ -299,4 +299,79 @@ public class ExecutionServiceTests
Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
}
+
+ // ─── Die Order, die beim Broker liegt (#489) ─────────────────────────────
+
+ ///
+ /// Der Test, um den es bei #489 geht. Am 2026-09-06 wurden 67 Orders bei geschlossener
+ /// Börse angenommen und für Montag vorgemerkt, während der Adapter sie als abgelehnt meldete und
+ /// jede Bindung freigegeben wurde. Das Geld war wieder im Topf, die Orders lebten weiter.
+ ///
+ [Fact]
+ public async Task WorkingOrder_KeepsTheCapitalBound()
+ {
+ ArrangeHappyPath();
+ _broker.PlaceOrderAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(OrderResult.Working("O-77", "PreSubmitted", "wartet auf Eröffnung")));
+
+ var result = await CreateSut().ExecuteAsync(BuySignal);
+
+ result.Action.Should().Be("PENDING");
+ result.IsPending.Should().BeTrue();
+ result.Executed.Should().BeFalse("nichts ist ausgeführt – PENDING ist kein Erfolg");
+
+ await _capital.Received(1).MarkSubmittedAsync(1, "O-77", Arg.Any());
+ await _capital.DidNotReceive().CancelReservationAsync(
+ Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task WorkingOrder_BooksNothing()
+ {
+ // Eine Position zu buchen, bevor ausgeführt wurde, wäre der Fehler in die andere Richtung.
+ ArrangeHappyPath();
+ _broker.PlaceOrderAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(OrderResult.Working("O-77", "PreSubmitted", "wartet")));
+
+ await CreateSut().ExecuteAsync(BuySignal);
+
+ await _portfolio.DidNotReceive().RecordFillAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ await _capital.DidNotReceive().SettleReservationAsync(
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task RejectedOrder_StillReleasesTheCapital()
+ {
+ // Die Gegenprobe: Bei einer beweisbar abgelehnten Order MUSS das Geld zurück. Sonst hätte
+ // der Fix aus #489 nur die eine Klemme gegen die andere getauscht.
+ ArrangeHappyPath();
+ _broker.PlaceOrderAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(OrderResult.Fail("Order abgelehnt")));
+
+ await CreateSut().ExecuteAsync(BuySignal);
+
+ await _capital.Received(1).CancelReservationAsync(
+ 1, Arg.Any(), Arg.Any());
+ await _capital.DidNotReceive().MarkSubmittedAsync(
+ Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task WorkingOrder_IsJournalledAsPending_NotAsFailure()
+ {
+ // Das Journal ist die Grundlage der Supervisor-Forensik. Stuende dort "Failed", suchte
+ // später jemand einen Fehler, den es nicht gab - und uebersaehe die offene Order.
+ ArrangeHappyPath();
+ _broker.PlaceOrderAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(OrderResult.Working("O-77", "PreSubmitted", "wartet")));
+
+ await CreateSut().ExecuteAsync(BuySignal);
+
+ _journal.Received().Write(Arg.Is(r =>
+ r.Decision == TradeDecision.Pending && r.Reason == DecisionReason.OrderWorking));
+ _orderLog.Received().Write(Arg.Is(e => e.EventType == OrderEventType.Placed));
+ }
}
diff --git a/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs b/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs
index 1f19320..bb007d5 100644
--- a/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs
+++ b/tests/IBKRTrader.Tests/Trading/IbkrMappingTests.cs
@@ -396,4 +396,53 @@ public class IbkrMappingTests
// scheiterte auf einem Paper-Konto ohne Datenabo jede einzelne Kursabfrage.
IbkrMapping.IsInformational(IbkrMapping.DelayedDataNotice).Should().BeTrue();
}
+
+ // ─── Ordermeldungen (#489) ─────────────────────────────────────────
+
+ ///
+ /// Der Fehler, der 67 Orders unsichtbar gemacht hat: IBKR schickt unter Code 399 Hinweise zu
+ /// angenommenen Orders – "Your order will not be placed at the exchange until Monday".
+ /// Als Ablehnung gelesen, gab das Kapitalmodell die Bindung frei, während die Order weiterlief.
+ ///
+ [Fact]
+ public void OrderNotice399_DoesNotEndAnOrder()
+ {
+ IbkrMapping.IsOrderNotice(399).Should().BeTrue();
+ IbkrMapping.IsOrderRejection(399).Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData(201, true)] // Order rejected
+ [InlineData(202, true)] // Order cancelled
+ [InlineData(203, true)] // Symbol nicht erlaubt
+ [InlineData(399, false)] // Hinweis zu einer angenommenen Order
+ [InlineData(404, false)] // Order held while securities are located
+ [InlineData(1100, false)] // Verbindung verloren - sagt nichts ueber die Order
+ [InlineData(504, false)] // nicht verbunden - dito
+ public void IsOrderRejection_OnlyForProvenEndings(int code, bool expected) =>
+ IbkrMapping.IsOrderRejection(code).Should().Be(expected);
+
+ [Theory]
+ [InlineData("Cancelled", true)]
+ [InlineData("ApiCancelled", true)]
+ [InlineData("Filled", false)]
+ [InlineData("PreSubmitted", false)]
+ [InlineData("Submitted", false)]
+ [InlineData("Inactive", false)]
+ public void IsDeadStatus_IsNarrowerThanIsTerminalStatus(string status, bool expected)
+ {
+ // "Inactive" ist ein Endstatus, aber kein Beweis: IBKR verwendet ihn auch fuer Orders, die
+ // spaeter von selbst wieder aktiv werden. Ihn als "tot" zu lesen, gaebe gebundenes Kapital
+ // frei, waehrend die Order lebt.
+ IbkrMapping.IsDeadStatus(status).Should().Be(expected);
+ }
+
+ [Fact]
+ public void EveryDeadStatus_IsAlsoTerminal()
+ {
+ // Die eine Richtung muss gelten, sonst wartet PlaceOrderAsync auf einen Endstatus, den es
+ // anschliessend nicht als Ende akzeptiert.
+ foreach (var status in new[] { "Cancelled", "ApiCancelled" })
+ IbkrMapping.IsTerminalStatus(status).Should().BeTrue($"{status} ist ein Endstatus");
+ }
}