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:
co-authored by
Claude Opus 4.8
parent
b378bc3499
commit
395caad11a
@@ -67,7 +67,9 @@ namespace PolyTrader.Modules.CopyTrading
|
||||
services.AddHostedService<PolymarketWssClient>();
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -60,5 +60,18 @@ namespace PolyTrader.Modules.CopyTrading.Logic
|
||||
/// Märkte) ~20 s, sonst ~120 s. Aus dem Plan.
|
||||
/// </summary>
|
||||
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 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
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PolyTrader.Core.Persistence;
|
||||
using PolyTrader.Modules.CopyTrading.Logic;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
@@ -29,19 +30,87 @@ namespace PolyTraderSharp.Services
|
||||
private readonly PolymarketClobClient _clob;
|
||||
private readonly TerminalLogger _logger;
|
||||
private readonly ThreemaService _threema;
|
||||
private readonly IPositionRepository _positionRepo;
|
||||
|
||||
public SellLadderService(
|
||||
CopyTradingState copyState,
|
||||
TradingState state,
|
||||
PolymarketClobClient clob,
|
||||
TerminalLogger logger,
|
||||
ThreemaService threema)
|
||||
ThreemaService threema,
|
||||
IPositionRepository positionRepo)
|
||||
{
|
||||
_copyState = copyState;
|
||||
_state = state;
|
||||
_clob = clob;
|
||||
_logger = logger;
|
||||
_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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using PolyTrader.Core.Persistence;
|
||||
using PolyTrader.Modules.CopyTrading.Logic;
|
||||
using PolyTrader.Modules.CopyTrading.Persistence;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
@@ -24,6 +25,7 @@ namespace PolyTraderSharp.Services
|
||||
private readonly ICopyTradeLogRepository _tradeLog;
|
||||
private readonly IPositionRepository _positionRepo;
|
||||
private readonly IMarketRepository _marketRepo;
|
||||
private readonly SellLadderService _sellLadder;
|
||||
|
||||
// Prevents duplicates. Fast O(1) lookup cache to prevent DB spam.
|
||||
private readonly ConcurrentDictionary<string, DateTime> _processedTxHashes = new();
|
||||
@@ -48,7 +50,8 @@ namespace PolyTraderSharp.Services
|
||||
TerminalLogger logger,
|
||||
IPositionRepository positionRepo,
|
||||
IMarketRepository marketRepo,
|
||||
ICopyTradeLogRepository tradeLog)
|
||||
ICopyTradeLogRepository tradeLog,
|
||||
SellLadderService sellLadder)
|
||||
{
|
||||
_state = state;
|
||||
_copyState = copyState;
|
||||
@@ -60,6 +63,7 @@ namespace PolyTraderSharp.Services
|
||||
_positionRepo = positionRepo;
|
||||
_marketRepo = marketRepo;
|
||||
_tradeLog = tradeLog;
|
||||
_sellLadder = sellLadder;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -119,6 +123,7 @@ namespace PolyTraderSharp.Services
|
||||
await PollLiveAccountsAsync(stoppingToken);
|
||||
await PollDemoExpirationsAsync(stoppingToken);
|
||||
await CleanupStaleOpenOrdersAsync(stoppingToken);
|
||||
await CheckProfitTargetsAsync(); // Phase 0.3
|
||||
_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)
|
||||
{
|
||||
var keysToProcess = _copyState.PendingOrderTimestamps.ToArray();
|
||||
|
||||
@@ -94,6 +94,23 @@ namespace PolyTrader.Tests
|
||||
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]
|
||||
public void Ladder_walks_down_in_steps_until_floor()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user