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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user