diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs
index c4122b7..6cdeb5b 100644
--- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs
+++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs
@@ -264,7 +264,15 @@ namespace PolyTraderSharp.Services
// Identische Preise bleiben bestehen. Abweichende verhindern ungültiges Blockieren von Funds.
if (!account.IsDemo && !string.IsNullOrEmpty(signal.TokenId))
{
- if (account.HasOpenLimitOrders)
+ // H2: Läuft für diesen Markt eine SELL-Eskalationsleiter, gehört die ruhende Order der
+ // Leiter. Der Pre-Signal-Cleanup würde sie sonst bedingungslos canceln – danach liegt bis
+ // zur nächsten Leiter-Stufe (20/120s) keine Order im Markt und die Leiter merkt nichts.
+ string ladderKey = $"{account.AccountId}_{signal.TokenId}";
+ if (_copyState.ExitLadders.ContainsKey(ladderKey))
+ {
+ _logger.TradeReasoning($"⏭️ [Order-Cleanup übersprungen] {account.Name} | {signal.MarketQuestion}: aktive SELL-Leiter besitzt die Order.");
+ }
+ else if (account.HasOpenLimitOrders)
{
await _clob.CancelConflictingOrdersAsync(account, signal.TokenId, signal.Price, signal.Side);
}
diff --git a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs
index cf362a6..42ee872 100644
--- a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs
+++ b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs
@@ -58,6 +58,8 @@ namespace PolyTraderSharp.Services
AccountState account, Position pos, decimal referencePrice, int sourceTraderId,
bool isHf, decimal maxPriceDifferencePct, decimal sellFloorPct, bool isNegRisk, string reasonTag)
{
+ string key = $"{account.AccountId}_{pos.TokenId}";
+
decimal firstLimit = SellLogic.FirstLimit(referencePrice, isHf, maxPriceDifferencePct);
decimal floor = SellLogic.Floor(referencePrice, sellFloorPct);
firstLimit = Math.Clamp(firstLimit, 0.01m, 0.99m);
@@ -73,7 +75,35 @@ namespace PolyTraderSharp.Services
return false;
}
- // Position tracken + ExitPending (idempotent für Engine-Fall [vorher entfernt] und Sync-Fall).
+ // H1 – Atomarer Claim ZUERST: nur EINE Leiter je Position. Master-SELL (Engine) und
+ // Profit-Target (Sync) laufen ohne gemeinsames Lock; ohne diesen Claim könnten beide
+ // ExitPending==false lesen und zwei GTC-SELLs / Fehlerkaskaden auslösen.
+ var ladder = new ExitLadderState
+ {
+ AccountId = account.AccountId,
+ TokenId = pos.TokenId,
+ SourceTraderId = sourceTraderId,
+ MarketQuestion = pos.MarketQuestion,
+ ReferencePrice = referencePrice,
+ CurrentLimit = firstLimit,
+ Floor = floor,
+ IsHf = isHf,
+ Attempt = 1,
+ LastActionAt = DateTime.UtcNow
+ };
+ if (!_copyState.ExitLadders.TryAdd(key, ladder))
+ {
+ // Es läuft bereits eine Leiter für diese Position (anderer Aufrufer hat den Claim).
+ // Position der bestehenden Leiter überlassen, aber sicherstellen, dass sie – falls der
+ // Aufrufer (Engine) sie zuvor entfernt hat – als ExitPending präsent bleibt (kein Waise).
+ pos.ExitPending = true;
+ account.OpenPositions[pos.TokenId] = pos;
+ if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, pos);
+ _logger.Info($"🪜 [SELL-LEITER {reasonTag}] {account.Name} | {pos.MarketQuestion}: Leiter läuft bereits – kein Doppelstart.");
+ return false;
+ }
+
+ // Ab hier besitzt DIESER Aufruf die Leiter. Position tracken + ExitPending.
pos.ExitPending = true;
account.OpenPositions[pos.TokenId] = pos;
if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, pos);
@@ -85,28 +115,16 @@ namespace PolyTraderSharp.Services
$" Stufen: {(isHf ? "HF ~20s" : "~120s")}/Schritt, {SellLogic.LadderStepPct}% relativ");
var result = await _clob.PlaceOrderAsync(account, pos.TokenId, "SELL", pos.Size * firstLimit, firstLimit, "GTC", _state.DebugOrderPayloadLog, isNegRisk);
- string key = $"{account.AccountId}_{pos.TokenId}";
if (result == "OK")
{
- _copyState.ExitLadders[key] = new ExitLadderState
- {
- AccountId = account.AccountId,
- TokenId = pos.TokenId,
- SourceTraderId = sourceTraderId,
- MarketQuestion = pos.MarketQuestion,
- ReferencePrice = referencePrice,
- CurrentLimit = firstLimit,
- Floor = floor,
- IsHf = isHf,
- Attempt = 1,
- LastActionAt = DateTime.UtcNow
- };
_copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, sourceTraderId, "SELL");
_logger.Trade($"✅ [SELL-LEITER platziert · {reasonTag}] {account.Name} | GTC-Limit {firstLimit:F3} für {pos.Size:F2} Shares.");
return true;
}
+ // Startorder fehlgeschlagen: Claim + ExitPending zurücknehmen, Position bleibt handelbar.
+ _copyState.ExitLadders.TryRemove(key, out _);
pos.ExitPending = false;
_copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow.AddSeconds(-15), sourceTraderId, "SELL");
_logger.TradeReasoning($"❌ [SELL-LEITER · {reasonTag}] Startorder fehlgeschlagen: {result}. Position bleibt; neuer Versuch beim nächsten Signal/Sync.");
@@ -135,7 +153,7 @@ namespace PolyTraderSharp.Services
}
}
- private async Task ProcessLadderAsync(string key, ExitLadderState ladder)
+ internal async Task ProcessLadderAsync(string key, ExitLadderState ladder)
{
// Account weg? -> Leiter verwerfen.
if (!_state.Accounts.TryGetValue(ladder.AccountId, out var account))
@@ -156,9 +174,43 @@ namespace PolyTraderSharp.Services
double waited = (DateTime.UtcNow - ladder.LastActionAt).TotalSeconds;
if (waited < SellLogic.LadderIntervalSeconds(ladder.IsHf)) return;
- // Bereits am Floor: halten + einmalig benachrichtigen (Order ruht weiter auf dem Floor).
+ // Bereits am Floor: Order ruht auf dem Floor und soll dort auf einen Fill warten.
if (SellLogic.IsAtFloor(ladder.CurrentLimit, ladder.Floor))
{
+ // K1 – Floor-Robustheit: prüfen, ob die Verkaufsorder überhaupt noch auf dem Markt ruht.
+ // Falls nicht (Cleanup-Timeout, externer Cancel, Teil-Fill hat die Order entfernt), würde die
+ // Position sonst bis zum Neustart unverkäuflich liegen bleiben → am Floor neu platzieren.
+ bool orderResting;
+ try
+ {
+ var open = await _clob.GetOpenOrdersAsync(account, ladder.TokenId);
+ orderResting = open.Any(o => (o.Side ?? string.Empty).ToUpperInvariant().Contains("SELL"));
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"[SELL-LEITER Floor] Order-Existenzprüfung fehlgeschlagen: {ex.Message}");
+ ladder.LastActionAt = DateTime.UtcNow;
+ return;
+ }
+
+ if (!orderResting)
+ {
+ bool isNegFloor = _state.MarketCache.TryGetValue(ladder.TokenId, out var mdFloor) && mdFloor.NegRisk;
+ _logger.Warning($"🔁 [SELL-LEITER Floor] {account.Name} | {ladder.MarketQuestion}: keine ruhende SELL-Order am Floor {ladder.Floor:F3} – platziere neu.");
+ try
+ {
+ var r = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", pos.Size * ladder.Floor, ladder.Floor, "GTC", _state.DebugOrderPayloadLog, isNegFloor);
+ if (r == "OK")
+ _copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, ladder.SourceTraderId, "SELL");
+ else
+ _logger.Warning($"⚠️ [SELL-LEITER Floor] Neuplatzierung am Floor fehlgeschlagen ({r}) – nächster Versuch beim nächsten Intervall.");
+ }
+ catch (Exception ex) { _logger.Error($"[SELL-LEITER Floor] Neuplatzierung-Fehler: {ex.Message}"); }
+ ladder.LastActionAt = DateTime.UtcNow;
+ return;
+ }
+
+ // Order ruht am Floor: einmalig benachrichtigen, dann halten.
if (!ladder.FloorNotified)
{
ladder.FloorNotified = true;
diff --git a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs
index d811e1f..6bcc8b2 100644
--- a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs
+++ b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs
@@ -1145,6 +1145,11 @@ namespace PolyTraderSharp.Services
if (parts.Length != 2 || !int.TryParse(parts[0], out int accountId)) continue;
string tokenId = parts[1];
+ // K1: Läuft für diesen Key eine SELL-Eskalationsleiter, gehört die offene Order der
+ // Leiter (sie preist selbst nach bzw. hält am Floor). Der Stale-Cleanup darf sie NICHT
+ // canceln – sonst ruht keine Verkaufsorder mehr und die Position steckt am Floor fest.
+ if (_copyState.ExitLadders.ContainsKey(kvp.Key)) continue;
+
if (!_state.Accounts.TryGetValue(accountId, out var account) || account.IsDemo) continue;
// Determine timeout based on trader category
diff --git a/tests/PolyTrader.Tests/Fakes/FakePositionRepository.cs b/tests/PolyTrader.Tests/Fakes/FakePositionRepository.cs
new file mode 100644
index 0000000..919f75c
--- /dev/null
+++ b/tests/PolyTrader.Tests/Fakes/FakePositionRepository.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using PolyTrader.Core.Persistence;
+using PolyTraderSharp.Models;
+
+namespace PolyTrader.Tests.Fakes
+{
+ ///
+ /// Minimaler In-Memory-Stub für . Die Leiter-Tests brauchen nur,
+ /// dass UpsertLive nicht crasht; die eigentliche Persistenz ist hier irrelevant.
+ ///
+ public sealed class FakePositionRepository : IPositionRepository
+ {
+ public List<(int AccountId, string TokenId)> UpsertedLive { get; } = new();
+
+ public List GetLive(int accountId) => new();
+ public List GetDemo(int accountId) => new();
+ public Position? FindLive(int accountId, string tokenId) => null;
+ public Position? FindDemo(int accountId, string tokenId) => null;
+ public void UpsertLive(int accountId, Position position) => UpsertedLive.Add((accountId, position.TokenId));
+ public void UpsertDemo(int accountId, Position position) { }
+ public void DeleteLive(int accountId, string tokenId) { }
+ public void DeleteDemo(int accountId, string tokenId) { }
+ public void DropDemo(int accountId) { }
+ }
+}
diff --git a/tests/PolyTrader.Tests/SellLadderServiceTests.cs b/tests/PolyTrader.Tests/SellLadderServiceTests.cs
new file mode 100644
index 0000000..b0fa149
--- /dev/null
+++ b/tests/PolyTrader.Tests/SellLadderServiceTests.cs
@@ -0,0 +1,196 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using PolyTrader.Tests.Fakes;
+using PolyTraderSharp;
+using PolyTraderSharp.Models;
+using PolyTraderSharp.Services;
+using Xunit;
+
+namespace PolyTrader.Tests
+{
+ ///
+ /// Integrationstests der SELL-Eskalationsleiter über einen gemockten CLOB-Client
+ /// (Slice 1 der Fable-Fixes): atomarer Leiter-Claim (H1) und Floor-Robustheit (K1).
+ /// Diese Fehler entstehen zwischen Services und sind durch reine Unit-Tests nicht fangbar.
+ ///
+ public class SellLadderServiceTests
+ {
+ private const string Tok = "tok-1";
+
+ private static (SellLadderService svc, CopyTradingState copy, TradingState state, FakeClobClient clob, FakePositionRepository repo)
+ Build(FakeClobClient? clob = null)
+ {
+ var copy = new CopyTradingState();
+ var state = new TradingState();
+ clob ??= new FakeClobClient();
+ var logger = new TerminalLogger();
+ var threema = new ThreemaService(logger, new JobManager());
+ var repo = new FakePositionRepository();
+ var svc = new SellLadderService(copy, state, clob, logger, threema, repo);
+ return (svc, copy, state, clob, repo);
+ }
+
+ private static (AccountState account, Position pos) LiveAccountWithPosition(TradingState state, decimal size = 100m)
+ {
+ var account = new AccountState { AccountId = 1, Name = "Live-Test", IsDemo = false };
+ var pos = new Position
+ {
+ TokenId = Tok,
+ MarketQuestion = "Wird X passieren?",
+ SourceTraderId = 7,
+ Size = size,
+ EntryPrice = 0.40m,
+ CurrentPrice = 0.50m,
+ AmountUsd = 40m
+ };
+ account.OpenPositions[pos.TokenId] = pos;
+ state.Accounts[account.AccountId] = account;
+ return (account, pos);
+ }
+
+ // ---------- H1: Atomarer Claim ----------
+
+ [Fact]
+ public async Task StartLadder_places_one_order_and_registers_ladder()
+ {
+ var (svc, copy, state, clob, _) = Build();
+ var (account, pos) = LiveAccountWithPosition(state);
+
+ bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
+
+ Assert.True(ok);
+ Assert.True(copy.ExitLadders.ContainsKey("1_" + Tok));
+ Assert.True(pos.ExitPending);
+ Assert.Single(clob.Placed);
+ Assert.Equal("SELL", clob.Placed[0].Side);
+ }
+
+ [Fact]
+ public async Task Second_StartLadder_for_same_position_is_idempotent_no_second_order()
+ {
+ var (svc, copy, state, clob, _) = Build();
+ var (account, pos) = LiveAccountWithPosition(state);
+
+ bool first = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
+ bool second = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Profit Target");
+
+ Assert.True(first);
+ Assert.False(second); // zweiter Aufruf verliert den Claim
+ Assert.Single(clob.Placed); // KEINE zweite GTC-Order
+ }
+
+ [Fact]
+ public async Task Concurrent_StartLadder_starts_exactly_one_ladder()
+ {
+ var (svc, copy, state, clob, _) = Build();
+ var (account, pos) = LiveAccountWithPosition(state);
+
+ var results = await Task.WhenAll(
+ Enumerable.Range(0, 8).Select(_ =>
+ svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "race")));
+
+ Assert.Equal(1, results.Count(r => r)); // genau ein Gewinner
+ Assert.Single(clob.Placed); // genau eine Order
+ Assert.True(copy.ExitLadders.ContainsKey("1_" + Tok));
+ }
+
+ [Fact]
+ public async Task StartLadder_when_ladder_already_exists_readds_position_as_exitpending()
+ {
+ // H1-Waisen-Schutz: Engine entfernt die Position vor StartLadderAsync. Verliert dieser Aufruf
+ // den Claim (andere Leiter läuft schon), muss die Position trotzdem als ExitPending präsent bleiben.
+ var (svc, copy, state, clob, _) = Build();
+ var (account, pos) = LiveAccountWithPosition(state);
+
+ copy.ExitLadders["1_" + Tok] = new ExitLadderState { AccountId = 1, TokenId = Tok, Floor = 0.40m, CurrentLimit = 0.45m };
+ account.OpenPositions.TryRemove(Tok, out _); // Engine hat entfernt
+ pos.ExitPending = false;
+
+ bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
+
+ Assert.False(ok);
+ Assert.True(account.OpenPositions.ContainsKey(Tok)); // wieder da
+ Assert.True(pos.ExitPending);
+ Assert.Empty(clob.Placed); // keine neue Order
+ }
+
+ [Fact]
+ public async Task StartLadder_releases_claim_when_place_fails()
+ {
+ var clob = new FakeClobClient { PlaceResult = "ERROR: insufficient balance" };
+ var (svc, copy, state, _, _) = Build(clob);
+ var (account, pos) = LiveAccountWithPosition(state);
+
+ bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
+
+ Assert.False(ok);
+ Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Claim zurückgegeben
+ Assert.False(pos.ExitPending);
+ Assert.True(account.OpenPositions.ContainsKey(Tok)); // Position bleibt handelbar
+ }
+
+ // ---------- K1: Floor-Robustheit ----------
+
+ private static ExitLadderState FloorLadder() => new()
+ {
+ AccountId = 1,
+ TokenId = Tok,
+ SourceTraderId = 7,
+ MarketQuestion = "Wird X passieren?",
+ ReferencePrice = 0.50m,
+ CurrentLimit = 0.40m,
+ Floor = 0.40m, // CurrentLimit == Floor -> IsAtFloor
+ IsHf = false,
+ Attempt = 5,
+ LastActionAt = DateTime.UtcNow.AddMinutes(-10) // Intervall (120s) längst vorbei
+ };
+
+ [Fact]
+ public async Task Floor_replaces_order_when_none_resting()
+ {
+ var (svc, copy, state, clob, _) = Build();
+ var (account, pos) = LiveAccountWithPosition(state);
+ var ladder = FloorLadder();
+ copy.ExitLadders["1_" + Tok] = ladder;
+ // FakeClob liefert für den Token keine offenen Orders -> Order ist weg (z.B. Cleanup/extern).
+
+ await svc.ProcessLadderAsync("1_" + Tok, ladder);
+
+ Assert.Single(clob.Placed);
+ Assert.Equal("SELL", clob.Placed[0].Side);
+ Assert.Equal(0.40m, clob.Placed[0].Price); // am Floor neu platziert
+ Assert.True(copy.PendingOrderTimestamps.ContainsKey("1_" + Tok));
+ }
+
+ [Fact]
+ public async Task Floor_holds_when_order_still_resting()
+ {
+ var (svc, copy, state, clob, _) = Build();
+ var (account, pos) = LiveAccountWithPosition(state);
+ var ladder = FloorLadder();
+ ladder.FloorNotified = true; // Threema-Notify überspringen
+ copy.ExitLadders["1_" + Tok] = ladder;
+ clob.OpenOrdersByAsset[Tok] = new() { ("oid-1", "SELL", 0.40m) };
+
+ await svc.ProcessLadderAsync("1_" + Tok, ladder);
+
+ Assert.Empty(clob.Placed); // Order ruht -> nicht neu platzieren
+ }
+
+ [Fact]
+ public async Task Ladder_completes_when_position_gone()
+ {
+ var (svc, copy, state, clob, _) = Build();
+ var (account, pos) = LiveAccountWithPosition(state);
+ var ladder = FloorLadder();
+ copy.ExitLadders["1_" + Tok] = ladder;
+ account.OpenPositions.TryRemove(Tok, out _); // Sync hat den Fill erkannt
+
+ await svc.ProcessLadderAsync("1_" + Tok, ladder);
+
+ Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Leiter beendet
+ Assert.Empty(clob.Placed);
+ }
+ }
+}