From e22a6e309185f8507aa34a639cddd100c3c85166 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 7 Jul 2026 17:40:40 +0200 Subject: [PATCH] Phase 0.1: SELL-Eskalationsleiter statt Market-Dump (CLOB-kritisch) Behebt die April-Verlustquelle: SELLs wurden als Market-Order mit 0.01-Limit ins oft leergeraeumte Orderbuch geworfen -> wir wurden zur Exit-Liquidity. Jetzt: GTC-Limit nahe am Master-Exit, stufenweises Nachpreisen bis zum Floor. - Position.ExitPending (runtime-only, EF-ignoriert): Position wird bei SELL NICHT mehr optimistisch entfernt, sondern als ExitPending zurueckgestellt (kein Doppel-SELL, Limits rechnen korrekt; Sync schliesst nach bestaetigtem Fill). - CopyTradingState.ExitLadders + ExitLadderState (transienter Leiter-Zustand). - SellLogic (pure, getestet): FirstLimit (HF-fest/prozentual), Floor (SellFloorPct), NextPrice (relative Stufe, auf Floor geclamped), IsAtFloor, LadderStepPct (3%), LadderIntervalSeconds (HF 20s / sonst 120s). - SellLadderService (BackgroundService): senkt offene Exit-Limits stufenweise (cancel via CancelConflictingOrdersAsync -> tiefer neu platzieren), am Floor ohne Fill Position halten + Threema-Benachrichtigung. Fills erkennt der bestehende Sync. - CopyTradingEngine SELL-Live-Pfad ruft die Leiter; Demo-Pfad unveraendert. Doppel-SELL-Guard ueber ExitPending. Umfangreiches Logging (kein Live-Test moeglich). 163 Tests gruen. Build/Smoke gruen. Backup-Rollback: Commit 1dffc9e. Co-Authored-By: Claude Opus 4.8 --- src/PolyTrader.Core/Models/Position.cs | 7 + .../Persistence/Ef/CoreDbContext.cs | 1 + .../CopyTradingModule.cs | 3 + .../CopyTradingState.cs | 25 ++++ .../Logic/SellLogic.cs | 9 ++ .../Services/CopyTradingEngine.cs | 77 ++++++---- .../Services/SellLadderService.cs | 141 ++++++++++++++++++ tests/PolyTrader.Tests/SellLogicTests.cs | 28 ++++ 8 files changed, 266 insertions(+), 25 deletions(-) create mode 100644 src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs 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 + } } }