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"); + } }