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