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
@@ -67,7 +67,9 @@ namespace PolyTrader.Modules.CopyTrading
services.AddHostedService<PolymarketWssClient>(); services.AddHostedService<PolymarketWssClient>();
// Phase 0.1: SELL-Eskalationsleiter (preist offene Exit-Limits stufenweise nach). // Phase 0.1: SELL-Eskalationsleiter (preist offene Exit-Limits stufenweise nach).
services.AddHostedService<SellLadderService>(); // Singleton + Hosted, damit Engine und TraderMonitor StartLadderAsync aufrufen können.
services.AddSingleton<SellLadderService>();
services.AddHostedService(sp => sp.GetRequiredService<SellLadderService>());
} }
public void RegisterUi(IModuleUiHost host, IServiceProvider services) public void RegisterUi(IModuleUiHost host, IServiceProvider services)
@@ -60,5 +60,18 @@ namespace PolyTrader.Modules.CopyTrading.Logic
/// Märkte) ~20 s, sonst ~120 s. Aus dem Plan. /// Märkte) ~20 s, sonst ~120 s. Aus dem Plan.
/// </summary> /// </summary>
public static int LadderIntervalSeconds(bool isHf) => isHf ? 20 : 120; public static int LadderIntervalSeconds(bool isHf) => isHf ? 20 : 120;
// ----- Take-Profit (Phase 0.3) -----
/// <summary>
/// Take-Profit-Schwelle erreicht? <c>currentPrice ≥ entryPrice × (1 + profitTargetPct/100)</c>.
/// <paramref name="profitTargetPct"/> ≤ 0 (oder ungültiger Entry) = deaktiviert; hohe Werte
/// (Default 9999) werden faktisch nie erreicht = inaktiv.
/// </summary>
public static bool IsProfitTargetReached(decimal currentPrice, decimal entryPrice, decimal profitTargetPct)
{
if (profitTargetPct <= 0m || entryPrice <= 0m) return false;
return currentPrice >= entryPrice * (1.0m + profitTargetPct / 100.0m);
}
} }
} }
@@ -24,6 +24,7 @@ namespace PolyTraderSharp.Services
private readonly IPositionRepository _positionRepo; private readonly IPositionRepository _positionRepo;
private readonly IMarketRepository _marketRepo; private readonly IMarketRepository _marketRepo;
private readonly IAccountRepository _accountRepo; private readonly IAccountRepository _accountRepo;
private readonly SellLadderService _sellLadder;
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new(); private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
private readonly ConcurrentDictionary<int, DateTime> _lastInactiveLogPerTrader = new(); private readonly ConcurrentDictionary<int, DateTime> _lastInactiveLogPerTrader = new();
@@ -37,7 +38,8 @@ namespace PolyTraderSharp.Services
PolymarketApiService api, PolymarketApiService api,
IPositionRepository positionRepo, IPositionRepository positionRepo,
IMarketRepository marketRepo, IMarketRepository marketRepo,
IAccountRepository accountRepo) IAccountRepository accountRepo,
SellLadderService sellLadder)
{ {
_state = state; _state = state;
_copyState = copyState; _copyState = copyState;
@@ -49,6 +51,7 @@ namespace PolyTraderSharp.Services
_positionRepo = positionRepo; _positionRepo = positionRepo;
_marketRepo = marketRepo; _marketRepo = marketRepo;
_accountRepo = accountRepo; _accountRepo = accountRepo;
_sellLadder = sellLadder;
} }
public override async Task StartAsync(CancellationToken cancellationToken) public override async Task StartAsync(CancellationToken cancellationToken)
@@ -658,66 +661,13 @@ namespace PolyTraderSharp.Services
} }
else else
{ {
// ===== Phase 0.1: SELL-Eskalationsleiter statt Market-Dump ===== // Phase 0.1: SELL-Eskalationsleiter statt Market-Dump (Logik zentral in
// Statt eines Market-SELLs mit 0.01-Limit (April-Verlustquelle: wir wurden // SellLadderService gleiche Quelle wie der Profit-Target-Exit im Sync).
// zur Exit-Liquidity) platzieren wir ein GTC-Limit nahe am Master-Exit. // openPos wurde oben entfernt; StartLadderAsync stellt es als ExitPending zurück.
// 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"; bool isHf = trader != null && trader.Category == "HF";
decimal firstLimit = SellLogic.FirstLimit(signal.Price, isHf, settings.MaxPriceDifference); await _sellLadder.StartLadderAsync(
decimal floor = SellLogic.Floor(signal.Price, settings.SellFloorPct); account, openPos, signal.Price, signal.TraderId, isHf,
firstLimit = Math.Clamp(firstLimit, 0.01m, 0.99m); settings.MaxPriceDifference, settings.SellFloorPct, isNegRisk, "Master SELL");
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.");
}
} }
} }
else else
@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using PolyTrader.Core.Persistence;
using PolyTrader.Modules.CopyTrading.Logic; using PolyTrader.Modules.CopyTrading.Logic;
using PolyTraderSharp.Models; using PolyTraderSharp.Models;
@@ -29,19 +30,87 @@ namespace PolyTraderSharp.Services
private readonly PolymarketClobClient _clob; private readonly PolymarketClobClient _clob;
private readonly TerminalLogger _logger; private readonly TerminalLogger _logger;
private readonly ThreemaService _threema; private readonly ThreemaService _threema;
private readonly IPositionRepository _positionRepo;
public SellLadderService( public SellLadderService(
CopyTradingState copyState, CopyTradingState copyState,
TradingState state, TradingState state,
PolymarketClobClient clob, PolymarketClobClient clob,
TerminalLogger logger, TerminalLogger logger,
ThreemaService threema) ThreemaService threema,
IPositionRepository positionRepo)
{ {
_copyState = copyState; _copyState = copyState;
_state = state; _state = state;
_clob = clob; _clob = clob;
_logger = logger; _logger = logger;
_threema = threema; _threema = threema;
_positionRepo = positionRepo;
}
/// <summary>
/// Startet eine SELL-Eskalationsleiter für eine Live-Position (Phase 0.1). Platziert das erste
/// GTC-Limit nahe am Referenzpreis, stellt die Position auf <see cref="Position.ExitPending"/>
/// (kein Doppel-SELL, Limits rechnen weiter) und registriert die Leiter; der Loop preist nach.
/// Gemeinsame Quelle für Master-SELLs (Engine) UND eigene Exits wie Profit-Target (Sync).
/// </summary>
public async Task<bool> StartLadderAsync(
AccountState account, Position pos, decimal referencePrice, int sourceTraderId,
bool isHf, decimal maxPriceDifferencePct, decimal sellFloorPct, bool isNegRisk, string reasonTag)
{
decimal firstLimit = SellLogic.FirstLimit(referencePrice, isHf, maxPriceDifferencePct);
decimal floor = SellLogic.Floor(referencePrice, 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(pos.Size * firstLimit, firstLimit, firstLimit, "SELL");
if (exact.shares <= 0)
{
_logger.TradeReasoning($"❌ [SELL-LEITER {reasonTag}] {account.Name} | {pos.MarketQuestion}: mathematisch keine Order möglich (Dust). Position wird gehalten.");
pos.ExitPending = false;
account.OpenPositions[pos.TokenId] = pos;
return false;
}
// Position tracken + ExitPending (idempotent für Engine-Fall [vorher entfernt] und Sync-Fall).
pos.ExitPending = true;
account.OpenPositions[pos.TokenId] = pos;
if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, pos);
_logger.Trade($"🪜 [SELL-LEITER Start · {reasonTag}]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {pos.MarketQuestion}\n" +
$" Referenz: {referencePrice:F3} | Startlimit: {firstLimit:F3} | Floor: {floor:F3}\n" +
$" 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;
}
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.");
return false;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -1,5 +1,6 @@
using System; using System;
using PolyTrader.Core.Persistence; using PolyTrader.Core.Persistence;
using PolyTrader.Modules.CopyTrading.Logic;
using PolyTrader.Modules.CopyTrading.Persistence; using PolyTrader.Modules.CopyTrading.Persistence;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Linq; using System.Linq;
@@ -24,6 +25,7 @@ namespace PolyTraderSharp.Services
private readonly ICopyTradeLogRepository _tradeLog; private readonly ICopyTradeLogRepository _tradeLog;
private readonly IPositionRepository _positionRepo; private readonly IPositionRepository _positionRepo;
private readonly IMarketRepository _marketRepo; private readonly IMarketRepository _marketRepo;
private readonly SellLadderService _sellLadder;
// Prevents duplicates. Fast O(1) lookup cache to prevent DB spam. // Prevents duplicates. Fast O(1) lookup cache to prevent DB spam.
private readonly ConcurrentDictionary<string, DateTime> _processedTxHashes = new(); private readonly ConcurrentDictionary<string, DateTime> _processedTxHashes = new();
@@ -48,7 +50,8 @@ namespace PolyTraderSharp.Services
TerminalLogger logger, TerminalLogger logger,
IPositionRepository positionRepo, IPositionRepository positionRepo,
IMarketRepository marketRepo, IMarketRepository marketRepo,
ICopyTradeLogRepository tradeLog) ICopyTradeLogRepository tradeLog,
SellLadderService sellLadder)
{ {
_state = state; _state = state;
_copyState = copyState; _copyState = copyState;
@@ -60,6 +63,7 @@ namespace PolyTraderSharp.Services
_positionRepo = positionRepo; _positionRepo = positionRepo;
_marketRepo = marketRepo; _marketRepo = marketRepo;
_tradeLog = tradeLog; _tradeLog = tradeLog;
_sellLadder = sellLadder;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -119,6 +123,7 @@ namespace PolyTraderSharp.Services
await PollLiveAccountsAsync(stoppingToken); await PollLiveAccountsAsync(stoppingToken);
await PollDemoExpirationsAsync(stoppingToken); await PollDemoExpirationsAsync(stoppingToken);
await CleanupStaleOpenOrdersAsync(stoppingToken); await CleanupStaleOpenOrdersAsync(stoppingToken);
await CheckProfitTargetsAsync(); // Phase 0.3
_lastLivePoll = DateTime.UtcNow; _lastLivePoll = DateTime.UtcNow;
} }
@@ -1093,6 +1098,40 @@ namespace PolyTraderSharp.Services
} }
} }
/// <summary>
/// Phase 0.3: Take-Profit. Erreicht eine Live-Position ihre ProfitTarget-Schwelle, wird sie
/// über die SELL-Eskalationsleiter (Startlimit = aktueller Preis) verkauft. PreRedeemLimit hat
/// Vorrang (näher an 1.00): würde PreRedeem greifen, überlassen wir den Exit dem Auto-Redeem.
/// (ProfitTarget steht projektweit auf 9999 = inaktiv, bis Richard es bewusst scharf schaltet.)
/// </summary>
private async Task CheckProfitTargetsAsync()
{
if (_state.GlobalTradingPaused || _state.LiveTradingMode == TradingMode.Inactive) return;
foreach (var acc in _state.Accounts.Values)
{
if (acc.IsDemo || !acc.IsActive) continue;
var s = _copyState.GetAccountSettings(acc.AccountId);
foreach (var pos in acc.OpenPositions.Values.ToArray())
{
if (pos.ExitPending || pos.CurrentPrice <= 0m) continue;
if (!SellLogic.IsProfitTargetReached(pos.CurrentPrice, pos.EntryPrice, s.ProfitTarget)) continue;
// PreRedeem hat Vorrang (näher an 1.00): Exit dem Auto-Redeem überlassen.
if (s.PreRedeemLimit > 0m && pos.CurrentPrice >= s.PreRedeemLimit) continue;
var trader = _copyState.Traders.TryGetValue(pos.SourceTraderId, out var t) ? t : null;
bool isHf = trader?.Category == "HF";
bool isNegRisk = _state.MarketCache.TryGetValue(pos.TokenId, out var md) && md.NegRisk;
_logger.Trade($"🎯 [PROFIT TARGET] {acc.Name} | {pos.MarketQuestion}\n" +
$" Aktuell {pos.CurrentPrice:F3} ≥ Entry {pos.EntryPrice:F3} × (1+{s.ProfitTarget:F0}%). Starte Exit-Leiter.");
await _sellLadder.StartLadderAsync(acc, pos, pos.CurrentPrice, pos.SourceTraderId, isHf, s.MaxPriceDifference, s.SellFloorPct, isNegRisk, "Profit Target");
}
}
}
private async Task CleanupStaleOpenOrdersAsync(CancellationToken ct) private async Task CleanupStaleOpenOrdersAsync(CancellationToken ct)
{ {
var keysToProcess = _copyState.PendingOrderTimestamps.ToArray(); var keysToProcess = _copyState.PendingOrderTimestamps.ToArray();
+17
View File
@@ -94,6 +94,23 @@ namespace PolyTrader.Tests
Assert.Equal(expected, LadderIntervalSeconds(isHf)); Assert.Equal(expected, LadderIntervalSeconds(isHf));
} }
[Theory]
[InlineData(0.50, 0.55, 10, true)] // genau an der Schwelle (entry*1.10)
[InlineData(0.50, 0.60, 10, true)] // darüber
[InlineData(0.50, 0.54, 10, false)] // darunter
[InlineData(0.50, 0.99, 9999, false)] // Default 9999 -> nie erreicht (inaktiv)
[InlineData(0.50, 0.99, 0, false)] // 0 -> deaktiviert
public void IsProfitTargetReached_threshold(double entry, double current, double pct, bool expected)
{
Assert.Equal(expected, IsProfitTargetReached((decimal)current, (decimal)entry, (decimal)pct));
}
[Fact]
public void IsProfitTargetReached_zero_entry_is_false()
{
Assert.False(IsProfitTargetReached(0.9m, 0m, 10m));
}
[Fact] [Fact]
public void Ladder_walks_down_in_steps_until_floor() public void Ladder_walks_down_in_steps_until_floor()
{ {