diff --git a/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs b/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs index b3b4287..ea449f3 100644 --- a/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs +++ b/src/PolyTrader.Modules.CopyTrading/CopyTradingModule.cs @@ -67,7 +67,9 @@ namespace PolyTrader.Modules.CopyTrading services.AddHostedService(); // Phase 0.1: SELL-Eskalationsleiter (preist offene Exit-Limits stufenweise nach). - services.AddHostedService(); + // Singleton + Hosted, damit Engine und TraderMonitor StartLadderAsync aufrufen können. + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); } public void RegisterUi(IModuleUiHost host, IServiceProvider services) diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs b/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs index 368fcaf..d2b5300 100644 --- a/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs +++ b/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs @@ -60,5 +60,18 @@ namespace PolyTrader.Modules.CopyTrading.Logic /// Märkte) ~20 s, sonst ~120 s. Aus dem Plan. /// public static int LadderIntervalSeconds(bool isHf) => isHf ? 20 : 120; + + // ----- Take-Profit (Phase 0.3) ----- + + /// + /// Take-Profit-Schwelle erreicht? currentPrice ≥ entryPrice × (1 + profitTargetPct/100). + /// ≤ 0 (oder ungültiger Entry) = deaktiviert; hohe Werte + /// (Default 9999) werden faktisch nie erreicht = inaktiv. + /// + 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); + } } } diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs index 08e9544..4ba26be 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs @@ -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 _accountSemaphores = new(); private readonly ConcurrentDictionary _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 diff --git a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs index 96715df..86e2053 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs @@ -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; + } + + /// + /// Startet eine SELL-Eskalationsleiter für eine Live-Position (Phase 0.1). Platziert das erste + /// GTC-Limit nahe am Referenzpreis, stellt die Position auf + /// (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). + /// + public async Task 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) diff --git a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs index f3c4172..d811e1f 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs @@ -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 _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 } } + /// + /// 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.) + /// + 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(); diff --git a/tests/PolyTrader.Tests/SellLogicTests.cs b/tests/PolyTrader.Tests/SellLogicTests.cs index 60ad9a0..331010f 100644 --- a/tests/PolyTrader.Tests/SellLogicTests.cs +++ b/tests/PolyTrader.Tests/SellLogicTests.cs @@ -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() {