using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Alpaca.Markets; using DesktopBot.Models; using DesktopBot.Services; namespace DesktopBot.Engine { // ═══════════════════════════════════════════════════════════════════════════ // POSITION RISK MANAGER // Valuta la situazione del portafoglio live PRIMA di aprire nuove posizioni // e gestisce le uscite in profitto / in perdita in modo proattivo. // // Regole applicate: // 1. MaxOpenPositions → blocca nuove aperture se già raggiunte // 2. MaxCapitalAllocated → blocca se troppo capitale è già impegnato // 3. ProfitLockPercent → chiude posizioni con PnL% >= soglia (lock profit) // 4. MaxLossPercent → chiude posizioni con PnL% <= -soglia (stop loss dinamico) // 5. UseBreakEvenStop → quando PnL% >= ProfitLockPercent/2, annota il // break-even e lo riporta al chiamante per aggiornare // l'ordine stop (o come informazione di log) // ═══════════════════════════════════════════════════════════════════════════ internal sealed class PositionRiskManager { private readonly ITradingService _svc; private readonly BotConfiguration _cfg; private readonly Action _logInfo; private readonly Action _logWarn; public PositionRiskManager( ITradingService service, BotConfiguration config, Action logInfo, Action logWarn) { _svc = service; _cfg = config; _logInfo = logInfo; _logWarn = logWarn; } // ── Verifica se è possibile aprire un nuovo ordine ────────────────── /// true = ok aprire, false = bloccato dal risk manager public async Task CanOpenNewPositionAsync() { try { // Conta solo le posizioni aperte dal bot (ClientOrderId BOT_) var positions = await _svc.GetBotPositionsAsync(); int openCount = positions?.Count ?? 0; // 1. Limite numero posizioni globale if (openCount >= _cfg.MaxOpenPositions) { _logWarn($"[RISK] Apertura bloccata: {openCount} posizioni aperte su {_cfg.MaxOpenPositions} consentite."); return false; } // 2. Limite posizioni per asset int assetCount = positions?.Count(p => string.Equals( p.Symbol, _cfg.Symbol, StringComparison.OrdinalIgnoreCase)) ?? 0; if (assetCount >= _cfg.MaxOpenPositionsPerAsset) { _logWarn($"[RISK] Apertura bloccata: {assetCount} posizioni aperte su {_cfg.Symbol} (limite per asset: {_cfg.MaxOpenPositionsPerAsset})."); return false; } // 3. Limite capitale allocato if (positions != null && positions.Count > 0) { decimal equity = await _svc.GetAvailableEquityAsync(); decimal totalEquity = equity + positions.Sum(p => Math.Abs(p.MarketValue ?? 0)); decimal allocated = positions.Sum(p => Math.Abs(p.MarketValue ?? 0)); decimal allocPct = totalEquity > 0 ? allocated / totalEquity : 0; if (allocPct >= _cfg.MaxCapitalAllocatedPercent) { _logWarn($"[RISK] Apertura bloccata: capitale allocato {allocPct:P1} >= limite {_cfg.MaxCapitalAllocatedPercent:P1}."); return false; } } return true; } catch (Exception ex) { _logWarn($"[RISK] Errore controllo posizioni: {ex.Message} — apertura permessa per sicurezza."); return true; // fail-open: non bloccare per errori di rete } } // ── Calcola la dimensione ottimale per la nuova posizione ──────────── /// /// Ritorna la quantità da comprare in base al capitale disponibile e /// alla quota assegnabile a questa specifica posizione. /// public async Task ComputeQuantityAsync(decimal price, bool isCrypto) { if (price <= 0) return 0; decimal equity = await _svc.GetAvailableEquityAsync(); // Considera solo le posizioni aperte dal bot per stimare il capitale già allocato IReadOnlyList positions; try { positions = await _svc.GetBotPositionsAsync(); } catch { positions = new List(); } decimal allocated = positions?.Sum(p => Math.Abs(p.MarketValue ?? 0)) ?? 0m; decimal totalEquity = equity + allocated; // Budget massimo assoluto per questa posizione decimal maxBudget = totalEquity * _cfg.MaxPositionSizePercent; // Non superare la quota di capitale libero disponibile decimal usableBudget = Math.Min(maxBudget, equity * 0.95m); // riserva 5% liquidità decimal rawQty = usableBudget / price; decimal qty = isCrypto ? Math.Round(rawQty, 4, MidpointRounding.ToEven) : Math.Floor(rawQty); if (qty <= 0) _logWarn($"[RISK] Quantità calcolata = 0 (budget usabile: {usableBudget:F2}, prezzo: {price:F2})."); return qty; } // ── Gestione proattiva delle posizioni aperte (profit lock / stop) ─── /// /// Valuta ogni posizione aperta per il simbolo del bot. Se una posizione /// supera la soglia di profitto o di perdita, la chiude emettendo log adeguati. /// Chiamare a ogni ciclo PRIMA della valutazione del segnale. /// public async Task ManageOpenPositionsAsync() { try { var position = await _svc.GetPositionAsync(_cfg.Symbol); if (position == null) return; decimal entryPrice = position.AverageEntryPrice; decimal currentPrice = position.AssetCurrentPrice ?? entryPrice; decimal qty = position.Quantity; decimal marketValue = position.MarketValue ?? (currentPrice * qty); decimal costBasis = position.CostBasis != 0 ? position.CostBasis : entryPrice * qty; if (costBasis == 0) return; decimal unrealizedPnl = marketValue - costBasis; decimal pnlPct = unrealizedPnl / costBasis; _logInfo($"[RISK] Posizione {_cfg.Symbol}: ingresso={entryPrice:F2} corrente={currentPrice:F2} PnL={pnlPct:P2} ({unrealizedPnl:+F2;-F2})"); // ── Profit Lock: chiudi se in profitto sopra soglia ────────── if (_cfg.ProfitLockPercent > 0 && pnlPct >= _cfg.ProfitLockPercent) { _logInfo($"[RISK] PROFIT LOCK: PnL {pnlPct:P2} >= {_cfg.ProfitLockPercent:P2}. Chiudo posizione."); await _svc.ClosePositionAsync(_cfg.Symbol); return; } // ── Break-even alert (solo log, SL fisico già piazzato) ────── if (_cfg.UseBreakEvenStop && _cfg.ProfitLockPercent > 0) { decimal breakEvenTrigger = _cfg.ProfitLockPercent / 2m; if (pnlPct >= breakEvenTrigger) _logInfo($"[RISK] Break-even zone raggiunta (PnL {pnlPct:P2}). Lo stop-loss dovrebbe essere al prezzo di ingresso ({entryPrice:F2})."); } // ── Stop Loss dinamico: chiudi se in perdita oltre soglia ──── if (_cfg.MaxLossPercent > 0 && pnlPct <= -_cfg.MaxLossPercent) { _logWarn($"[RISK] STOP LOSS DINAMICO: PnL {pnlPct:P2} <= -{_cfg.MaxLossPercent:P2}. Chiudo posizione per limitare le perdite."); await _svc.ClosePositionAsync(_cfg.Symbol); } } catch (Exception ex) { // La posizione potrebbe non esistere (già chiusa automaticamente dall'ordine SL/TP) if (!ex.Message.Contains("position does not exist", StringComparison.OrdinalIgnoreCase) && !ex.Message.Contains("404", StringComparison.OrdinalIgnoreCase)) _logWarn($"[RISK] Errore gestione posizione: {ex.Message}"); } } // ── Report sintetico della situazione del portafoglio ──────────────── public async Task LogPortfolioStatusAsync() { try { var positions = await _svc.GetAllPositionsAsync(); if (positions == null || positions.Count == 0) { _logInfo("[RISK] Nessuna posizione aperta."); return; } decimal totalUnrealized = positions.Sum(p => p.UnrealizedProfitLoss.GetValueOrDefault()); _logInfo($"[RISK] Portafoglio: {positions.Count} posizioni aperte | PnL totale non realizzato: {totalUnrealized:+F2;-F2}"); foreach (var p in positions) { decimal cb = p.CostBasis != 0 ? p.CostBasis : 1m; decimal pnlPct = p.UnrealizedProfitLoss.GetValueOrDefault() / cb; _logInfo($" [{p.Symbol}] qty={p.Quantity} entry={p.AverageEntryPrice:F2} mktVal={p.MarketValue:F2} PnL={pnlPct:P2}"); } } catch (Exception ex) { _logWarn($"[RISK] Errore lettura portafoglio: {ex.Message}"); } } } }