Files
Encelado/DesktopBot/Engine/PositionRiskManager.cs
T
2026-06-09 18:29:41 +02:00

219 lines
10 KiB
C#

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<string> _logInfo;
private readonly Action<string> _logWarn;
public PositionRiskManager(
ITradingService service,
BotConfiguration config,
Action<string> logInfo,
Action<string> logWarn)
{
_svc = service;
_cfg = config;
_logInfo = logInfo;
_logWarn = logWarn;
}
// ── Verifica se è possibile aprire un nuovo ordine ──────────────────
/// <returns>true = ok aprire, false = bloccato dal risk manager</returns>
public async Task<bool> 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 ────────────
/// <summary>
/// Ritorna la quantità da comprare in base al capitale disponibile e
/// alla quota assegnabile a questa specifica posizione.
/// </summary>
public async Task<decimal> 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<IPosition> positions;
try { positions = await _svc.GetBotPositionsAsync(); }
catch { positions = new List<IPosition>(); }
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) ───
/// <summary>
/// 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.
/// </summary>
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}");
}
}
}
}