219 lines
10 KiB
C#
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}");
|
|
}
|
|
}
|
|
}
|
|
}
|