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