Orders, die beim Broker liegen, gelten nicht mehr als gescheitert

Am geschlossenen Markt gemessen: IBKR nimmt eine Order an und merkt sie fuer
die naechste Eroeffnung vor. Dazu schickt es Code 399 - eine Mitteilung, deren
Text mit "Warning" beginnt. Der Adapter las jede Meldung aus dem Fehler-Callback
als Ablehnung, meldete "Order abgelehnt", und das Kapitalmodell gab die Bindung
frei. Auf dem Testkonto lagen dadurch 67 Orders ueber 306 BAS, 140 SAP und 43 ALV
beim Broker, vorgemerkt fuer Montag 09:00, ohne eine einzige Reservierung dahinter.

OrderResult kannte nur "erfolgreich" und "gescheitert". Der haeufigste Fall
passte in keines von beiden. Es gibt jetzt einen dritten Ausgang - Working:
abgesendet, Ausgang offen. Die Trennlinie ist, ob BEWIESEN ist, dass beim Broker
nichts liegt; ohne Beweis bleibt das Kapital gebunden. Eine Bindung, die zu lange
steht, kostet Spielraum und faellt auf. Eine, die zu frueh verschwindet, kostet
Geld und faellt nicht auf.

- 399/404 sind Ordermeldungen, keine Ablehnungen; nur 201/202/203 u. a. beenden
  eine Order beweisbar. Ein Verbindungsabbruch beendet unsere Sicht, nicht die Order.
- IsDeadStatus ist enger als IsTerminalStatus: "Inactive" ist kein Beweis.
- ExecutionService haelt die Bindung bei Working (MarkSubmittedAsync statt
  CancelReservationAsync) und journalisiert Pending/OrderWorking statt Failed.
- Neu lesbar: das Orderbuch des Brokers (reqAllOpenOrders) samt Storno.
  --open-orders stellt Orderbuch und eigene Bindungen gegenueber,
  --cancel-order <ID>|all --confirm nimmt zurueck (Sammelstorno nur im Paper-Modus).
- Der Wiederanlauf-Abgleich lief bisher gegen eine leere Orderliste und haette
  ab jetzt jede gesunde Bindung in Quarantaene gestellt. Er sitzt jetzt im
  ReservationWatchdogWorker, hinter dem Beweis, dass der Broker antwortet.

457 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-06 12:27:32 +02:00
co-authored by Claude Opus 5
parent 1dac482cc7
commit e7e3970dec
18 changed files with 837 additions and 39 deletions
@@ -293,6 +293,10 @@ public sealed class CapitalService : ICapitalService
CancellationToken ct = default) =>
_reservations.SettleAsync(reservationId, filledQuantity, actualAmount, brokerOrderId, ct);
/// <inheritdoc />
public Task MarkSubmittedAsync(long reservationId, string? brokerOrderId, CancellationToken ct = default) =>
_reservations.MarkSubmittedAsync(reservationId, brokerOrderId, ct);
/// <inheritdoc />
public Task CancelReservationAsync(long reservationId, string reason, CancellationToken ct = default) =>
_reservations.CancelAsync(reservationId, reason, ct);
@@ -37,6 +37,15 @@ public interface ICapitalService
long reservationId, int filledQuantity, decimal actualAmount, string? brokerOrderId = null,
CancellationToken ct = default);
/// <summary>
/// Vermerkt, dass die Order beim Broker liegt – die Bindung bleibt bestehen.
///
/// <para><b>Der Gegenspieler von <see cref="CancelReservationAsync"/>.</b> 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.</para>
/// </summary>
Task MarkSubmittedAsync(long reservationId, string? brokerOrderId, CancellationToken ct = default);
/// <summary>Gibt eine Bindung frei, ohne dass ausgeführt wurde.</summary>
Task CancelReservationAsync(long reservationId, string reason, CancellationToken ct = default);
}
@@ -325,6 +325,18 @@ public sealed class ReservationService
return overdue;
}
/// <summary>
/// Wie viele Bindungen warten noch auf ein Ergebnis? Reine Auskunft – ändert nichts.
/// </summary>
public async Task<int> 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);
}
/// <summary>
/// Abgleich beim Start: jede nicht abgeschlossene Bindung gegen die offenen Orders des Brokers.
///
@@ -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)
/// <summary>
/// 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.
/// </summary>
Pending
}
/// <summary>
@@ -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)
}
/// <summary>Art eines Order-Lifecycle-Ereignisses (als String persistiert – erweiterbar).</summary>
@@ -0,0 +1,97 @@
namespace IBKRTrader.Core.Trading;
/// <summary>
/// Wie eine Order beim Broker ausgegangen ist – <b>drei</b> Ausgänge, nicht zwei.
///
/// <para><b>Warum das der Kern von #489 ist.</b> Bis dahin kannte <see cref="OrderResult"/> nur
/// „erfolgreich" und „fehlgeschlagen". Damit ließ sich der häufigste Fall am Wochenende gar nicht
/// ausdrücken: Die Order ist beim Broker <b>angenommen</b> 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.</para>
/// </summary>
public enum OrderOutcome
{
/// <summary>Ausgeführt. Menge und Preis stehen fest.</summary>
Filled,
/// <summary>
/// Endgültig nicht zustande gekommen. Nur zulässig, wenn <b>feststeht</b>, dass beim Broker
/// nichts liegt: nicht abgesendet, oder ein Endstatus bzw. eine Ablehnung ist eingetroffen.
/// </summary>
Rejected,
/// <summary>
/// Abgesendet, Ausgang offen. Die Order kann beim Broker aktiv sein – gebundenes Kapital
/// <b>bleibt gebunden</b>, bis ihr Schicksal feststeht.
///
/// <para>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.</para>
/// </summary>
Working
}
/// <summary>
/// Eine beim Broker liegende Order, so wie er sie führt – nicht, wie wir sie meinen.
///
/// <para>Grundlage jeder Aussage über hängende Orders: Ohne diese Abfrage ist „die Order wurde
/// abgelehnt" eine Vermutung, keine Feststellung.</para>
/// </summary>
/// <param name="OrderId">Order-ID des Brokers (permanent über Neustarts: <paramref name="PermId"/>).</param>
/// <param name="PermId">Dauerhafte ID des Brokers – überlebt Verbindungsabbrüche und Client-Wechsel.</param>
/// <param name="Instrument">Betroffenes Instrument, soweit auflösbar.</param>
/// <param name="Side">Kauf oder Verkauf.</param>
/// <param name="Quantity">Gesamtmenge des Auftrags.</param>
/// <param name="Filled">Bereits ausgeführte Menge.</param>
/// <param name="Remaining">Noch offene Menge.</param>
/// <param name="OrderType">Ordertyp laut Broker ("MKT", "LMT", …).</param>
/// <param name="LimitPrice">Limitpreis, falls gesetzt.</param>
/// <param name="Status">Status laut Broker ("PreSubmitted", "Submitted", …).</param>
/// <param name="Account">Konto, auf dem sie liegt.</param>
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)
{
/// <summary>
/// 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.
/// </summary>
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})";
}
/// <summary>
/// Lesender Zugriff auf das Orderbuch des Brokers und das Zurücknehmen einer Order.
///
/// <para>Getrennt von <see cref="IBrokerClient"/> aus demselben Grund wie
/// <see cref="IBrokerPortfolioReader"/>: 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 <b>verringert</b> Risiko.</para>
/// </summary>
public interface IBrokerOrderBook
{
/// <summary>
/// Alle offenen Orders des Kontos. Leere Liste, wenn der Broker nicht erreichbar ist –
/// <b>Achtung:</b> „leer" heißt dann nicht „keine offenen Orders". Wer daraus schließt, eine
/// Bindung sei gegenstandslos, irrt bei jeder Verbindungsstörung.
/// </summary>
Task<IReadOnlyList<BrokerOrder>> GetOpenOrdersAsync(CancellationToken ct = default);
/// <summary>Nimmt eine Order zurück. <c>true</c>, wenn der Broker die Stornierung bestätigt hat.</summary>
Task<bool> CancelOrderAsync(int orderId, CancellationToken ct = default);
}
@@ -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;
}
}
@@ -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.
/// </summary>
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<IReadOnlyList<BrokerOrder>> GetOpenOrdersAsync(CancellationToken ct = default)
{
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty<BrokerOrder>();
return await _connection!.RequestOpenOrdersAsync(_requestTimeout, ct).ConfigureAwait(false);
}
public async Task<bool> 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<bool> IsReadyAsync(CancellationToken ct)
@@ -42,6 +42,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
// Portfolio-Abruf ist ein kontoweites Abonnement, keine reqId-Anfrage – daher nur ein Slot.
private PortfolioSlot? _portfolio;
// Dasselbe gilt fuer das Orderbuch: reqAllOpenOrders traegt keine reqId.
private OpenOrderSlot? _openOrders;
private TaskCompletionSource<bool> _handshake = NewTcs();
private TaskCompletionSource<long>? _serverTime;
@@ -628,26 +631,37 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
{
_socket.placeOrder(orderId, contract, IbkrMapping.BuildOrder(request, orderId, _account));
// Ab hier ist die Order abgesendet. Alles Weitere entscheidet nur noch darüber, ob wir
// BEWEISEN können, dass beim Broker nichts liegt. Ohne Beweis lautet die Antwort
// "Working" – und gebundenes Kapital bleibt gebunden.
var id = orderId.ToString(CultureInfo.InvariantCulture);
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
// Die Order ist übermittelt und liegt womöglich aktiv bei IBKR. Sie hier als
// reinen Fehlschlag zu buchen wäre falsch – deshalb Order-ID und letzter Status
// in die Meldung, damit der Betreiber sie in TWS wiederfindet.
var status = string.IsNullOrEmpty(slot.Status) ? "keine Rückmeldung" : slot.Status;
return OrderResult.Fail(
return OrderResult.Working(id, status,
$"Order {orderId} ({request.Side} {request.Quantity}x {request.Symbol}) wurde übermittelt, " +
$"blieb aber {timeout.TotalSeconds:F0}s ohne Endstatus (zuletzt: {status}). " +
"Sie kann bei IBKR weiterhin aktiv sein und muss dort geprüft werden.");
"Sie kann bei IBKR weiterhin aktiv sein.");
}
if (slot.Error is not null)
return OrderResult.Fail($"Order {orderId} abgelehnt: {slot.Error}");
return slot.Rejected
? OrderResult.Fail($"Order {orderId} abgelehnt: {slot.Error}")
// Eine Meldung, die die Order nicht beweisbar beendet, etwa ein
// Verbindungsabbruch: Unsere Sicht ist weg, die Order womoeglich nicht.
: OrderResult.Working(id, slot.Status,
$"Order {orderId}: {slot.Error} – ob sie beim Broker liegt, ist damit nicht entschieden.");
if (slot.Status == "Filled" && slot.Filled > 0)
return OrderResult.Filled(orderId.ToString(CultureInfo.InvariantCulture),
slot.Filled, (decimal)slot.AvgFillPrice);
return OrderResult.Filled(id, slot.Filled, (decimal)slot.AvgFillPrice);
return OrderResult.Fail($"Order {orderId} endete ohne Ausführung (Status {slot.Status}).");
if (IbkrMapping.IsDeadStatus(slot.Status))
return OrderResult.Fail($"Order {orderId} endete ohne Ausführung (Status {slot.Status}).");
return OrderResult.Working(id, slot.Status,
$"Order {orderId} steht auf {slot.Status}" +
(slot.Notice is null ? "" : $" – {slot.Notice}") + ".");
}
finally
{
@@ -655,7 +669,88 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
}
}
// ─── EWrapper-Callbacks (laufen auf dem Reader-Thread) ────────────────────
/// <summary>
/// Fragt das Orderbuch des Kontos ab – alle offenen Orders, auch die anderer Client-IDs und die
/// aus früheren Sitzungen.
///
/// <para><c>reqAllOpenOrders</c> statt <c>reqOpenOrders</c>: Letzteres liefert nur, was <b>dieser</b>
/// Client selbst abgesetzt hat. Eine Order, die der Dienst vor einem Neustart platziert hat,
/// fände man damit nicht – und genau die ist die gefährliche.</para>
/// </summary>
public async Task<IReadOnlyList<BrokerOrder>> RequestOpenOrdersAsync(TimeSpan timeout, CancellationToken ct)
{
var slot = new OpenOrderSlot();
_openOrders = slot;
try
{
_socket.reqAllOpenOrders();
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
_logger.Warn(LogModule,
"Zeitüberschreitung bei der Abfrage der offenen Orders. Das Ergebnis ist unvollständig " +
"und darf nicht als \"keine offenen Orders\" gelesen werden.");
return Array.Empty<BrokerOrder>();
}
lock (slot.Orders) return slot.Orders.ToList();
}
finally
{
_openOrders = null;
}
}
/// <summary>
/// Nimmt eine Order zurück und wartet auf die Bestätigung des Brokers.
///
/// <para>Ohne das Warten wäre die Rückmeldung wertlos: <c>cancelOrder</c> ist ein Zuruf, keine
/// Zusage. Bestätigt der Broker nicht, liegt die Order weiter – und der Aufrufer muss das
/// erfahren, statt sie für erledigt zu halten.</para>
/// </summary>
public async Task<bool> CancelOrderAsync(int orderId, TimeSpan timeout, CancellationToken ct)
{
var slot = new OrderSlot();
_orders[orderId] = slot;
try
{
_socket.cancelOrder(orderId);
if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false))
{
_logger.Warn(LogModule, $"Order {orderId}: keine Bestätigung der Stornierung.");
return false;
}
var cancelled = IbkrMapping.IsDeadStatus(slot.Status) || slot.Rejected;
_logger.Info(LogModule, cancelled
? $"Order {orderId} storniert."
: $"Order {orderId} nicht storniert (Status {slot.Status}).");
return cancelled;
}
finally
{
_orders.TryRemove(orderId, out _);
}
}
// ─── EWrapper-Callbacks (laufen auf dem Reader-Thread)
public override void openOrder(int orderId, Contract contract, Order order, OrderState state)
{
var slot = _openOrders;
if (slot is null) return;
var mapped = IbkrMapping.ToBrokerOrder(orderId, contract, order, state);
lock (slot.Orders)
{
// TWS wiederholt dieselbe Order gelegentlich innerhalb eines Abrufs.
if (!slot.Orders.Any(o => o.OrderId == mapped.OrderId && o.PermId == mapped.PermId))
slot.Orders.Add(mapped);
}
}
public override void openOrderEnd() => _openOrders?.Complete();
public override void nextValidId(int orderId)
{
@@ -851,6 +946,23 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
double avgFillPrice, int permId, int parentId, double lastFillPrice, int clientId,
string whyHeld, double mktCapPrice)
{
// reqAllOpenOrders liefert zu jeder Order beide Callbacks. openOrder kennt die Fuellmenge
// nicht - sie kommt hier nach und wird in die laufende Abfrage nachgetragen.
if (_openOrders is { } book)
{
lock (book.Orders)
{
var index = book.Orders.FindIndex(o => o.OrderId == orderId);
if (index >= 0)
book.Orders[index] = book.Orders[index] with
{
Status = status,
Filled = (int)filled,
Remaining = (int)remaining
};
}
}
if (!_orders.TryGetValue(orderId, out var slot)) return;
slot.Status = status;
@@ -869,6 +981,24 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
}
var text = $"[{errorCode}] {errorMsg}";
// Ordermeldungen zuerst und getrennt: Ob eine Meldung eine Order beendet, entscheidet der
// Code – nicht die Tatsache, dass sie über den Fehler-Callback kam. IBKR schickt über
// denselben Weg auch Hinweise zu angenommenen Orders (#489).
if (id >= 0 && _orders.TryGetValue(id, out var order))
{
if (IbkrMapping.IsOrderNotice(errorCode))
{
order.Notice = errorMsg;
_logger.Info(LogModule, $"Order {id} angenommen, Hinweis des Brokers: {text}");
return;
}
order.Rejected = IbkrMapping.IsOrderRejection(errorCode);
order.Fail(text);
return;
}
if (id >= 0 && FailPending(id, text)) return;
_logger.Warn(LogModule, text);
@@ -911,7 +1041,8 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
if (_quotes .TryGetValue(id, out var q)) { q.Fail(error); return true; }
if (_accounts .TryGetValue(id, out var a)) { a.Fail(error); return true; }
if (_contracts .TryGetValue(id, out var c)) { c.Fail(error); return true; }
if (_orders .TryGetValue(id, out var o)) { o.Fail(error); return true; }
// _orders fehlt hier absichtlich: siehe error(int, int, string) – dort wird unterschieden,
// ob eine Meldung die Order beendet oder nur begleitet.
if (_executions.TryGetValue(id, out var e)) { e.Fail(error); return true; }
return false;
}
@@ -1008,9 +1139,27 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
private sealed class OrderSlot : Slot
{
public string Status = "";
public int Filled;
public double AvgFillPrice;
public string Status = "";
public int Filled;
public double AvgFillPrice;
/// <summary>
/// Steht fest, dass beim Broker nichts liegt? Nur dann darf gebundenes Kapital zurück.
/// Vorgabe <c>false</c>: Ein Verbindungsabbruch beendet unsere Sicht auf die Order, nicht
/// die Order.
/// </summary>
public bool Rejected;
/// <summary>Hinweis des Brokers zu einer angenommenen Order (etwa 399).</summary>
public string? Notice;
}
/// <summary>
/// Das Orderbuch ist – wie das Portfolio – ein kontoweites Abonnement ohne eigene reqId.
/// </summary>
private sealed class OpenOrderSlot : Slot
{
public readonly List<BrokerOrder> Orders = new();
}
private sealed class PortfolioSlot : Slot
@@ -194,6 +194,39 @@ internal static class IbkrMapping
public bool IsAssumed => Source is ExecutionTimeSource.FallbackZone or ExecutionTimeSource.UnknownZoneFallback;
}
/// <summary>
/// Übersetzt eine offene Order des Brokers in unser Bild davon.
///
/// <para>Das Instrument darf <c>null</c> 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.</para>
///
/// <para><b>Ausgeführte Menge ist hier immer 0.</b> Sie steht nicht im <c>openOrder</c>-Callback,
/// sondern kommt über <c>orderStatus</c> nach; der Adapter trägt sie dort nach. Ein geratener
/// Wert wäre schlimmer als eine offene 0.</para>
/// </summary>
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);
}
/// <summary>Ausführungsseite laut TWS: "BOT" = gekauft, "SLD" = verkauft.</summary>
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";
/// <summary>
/// Ein Status, aus dem <b>bewiesen</b> folgt, dass beim Broker nichts mehr liegt.
///
/// <para>"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.</para>
/// </summary>
public static bool IsDeadStatus(string status) =>
status is "Cancelled" or "ApiCancelled";
/// <summary>
/// Meldungen, die zu einer Order gehören, sie aber <b>nicht</b> ablehnen.
///
/// <para><b>399 ist der Grund, aus dem es diese Liste gibt</b> (#489): IBKR schickt darunter
/// Hinweise zu einer <i>angenommenen</i> 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.</para>
/// </summary>
public static bool IsOrderNotice(int errorCode) =>
errorCode is 399 or 404;
/// <summary>
/// Codes, die eine Order <b>beweisbar</b> beenden. Alles andere ist im Zweifel eine Störung
/// <i>unserer</i> Sicht, nicht das Ende der Order.
/// </summary>
public static bool IsOrderRejection(int errorCode) =>
errorCode is 201 or 202 or 203 or 321 or 382 or 383;
/// <summary>Kein Marktdaten-Abo, TWS liefert stattdessen verzögerte Kurse.</summary>
public const int DelayedDataNotice = 10167;
@@ -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.
/// </summary>
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<IReadOnlyList<BrokerExecution>> GetExecutionsAsync(DateTime? since = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<BrokerExecution>>(Array.Empty<BrokerExecution>());
// 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<IReadOnlyList<BrokerOrder>> GetOpenOrdersAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<BrokerOrder>>(Array.Empty<BrokerOrder>());
public Task<bool> CancelOrderAsync(int orderId, CancellationToken ct = default)
=> Task.FromResult(false);
public Task<Quote?> GetQuoteAsync(InstrumentRef instrument, CancellationToken ct = default)
=> Task.FromResult<Quote?>(null);
+38 -5
View File
@@ -112,17 +112,39 @@ public sealed record OrderRequest
/// <summary>Ergebnis einer Order-Platzierung.</summary>
public sealed record OrderResult
{
public bool Success { get; init; }
/// <summary>Wie die Order ausgegangen ist. Maßgeblich; <see cref="Success"/> leitet sich daraus ab.</summary>
public OrderOutcome Outcome { get; init; } = OrderOutcome.Rejected;
/// <summary>
/// Ausgeführt – und <b>nur</b> das. Ein <see cref="OrderOutcome.Working"/> ist kein Erfolg,
/// aber auch kein Fehlschlag; wer nur dieses Flag prüft, behandelt beides gleich.
/// </summary>
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 };
/// <summary>Letzter vom Broker gemeldeter Status – nur bei <see cref="OrderOutcome.Working"/> belegt.</summary>
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 };
/// <summary>
/// Endgültig nicht zustande gekommen. Nur verwenden, wenn feststeht, dass beim Broker nichts
/// liegt – im Zweifel <see cref="Working"/>.
/// </summary>
public static OrderResult Fail(string error) =>
new() { Success = false, Error = error };
new() { Outcome = OrderOutcome.Rejected, Error = error };
/// <summary>
/// Abgesendet, Ausgang offen. Gebundenes Kapital bleibt gebunden, bis das Schicksal der Order
/// feststeht.
/// </summary>
public static OrderResult Working(string orderId, string status, string message) =>
new() { Outcome = OrderOutcome.Working, OrderId = orderId, Status = status, Error = message };
}
/// <summary>Momentaufnahme eines Kurses.</summary>
@@ -397,14 +419,25 @@ public sealed record RiskDecision
/// <summary>Ergebnis einer Signal-Ausführung durch den <see cref="IExecutionService"/>.</summary>
public sealed record ExecutionResult
{
/// <summary>"EXECUTE", "SKIP" oder "ERROR".</summary>
/// <summary>"EXECUTE", "PENDING", "SKIP" oder "ERROR".</summary>
public required string Action { get; init; }
public string Reason { get; init; } = "";
public OrderResult? Order { get; init; }
/// <summary>Ausgeführt und verbucht. Ein <see cref="Pending"/> ist das ausdrücklich nicht.</summary>
public bool Executed => Action == "EXECUTE";
/// <summary>
/// Die Order liegt beim Broker, ihr Ausgang steht noch aus. Für ein Modul heißt das:
/// <b>nicht erneut senden</b> – anders als bei SKIP oder ERROR.
/// </summary>
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 };
/// <summary>Abgesendet, Ausgang offen – gebundenes Kapital bleibt gebunden.</summary>
public static ExecutionResult Pending(string reason, OrderResult order) =>
new() { Action = "PENDING", Reason = reason, Order = order };
}
@@ -18,12 +18,21 @@ namespace IBKRTrader.Core.Workers.BuiltIn;
/// globaler Kaufstopp – <b>und niemals ein automatischer Verkauf zur Heilung</b>. 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.</item>
/// <item>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.</item>
/// </list>
///
/// <para><b>Die Reihenfolge ist Absicht:</b> 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.</para>
/// </summary>
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<CoreDbContext> 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" : "") + ".";
}
}
+178
View File
@@ -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;
/// <summary>
/// Der Blick ins Orderbuch des Brokers – und der Abgleich mit dem, was wir selbst gebucht haben
/// (#489).
///
/// <para><b>Wozu es das gibt.</b> 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.</para>
///
/// <para><b>Die Aussage, die dieser Schalter nicht macht:</b> 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.</para>
/// </summary>
internal static class OrderBookTool
{
public static async Task<int> ListAsync(IServiceProvider services)
{
var inv = CultureInfo.InvariantCulture;
var book = services.GetRequiredService<IBrokerOrderBook>();
var dbf = services.GetRequiredService<IDbContextFactory<CoreDbContext>>();
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 <ID> --confirm");
return 2;
}
Console.WriteLine("Orderbuch und Bindungen decken sich.");
return 0;
}
public static async Task<int> 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<IBrokerOrderBook>();
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;
}
/// <summary>
/// Nimmt <b>alle</b> offenen Orders zurück – das Aufräumwerkzeug nach einem Testlauf.
///
/// <para><b>Nur im Paper-Modus.</b> 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.</para>
/// </summary>
private static async Task<int> CancelAllAsync(IServiceProvider services, bool confirm)
{
var trading = services.GetRequiredService<SettingsService>().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<IBrokerOrderBook>();
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;
}
}
+14
View File
@@ -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);
+16 -3
View File
@@ -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 <ID> --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;
}
+20 -11
View File
@@ -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<ReservationService>();
var orphans = reservations.RecoverAsync(Array.Empty<string>()).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<IbkrBrokerClient>();
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<IbkrBrokerClient>());
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<IbkrBrokerClient>());
services.AddSingleton<IBrokerOrderBook>(sp => sp.GetRequiredService<IbkrBrokerClient>());
services.AddSingleton<IOptionMarketData>(sp => sp.GetRequiredService<IbkrBrokerClient>());
services.AddSingleton<IQuoteSource>(sp => sp.GetRequiredService<IbkrBrokerClient>());
}
@@ -279,6 +287,7 @@ public static class AppHostBuilder
services.AddSingleton<NullBrokerClient>();
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<NullBrokerClient>());
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<NullBrokerClient>());
services.AddSingleton<IBrokerOrderBook>(sp => sp.GetRequiredService<NullBrokerClient>());
services.AddSingleton<IOptionMarketData>(sp => sp.GetRequiredService<NullBrokerClient>());
services.AddSingleton<IQuoteSource>(sp => sp.GetRequiredService<NullBrokerClient>());
}
@@ -299,4 +299,79 @@ public class ExecutionServiceTests
Arg.Any<string>(), Arg.Any<InstrumentRef>(), Arg.Any<TradeSide>(),
Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
}
// ─── Die Order, die beim Broker liegt (#489) ─────────────────────────────
/// <summary>
/// <b>Der Test, um den es bei #489 geht.</b> 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.
/// </summary>
[Fact]
public async Task WorkingOrder_KeepsTheCapitalBound()
{
ArrangeHappyPath();
_broker.PlaceOrderAsync(Arg.Any<OrderRequest>(), Arg.Any<CancellationToken>())
.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<CancellationToken>());
await _capital.DidNotReceive().CancelReservationAsync(
Arg.Any<long>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[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<OrderRequest>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(OrderResult.Working("O-77", "PreSubmitted", "wartet")));
await CreateSut().ExecuteAsync(BuySignal);
await _portfolio.DidNotReceive().RecordFillAsync(
Arg.Any<string>(), Arg.Any<InstrumentRef>(), Arg.Any<TradeSide>(),
Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
await _capital.DidNotReceive().SettleReservationAsync(
Arg.Any<long>(), Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[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<OrderRequest>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(OrderResult.Fail("Order abgelehnt")));
await CreateSut().ExecuteAsync(BuySignal);
await _capital.Received(1).CancelReservationAsync(
1, Arg.Any<string>(), Arg.Any<CancellationToken>());
await _capital.DidNotReceive().MarkSubmittedAsync(
Arg.Any<long>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[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<OrderRequest>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(OrderResult.Working("O-77", "PreSubmitted", "wartet")));
await CreateSut().ExecuteAsync(BuySignal);
_journal.Received().Write(Arg.Is<CoreDecisionRecord>(r =>
r.Decision == TradeDecision.Pending && r.Reason == DecisionReason.OrderWorking));
_orderLog.Received().Write(Arg.Is<CoreOrderEvent>(e => e.EventType == OrderEventType.Placed));
}
}
@@ -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) ─────────────────────────────────────────
/// <summary>
/// Der Fehler, der 67 Orders unsichtbar gemacht hat: IBKR schickt unter Code 399 Hinweise zu
/// <b>angenommenen</b> 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.
/// </summary>
[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");
}
}