Phase 0.3: ProfitTarget-Take-Profit implementiert (dormant bei 9999)

- SellLogic.IsProfitTargetReached (pure, getestet): currentPrice >= entry*(1+pct/100);
  pct<=0 oder Default 9999 = inaktiv.
- Ladder-Start-Logik konsolidiert: SellLadderService.StartLadderAsync ist jetzt die
  gemeinsame Quelle fuer Master-SELLs (Engine) UND eigene Exits (Profit-Target).
  SellLadderService als Singleton+Hosted registriert; Engine + TraderMonitor
  injizieren es. Engine-SELL-Block ruft nur noch StartLadderAsync (verhaltensgleich).
- TraderMonitorService.CheckProfitTargetsAsync im 30s-Live-Sync: erreicht eine
  Live-Position ihre Schwelle, Exit ueber die Leiter (Startlimit = aktueller Preis,
  ExitReason "Profit Target"). PreRedeemLimit hat Vorrang. Dormant, da ProfitTarget
  projektweit 9999.

169 Tests gruen. Build/Smoke gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-07 17:54:21 +02:00
co-authored by Claude Opus 4.8
parent b378bc3499
commit 395caad11a
6 changed files with 153 additions and 63 deletions
@@ -24,6 +24,7 @@ namespace PolyTraderSharp.Services
private readonly IPositionRepository _positionRepo;
private readonly IMarketRepository _marketRepo;
private readonly IAccountRepository _accountRepo;
private readonly SellLadderService _sellLadder;
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
private readonly ConcurrentDictionary<int, DateTime> _lastInactiveLogPerTrader = new();
@@ -37,7 +38,8 @@ namespace PolyTraderSharp.Services
PolymarketApiService api,
IPositionRepository positionRepo,
IMarketRepository marketRepo,
IAccountRepository accountRepo)
IAccountRepository accountRepo,
SellLadderService sellLadder)
{
_state = state;
_copyState = copyState;
@@ -49,6 +51,7 @@ namespace PolyTraderSharp.Services
_positionRepo = positionRepo;
_marketRepo = marketRepo;
_accountRepo = accountRepo;
_sellLadder = sellLadder;
}
public override async Task StartAsync(CancellationToken cancellationToken)
@@ -658,66 +661,13 @@ namespace PolyTraderSharp.Services
}
else
{
// ===== 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.
// Phase 0.1: SELL-Eskalationsleiter statt Market-Dump (Logik zentral in
// SellLadderService gleiche Quelle wie der Profit-Target-Exit im Sync).
// openPos wurde oben entfernt; StartLadderAsync stellt es als ExitPending zurück.
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}] übersprungen (Dust): mathematisch keine Order möglich. Position wird gehalten.");
openPos.ExitPending = false;
account.OpenPositions.TryAdd(signal.TokenId, openPos);
return;
}
// 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);
_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")
{
_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, "SELL");
_logger.Trade($"✅ [LIVE SELL-LEITER platziert] {account.Name} | GTC-Limit {firstLimit:F3} für {openPos.Size:F2} Shares.");
}
else
{
// Startorder fehlgeschlagen: Position bleibt (ExitPending zurücksetzen), Cooldown.
openPos.ExitPending = false;
_copyState.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow.AddSeconds(-15), signal.TraderId, "SELL");
_logger.TradeReasoning($"❌ [LIVE SELL-LEITER] Startorder fehlgeschlagen: {result}. Position bleibt im Portfolio; neuer Versuch beim nächsten Signal/Sync.");
}
await _sellLadder.StartLadderAsync(
account, openPos, signal.Price, signal.TraderId, isHf,
settings.MaxPriceDifference, settings.SellFloorPct, isNegRisk, "Master SELL");
}
}
else