diff --git a/src/PolyTrader.Core/Services/IClobClient.cs b/src/PolyTrader.Core/Services/IClobClient.cs index 6ce6b5c..aef104e 100644 --- a/src/PolyTrader.Core/Services/IClobClient.cs +++ b/src/PolyTrader.Core/Services/IClobClient.cs @@ -25,8 +25,11 @@ namespace PolyTraderSharp.Services /// Cancelt konfligierende offene Orders desselben Tokens vor dem Nachpreisen/Platzieren. Task CancelConflictingOrdersAsync(AccountState acc, string assetId, decimal newPrice, string sideStr); - /// Liefert die offenen Orders (Id, Side, Price) für ein Asset. - Task> GetOpenOrdersAsync(AccountState acc, string assetId); + /// + /// Liefert die offenen Orders (Id, Side, Price). Mit nur für dieses + /// Asset; ohne (leer) ALLE offenen Orders des Accounts – genutzt von der Startup-Reconciliation. + /// + Task> GetOpenOrdersAsync(AccountState acc, string assetId = ""); /// Cancelt eine einzelne Order per OrderId; true bei Erfolg. Task CancelOrderAsync(AccountState acc, string orderId); diff --git a/src/PolyTrader.Core/Services/PolymarketClobClient.cs b/src/PolyTrader.Core/Services/PolymarketClobClient.cs index 7947422..2503e66 100644 --- a/src/PolyTrader.Core/Services/PolymarketClobClient.cs +++ b/src/PolyTrader.Core/Services/PolymarketClobClient.cs @@ -364,7 +364,7 @@ namespace PolyTraderSharp.Services return 0; } - public async Task> GetOpenOrdersAsync(AccountState acc, string assetId) + public async Task> GetOpenOrdersAsync(AccountState acc, string assetId = "") { var result = new System.Collections.Generic.List<(string Id, string Side, decimal Price)>(); if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey)) @@ -373,7 +373,10 @@ namespace PolyTraderSharp.Services try { string endpoint = "/data/orders"; - string requestUrl = $"{endpoint}?asset_id={assetId}"; + // Ohne assetId: ALLE offenen Orders des Accounts (Startup-Reconciliation K2). + // Die HMAC-Signatur geht über den Pfad "/data/orders" ohne Query-String, daher ist + // das Weglassen des asset_id-Filters signaturneutral. + string requestUrl = string.IsNullOrEmpty(assetId) ? endpoint : $"{endpoint}?asset_id={assetId}"; string timestamp = GetClobTimestamp(); string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint); diff --git a/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs b/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs index 52c7b51..00283c4 100644 --- a/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs +++ b/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs @@ -51,6 +51,15 @@ namespace PolyTrader.Modules.CopyTrading // WSS-Infrastruktur (Core) wird nur vom Copytrading-Blockchain-Listener genutzt. services.AddSingleton(); + // IClobClient-Seam: Leiter/Reconciliation hängen am Interface (mockbar für Integrationstests); + // die Live-Instanz ist der eine PolymarketClobClient-Singleton aus dem App-Container. + services.AddSingleton(sp => sp.GetRequiredService()); + + // K2 – Neustart-Reconciliation: MUSS als erster Modul-HostedService laufen (nach der + // State-Hydration, vor TraderMonitor/Engine), damit verwaiste CLOB-Orders vom vorherigen + // Lauf storniert sind, bevor das erste Signal verarbeitet wird. + services.AddHostedService(); + // Modul-Services (Signalquelle, Ausführung, Analytics) services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); @@ -66,10 +75,6 @@ namespace PolyTrader.Modules.CopyTrading services.AddHostedService(); services.AddHostedService(); - // IClobClient-Seam: Leiter/Reconciliation hängen am Interface (mockbar für Integrationstests); - // die Live-Instanz ist der eine PolymarketClobClient-Singleton aus dem App-Container. - services.AddSingleton(sp => sp.GetRequiredService()); - // Phase 0.1: SELL-Eskalationsleiter (preist offene Exit-Limits stufenweise nach). // Singleton + Hosted, damit Engine und TraderMonitor StartLadderAsync aufrufen können. services.AddSingleton(); diff --git a/src/PolyTrader.Modules.CopyTrading/Services/StartupOrderReconciliationService.cs b/src/PolyTrader.Modules.CopyTrading/Services/StartupOrderReconciliationService.cs new file mode 100644 index 0000000..a834b23 --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Services/StartupOrderReconciliationService.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using PolyTraderSharp; + +namespace PolyTraderSharp.Services +{ + /// + /// K2 (Fable-Fixes) – Neustart-Reconciliation. Ruhende GTC-Orders der SELL-Eskalationsleiter + /// (und ruhende MakerEntry-BUYs) überleben einen Neustart auf dem CLOB, der zugehörige + /// Verwaltungszustand (ExitLadders, Position.ExitPending, PendingOrderTimestamps) ist aber + /// bewusst transient. Ohne Bereinigung liefe die Engine gegen Waisen-Orders (Doppel-Leiter auf + /// dieselben Shares, Fehlerkaskaden). + /// + /// Deterministische Lösung: Beim Start je LIVE-Account alle offenen CLOB-Orders pauschal canceln. + /// Danach entscheidet die Engine sauber neu (Master-SELL/Profit-Target starten frische Leitern). + /// Bewusst blockierend als IHostedService, registriert VOR TraderMonitor/Engine, damit die + /// Bereinigung abgeschlossen ist, bevor das erste Signal verarbeitet wird. Pro Account + /// fehlertolerant – ein Account-Fehler darf den Start nicht verhindern. + /// + public class StartupOrderReconciliationService : IHostedService + { + private readonly TradingState _state; + private readonly IClobClient _clob; + private readonly TerminalLogger _logger; + + public StartupOrderReconciliationService(TradingState state, IClobClient clob, TerminalLogger logger) + { + _state = state; + _clob = clob; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + int accountsProcessed = 0, ordersCancelled = 0; + + foreach (var acc in _state.Accounts.Values) + { + if (cancellationToken.IsCancellationRequested) break; + + // Nur echte Live-Accounts mit CLOB-Credentials haben ruhende Orders. + if (acc.IsDemo) continue; + if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) + || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey)) continue; + + try + { + var openOrders = await _clob.GetOpenOrdersAsync(acc); // ohne assetId = ALLE offenen Orders + if (openOrders.Count == 0) + { + _logger.Info($"🧹 [Startup-Reconciliation] {acc.Name}: keine offenen Orders."); + accountsProcessed++; + continue; + } + + _logger.Warning($"🧹 [Startup-Reconciliation] {acc.Name}: {openOrders.Count} verwaiste Order(s) vom vorherigen Lauf – storniere alle."); + foreach (var order in openOrders) + { + if (cancellationToken.IsCancellationRequested) break; + try + { + bool ok = await _clob.CancelOrderAsync(acc, order.Id); + if (ok) ordersCancelled++; + else _logger.Warning($"⚠️ [Startup-Reconciliation] {acc.Name}: Order {order.Id} konnte nicht storniert werden."); + } + catch (Exception ex) + { + _logger.Error($"[Startup-Reconciliation] {acc.Name}: Cancel {order.Id} fehlgeschlagen: {ex.Message}"); + } + } + accountsProcessed++; + } + catch (Exception ex) + { + _logger.Error($"[Startup-Reconciliation] {acc.Name}: Abruf offener Orders fehlgeschlagen: {ex.Message}"); + } + } + + _logger.Info($"🧹 [Startup-Reconciliation] Abgeschlossen: {accountsProcessed} Live-Account(s) geprüft, {ordersCancelled} Order(s) storniert. Engine entscheidet jetzt sauber neu."); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/tests/PolyTrader.Tests/Fakes/FakeClobClient.cs b/tests/PolyTrader.Tests/Fakes/FakeClobClient.cs index 9efba56..7ed66cc 100644 --- a/tests/PolyTrader.Tests/Fakes/FakeClobClient.cs +++ b/tests/PolyTrader.Tests/Fakes/FakeClobClient.cs @@ -25,9 +25,12 @@ namespace PolyTrader.Tests.Fakes public Queue PlaceResults { get; } = new(); public bool CancelResult { get; set; } = true; - /// Offene Orders je Asset, die GetOpenOrdersAsync zurückgibt. + /// Offene Orders je Asset, die GetOpenOrdersAsync(assetId) zurückgibt. public Dictionary> OpenOrdersByAsset { get; } = new(); + /// Alle offenen Orders des Accounts, die GetOpenOrdersAsync() ohne assetId zurückgibt (K2). + public List<(string Id, string Side, decimal Price)> AllOpenOrders { get; } = new(); + public Task PlaceOrderAsync( AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, @@ -44,8 +47,10 @@ namespace PolyTrader.Tests.Fakes return Task.CompletedTask; } - public Task> GetOpenOrdersAsync(AccountState acc, string assetId) + public Task> GetOpenOrdersAsync(AccountState acc, string assetId = "") { + if (string.IsNullOrEmpty(assetId)) + return Task.FromResult(new List<(string, string, decimal)>(AllOpenOrders)); if (OpenOrdersByAsset.TryGetValue(assetId, out var list)) return Task.FromResult(new List<(string, string, decimal)>(list)); return Task.FromResult(new List<(string, string, decimal)>()); diff --git a/tests/PolyTrader.Tests/StartupOrderReconciliationTests.cs b/tests/PolyTrader.Tests/StartupOrderReconciliationTests.cs new file mode 100644 index 0000000..2c91b88 --- /dev/null +++ b/tests/PolyTrader.Tests/StartupOrderReconciliationTests.cs @@ -0,0 +1,83 @@ +using System.Threading; +using System.Threading.Tasks; +using PolyTrader.Tests.Fakes; +using PolyTraderSharp; +using PolyTraderSharp.Models; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests +{ + /// + /// Slice 2 (Fable-Fixes) – K2 Neustart-Reconciliation: Beim Start werden alle offenen CLOB-Orders + /// je Live-Account pauschal storniert, damit die Engine gegen keine Waisen-Orders anläuft. + /// + public class StartupOrderReconciliationTests + { + private static AccountState LiveAccount(int id, string name) + { + return new AccountState + { + AccountId = id, + Name = name, + IsDemo = false, + ApiKey = "k", + ApiSecret = "s", + ApiPassphrase = "p", + PrivateKey = "0xabc" + }; + } + + [Fact] + public async Task Cancels_all_open_orders_for_live_accounts() + { + var state = new TradingState(); + var acc = LiveAccount(1, "Live"); + state.Accounts[acc.AccountId] = acc; + + var clob = new FakeClobClient(); + clob.AllOpenOrders.Add(("o1", "SELL", 0.40m)); + clob.AllOpenOrders.Add(("o2", "BUY", 0.55m)); + + var svc = new StartupOrderReconciliationService(state, clob, new TerminalLogger()); + await svc.StartAsync(CancellationToken.None); + + Assert.Equal(new[] { "o1", "o2" }, clob.CanceledOrderIds); + } + + [Fact] + public async Task Skips_demo_accounts_and_accounts_without_credentials() + { + var state = new TradingState(); + + var demo = LiveAccount(1, "Demo"); + demo.IsDemo = true; + var noCreds = new AccountState { AccountId = 2, Name = "NoCreds", IsDemo = false }; + state.Accounts[demo.AccountId] = demo; + state.Accounts[noCreds.AccountId] = noCreds; + + var clob = new FakeClobClient(); + clob.AllOpenOrders.Add(("x", "SELL", 0.40m)); + + var svc = new StartupOrderReconciliationService(state, clob, new TerminalLogger()); + await svc.StartAsync(CancellationToken.None); + + Assert.Empty(clob.CanceledOrderIds); // weder Demo noch credential-lose Accounts angefasst + } + + [Fact] + public async Task No_open_orders_cancels_nothing() + { + var state = new TradingState(); + var acc = LiveAccount(1, "Live"); + state.Accounts[acc.AccountId] = acc; + + var clob = new FakeClobClient(); // AllOpenOrders leer + + var svc = new StartupOrderReconciliationService(state, clob, new TerminalLogger()); + await svc.StartAsync(CancellationToken.None); + + Assert.Empty(clob.CanceledOrderIds); + } + } +}