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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-07 17:40:40 +02:00
co-authored by Claude Opus 4.8
parent 1dffc9e253
commit e22a6e3091
8 changed files with 266 additions and 25 deletions
@@ -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.");
}
}
}