diff --git a/src/PolyTrader.Core/Models/Position.cs b/src/PolyTrader.Core/Models/Position.cs
index 7765472..5b572d0 100644
--- a/src/PolyTrader.Core/Models/Position.cs
+++ b/src/PolyTrader.Core/Models/Position.cs
@@ -23,5 +23,12 @@ namespace PolyTraderSharp.Models
public decimal CurrentValueUsd { get; set; }
public System.DateTime? ExpiryDate { get; set; }
public System.DateTime OpenedAt { get; set; } = System.DateTime.UtcNow;
+
+ ///
+ /// Runtime-only (nicht persistiert): Für diese Position läuft gerade eine SELL-Eskalations-
+ /// leiter (Phase 0.1). Verhindert Doppel-SELL und hält die Limits-Berechnung korrekt, bis der
+ /// Fill über den Sync bestätigt ist. Nach App-Neustart bewusst false (Ladder-Zustand ist transient).
+ ///
+ public bool ExitPending { get; set; }
}
}
diff --git a/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs b/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs
index 4838537..1a81797 100644
--- a/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs
+++ b/src/PolyTrader.Core/Persistence/Ef/CoreDbContext.cs
@@ -41,6 +41,7 @@ namespace PolyTrader.Core.Persistence.Ef
{
e.ToTable("core_positions");
e.HasKey(x => new { x.AccountId, x.IsDemo, x.TokenId });
+ e.Ignore(x => x.ExitPending); // Runtime-only (SELL-Eskalationsleiter)
e.Property(x => x.TokenId).HasMaxLength(120);
e.Property(x => x.MarketSlug).HasMaxLength(300);
e.Property(x => x.ConditionId).HasMaxLength(120);
diff --git a/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs b/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs
index 787433a..b3b4287 100644
--- a/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs
+++ b/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs
@@ -65,6 +65,9 @@ namespace PolyTrader.Modules.CopyTrading
// Polymarket-Markt-WSS (Auto-Redeem nach Copytrading-Settings).
services.AddHostedService();
services.AddHostedService();
+
+ // Phase 0.1: SELL-Eskalationsleiter (preist offene Exit-Limits stufenweise nach).
+ services.AddHostedService();
}
public void RegisterUi(IModuleUiHost host, IServiceProvider services)
diff --git a/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs b/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs
index 5d58467..7780306 100644
--- a/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs
+++ b/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs
@@ -55,5 +55,30 @@ namespace PolyTraderSharp
// Master Trader Position Tracker (Key: "{TraderId}_{TokenId}", Value: (Shares, LastUpdated))
// Entscheidet, ob ein SELL-Signal ein Teilverkauf (ignorieren) oder ein Voll-Exit (kopieren) ist.
public ConcurrentDictionary MasterTraderPositions { get; } = new();
+
+ // Aktive SELL-Eskalationsleitern (Phase 0.1). Key: "{AccountId}_{TokenId}".
+ // Transient (nach App-Neustart leer) – siehe Position.ExitPending.
+ public ConcurrentDictionary ExitLadders { get; } = new();
+ }
+
+ ///
+ /// Laufzeit-Zustand einer SELL-Eskalationsleiter (Phase 0.1): schrittweises Absenken des
+ /// Limit-Preises vom Master-Exit bis zum Floor, verwaltet vom SellLadderService.
+ ///
+ public sealed class ExitLadderState
+ {
+ public int AccountId { get; set; }
+ public string TokenId { get; set; } = string.Empty;
+ public int SourceTraderId { get; set; }
+ public string MarketQuestion { get; set; } = string.Empty;
+
+ public decimal ReferencePrice { get; set; } // Master-Exit-Preis
+ public decimal CurrentLimit { get; set; } // aktuell platziertes Limit
+ public decimal Floor { get; set; } // absolute Untergrenze
+ public bool IsHf { get; set; }
+
+ public int Attempt { get; set; } = 1;
+ public DateTime LastActionAt { get; set; } = DateTime.UtcNow;
+ public bool FloorNotified { get; set; }
}
}
diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs b/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs
index 855ef88..368fcaf 100644
--- a/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs
+++ b/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs
@@ -51,5 +51,14 @@ namespace PolyTrader.Modules.CopyTrading.Logic
/// Leiter erschöpft: aktueller Preis hat den Floor erreicht/unterschritten.
public static bool IsAtFloor(decimal currentPrice, decimal floor) => currentPrice <= floor;
+
+ /// Relative Schrittweite je Leiter-Stufe (%). Plan: „2 ¢ oder 3 % relativ".
+ public const decimal LadderStepPct = 3.0m;
+
+ ///
+ /// Wartezeit ohne Fill, bevor eine Stufe tiefer nachgepreist wird: HF-Trader (schnelle
+ /// Märkte) ~20 s, sonst ~120 s. Aus dem Plan.
+ ///
+ public static int LadderIntervalSeconds(bool isHf) => isHf ? 20 : 120;
}
}
diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs
index 34f8020..192c2cb 100644
--- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs
+++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs
@@ -576,6 +576,14 @@ namespace PolyTraderSharp.Services
}
}
+ // Läuft für diese Position bereits eine SELL-Eskalationsleiter (Phase 0.1)?
+ // Dann NICHT erneut verkaufen – der SellLadderService preist selbst nach.
+ if (account.OpenPositions.TryGetValue(signal.TokenId, out var laddering) && laddering.ExitPending)
+ {
+ _logger.Info($"SELL [{signal.MarketQuestion}] ignoriert: Eskalationsleiter läuft bereits (Konto {account.Name}).");
+ return;
+ }
+
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
// Defense-in-depth: Verify the removed position actually belongs to this trader
@@ -647,46 +655,65 @@ namespace PolyTraderSharp.Services
}
else
{
- decimal sellLimit = 0.01m; // Market Order Fallback Limit (PolyMarket Safety)
- decimal expectedUsdc = openPos.Size * sellLimit;
-
- var exact = PolymarketClobClient.CalculateExactOrderAmounts(expectedUsdc, sellLimit, sellLimit, "SELL", "MARKET");
+ // ===== Phase 0.1: SELL-Eskalationsleiter statt Market-Dump =====
+ // Statt eines Market-SELLs mit 0.01-Limit (April-Verlustquelle: wir wurden
+ // zur Exit-Liquidity) platzieren wir ein GTC-Limit nahe am Master-Exit.
+ // Der SellLadderService senkt es stufenweise bis zum Floor. Die Position wird
+ // NICHT optimistisch entfernt, sondern als ExitPending zurückgestellt; der Sync
+ // schließt sie nach bestätigtem Fill.
+ bool isHf = trader != null && trader.Category == "HF";
+ decimal firstLimit = SellLogic.FirstLimit(signal.Price, isHf, settings.MaxPriceDifference);
+ decimal floor = SellLogic.Floor(signal.Price, settings.SellFloorPct);
+ firstLimit = Math.Clamp(firstLimit, 0.01m, 0.99m);
+ floor = Math.Clamp(floor, 0.01m, 0.99m);
+ if (floor > firstLimit) floor = firstLimit; // Floor nie über dem Startlimit
+ var exact = PolymarketClobClient.CalculateExactOrderAmounts(openPos.Size * firstLimit, firstLimit, firstLimit, "SELL");
if (exact.shares <= 0)
{
- _logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
- $" Konto: {account.Name}\n" +
- $" Grund: Mathematical Order Size Error (Dust Token).");
- // We don't return to OpenPositions to let dust drop gracefully
+ _logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] übersprungen (Dust): mathematisch keine Order möglich. Position wird gehalten.");
+ openPos.ExitPending = false;
+ account.OpenPositions.TryAdd(signal.TokenId, openPos);
return;
}
- _logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET SELL an Polymarket CTF-Router...\n" +
- $" Account: {account.Name}\n" +
- $" Order: MARKET (Target: {signal.Price:F3})");
+ // Position als ExitPending zurückstellen (kein Doppel-SELL; Limits rechnen korrekt weiter).
+ openPos.ExitPending = true;
+ account.OpenPositions.TryAdd(signal.TokenId, openPos);
+ _positionRepo.UpsertLive(account.AccountId, openPos);
- var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, expectedUsdc, sellLimit, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
+ _logger.Trade($"🪜 [LIVE SELL-LEITER Start]\n" +
+ $" Konto: {account.Name}\n" +
+ $" Markt: {signal.MarketQuestion}\n" +
+ $" Referenz: {signal.Price:F3} (Master-Exit) | Startlimit: {firstLimit:F3} | Floor: {floor:F3}\n" +
+ $" Stufen: {(isHf ? "HF ~20s" : "~120s")}/Schritt, {SellLogic.LadderStepPct}% relativ");
+
+ var result = await _clob.PlaceOrderAsync(account, signal.TokenId, "SELL", openPos.Size * firstLimit, firstLimit, "GTC", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
- // Track order placement time for stale order cleanup / sync routines
+ _copyState.ExitLadders[orderKey] = new ExitLadderState
+ {
+ AccountId = account.AccountId,
+ TokenId = signal.TokenId,
+ SourceTraderId = signal.TraderId,
+ MarketQuestion = signal.MarketQuestion,
+ ReferencePrice = signal.Price,
+ CurrentLimit = firstLimit,
+ Floor = floor,
+ IsHf = isHf,
+ Attempt = 1,
+ LastActionAt = DateTime.UtcNow
+ };
_copyState.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId);
-
- _logger.Trade($"✅ [LIVE MARKET SELL PLATZIERT] - {account.Name} - MARKET Swept. Gewinne/Verluste in Kürze im API Sync sichtbar.");
+ _logger.Trade($"✅ [LIVE SELL-LEITER platziert] {account.Name} | GTC-Limit {firstLimit:F3} für {openPos.Size:F2} Shares.");
}
else
{
- // Call failed, log it so the user knows Sells are being attempted but failing.
- _logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
- $" Konto: {account.Name}\n" +
- $" Grund: {result}\n" +
- $" Aktion: Position bleibt vorerst im Portfolio erhalten.");
-
- // Temporären Cooldown (5 Sek) setzen, um Log-Spam durch wiederholte API-Fehler zu vermeiden
+ // Startorder fehlgeschlagen: Position bleibt (ExitPending zurücksetzen), Cooldown.
+ openPos.ExitPending = false;
_copyState.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow.AddSeconds(-15), signal.TraderId);
-
- // Reverse the TryRemove if it failed, so the next poll can try again
- account.OpenPositions.TryAdd(signal.TokenId, openPos);
+ _logger.TradeReasoning($"❌ [LIVE SELL-LEITER] Startorder fehlgeschlagen: {result}. Position bleibt im Portfolio; neuer Versuch beim nächsten Signal/Sync.");
}
}
}
diff --git a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs
new file mode 100644
index 0000000..8eff2c6
--- /dev/null
+++ b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using PolyTrader.Modules.CopyTrading.Logic;
+using PolyTraderSharp.Models;
+
+namespace PolyTraderSharp.Services
+{
+ ///
+ /// Phase 0.1 – SELL-Eskalationsleiter. Statt SELLs als Market-Order ins (oft leergeräumte)
+ /// Orderbuch zu werfen (April-Verlustquelle), platziert die Engine ein GTC-Limit nahe am
+ /// Master-Exit und markiert die Position . Dieser Service
+ /// senkt das Limit stufenweise (relative Schrittweite) bis zum Floor, wenn kein Fill kommt:
+ /// canceln → eine Stufe tiefer neu platzieren. Am Floor ohne Fill: Position halten +
+ /// Threema-Benachrichtigung. Fills erkennt der bestehende Sync (TraderMonitorService), der die
+ /// Position entfernt → die Leiter endet dann von selbst.
+ ///
+ /// Bewusst separat vom CLOB-Order-Code gehalten; die Preislogik ist in
+ /// pur und unit-getestet. Umfangreiches Logging, da (noch) kein Live-Test möglich ist.
+ ///
+ public class SellLadderService : BackgroundService
+ {
+ private static readonly TimeSpan Tick = TimeSpan.FromSeconds(5);
+
+ private readonly CopyTradingState _copyState;
+ private readonly TradingState _state;
+ private readonly PolymarketClobClient _clob;
+ private readonly TerminalLogger _logger;
+ private readonly ThreemaService _threema;
+
+ public SellLadderService(
+ CopyTradingState copyState,
+ TradingState state,
+ PolymarketClobClient clob,
+ TerminalLogger logger,
+ ThreemaService threema)
+ {
+ _copyState = copyState;
+ _state = state;
+ _clob = clob;
+ _logger = logger;
+ _threema = threema;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.Info("SELL-Eskalationsleiter-Service gestartet.");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(Tick, stoppingToken);
+ foreach (var kvp in _copyState.ExitLadders.ToArray())
+ {
+ await ProcessLadderAsync(kvp.Key, kvp.Value);
+ }
+ }
+ catch (OperationCanceledException) { break; }
+ catch (Exception ex)
+ {
+ _logger.Error($"SellLadderService Fehler: {ex.Message}");
+ }
+ }
+ }
+
+ private async Task ProcessLadderAsync(string key, ExitLadderState ladder)
+ {
+ // Account weg? -> Leiter verwerfen.
+ if (!_state.Accounts.TryGetValue(ladder.AccountId, out var account))
+ {
+ _copyState.ExitLadders.TryRemove(key, out _);
+ return;
+ }
+
+ // Position weg -> Sync hat den Fill erkannt und geschlossen. Leiter fertig.
+ if (!account.OpenPositions.TryGetValue(ladder.TokenId, out var pos))
+ {
+ _copyState.ExitLadders.TryRemove(key, out _);
+ _logger.Trade($"✅ [SELL-LEITER fertig] {account.Name} | {ladder.MarketQuestion} | Position gefüllt/geschlossen (Sync).");
+ return;
+ }
+
+ // Noch nicht Zeit für die nächste Stufe?
+ 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).
+ if (SellLogic.IsAtFloor(ladder.CurrentLimit, ladder.Floor))
+ {
+ if (!ladder.FloorNotified)
+ {
+ ladder.FloorNotified = true;
+ string msg = $"⚠️ SELL-Floor ohne Fill erreicht – Position wird gehalten.\n" +
+ $"Konto: {account.Name}\nMarkt: {ladder.MarketQuestion}\n" +
+ $"Floor: {ladder.Floor:F3} (Master-Exit war {ladder.ReferencePrice:F3})";
+ _logger.Warning($"🛑 [SELL-LEITER Floor] {account.Name} | {ladder.MarketQuestion} | Floor {ladder.Floor:F3} ohne Fill – halte Position.");
+ try { await _threema.SendMessageAsync(msg); } catch (Exception ex) { _logger.Error($"Threema-Benachrichtigung fehlgeschlagen: {ex.Message}"); }
+ }
+ ladder.LastActionAt = DateTime.UtcNow; // Re-Notify-Spam vermeiden
+ return;
+ }
+
+ // Eine Stufe tiefer: alte Order canceln, tiefer neu platzieren.
+ decimal next = SellLogic.NextPrice(ladder.CurrentLimit, SellLogic.LadderStepPct, ladder.Floor);
+ bool isNegRisk = _state.MarketCache.TryGetValue(ladder.TokenId, out var md) && md.NegRisk;
+
+ _logger.Trade($"🪜 [SELL-LEITER Stufe {ladder.Attempt + 1}] {account.Name} | {ladder.MarketQuestion}\n" +
+ $" Limit {ladder.CurrentLimit:F3} → {next:F3} (Floor {ladder.Floor:F3}, {(ladder.IsHf ? "HF" : "Standard")})");
+
+ try
+ {
+ await _clob.CancelConflictingOrdersAsync(account, ladder.TokenId, next, "SELL");
+
+ decimal usdc = pos.Size * next;
+ var result = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", usdc, next, "GTC", _state.DebugOrderPayloadLog, isNegRisk);
+
+ if (result == "OK")
+ {
+ ladder.CurrentLimit = next;
+ ladder.Attempt++;
+ ladder.LastActionAt = DateTime.UtcNow;
+ _copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, ladder.SourceTraderId);
+ _logger.Trade($"✅ [SELL-LEITER Stufe {ladder.Attempt}] {account.Name} | neues GTC-Limit {next:F3} für {pos.Size:F2} Shares.");
+ }
+ else
+ {
+ ladder.LastActionAt = DateTime.UtcNow; // beim nächsten Tick erneut versuchen
+ _logger.Warning($"⚠️ [SELL-LEITER] Nachpreisen fehlgeschlagen ({result}) – nächster Versuch in {SellLogic.LadderIntervalSeconds(ladder.IsHf)}s.");
+ }
+ }
+ catch (Exception ex)
+ {
+ ladder.LastActionAt = DateTime.UtcNow;
+ _logger.Error($"[SELL-LEITER] Fehler beim Nachpreisen: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/tests/PolyTrader.Tests/SellLogicTests.cs b/tests/PolyTrader.Tests/SellLogicTests.cs
index e827e68..60ad9a0 100644
--- a/tests/PolyTrader.Tests/SellLogicTests.cs
+++ b/tests/PolyTrader.Tests/SellLogicTests.cs
@@ -85,5 +85,33 @@ namespace PolyTrader.Tests
Assert.True(IsAtFloor(0.79m, 0.80m));
Assert.False(IsAtFloor(0.81m, 0.80m));
}
+
+ [Theory]
+ [InlineData(true, 20)] // HF-Trader: schnelles Nachpreisen
+ [InlineData(false, 120)] // Standard
+ public void LadderIntervalSeconds_hf_is_faster(bool isHf, int expected)
+ {
+ Assert.Equal(expected, LadderIntervalSeconds(isHf));
+ }
+
+ [Fact]
+ public void Ladder_walks_down_in_steps_until_floor()
+ {
+ // Simuliert den Service: von Startlimit in 3%-Schritten bis zum Floor, dann Stopp.
+ decimal reference = 0.99m;
+ decimal price = FirstLimit(reference, isHf: false, maxPriceDifferencePct: 2m); // 0.9702
+ decimal floor = Floor(reference, 15m); // 0.8415
+
+ int steps = 0;
+ while (!IsAtFloor(price, floor) && steps < 100)
+ {
+ price = NextPrice(price, LadderStepPct, floor);
+ steps++;
+ }
+
+ Assert.True(IsAtFloor(price, floor));
+ Assert.Equal(floor, price); // endet exakt auf dem Floor (geclamped)
+ Assert.InRange(steps, 1, 20); // terminiert in wenigen Schritten
+ }
}
}