Sviluppo TradingBot

This commit is contained in:
2026-06-09 18:29:41 +02:00
parent 61f1e59964
commit e3c0bd51b2
133 changed files with 24903 additions and 1 deletions
+611
View File
@@ -0,0 +1,611 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Alpaca.Markets;
using DesktopBot.Models;
using DesktopBot.Services;
namespace DesktopBot.Engine
{
/// <summary>
/// Motore di trading automatizzato multi-strategia.
/// Strategie supportate:
/// 1. EMA Crossover - Golden/Death cross su EMA veloce vs lenta
/// 2. RSI - Ipervenduto/Ipercomprato con Wilder RSI
/// 3. MACD - Crossover MACD line / Signal line
/// 4. Volatility Breakout - Keltner Channel + filtro RVOL (Cap.3 report)
/// 5. Kalman Mean Reversion- Fair-value con filtro di Kalman (Cap.4 report)
/// </summary>
public class AutomatedBotEngine
{
private readonly ITradingService _tradingService;
/// <summary>Lock per serializzare le operazioni di apertura/chiusura ordini.</summary>
private readonly SemaphoreSlim _orderExecutionLock = new SemaphoreSlim(1, 1);
/// <summary>Asset class per il controllo orario mercato (settato dal viewmodel).</summary>
public string AssetClass { get; set; } = "us_equity";
// ?? eventi esposti al viewmodel ???????????????????????????????????????
public event EventHandler<BotLogEntry> LogGenerated;
public event EventHandler<TradingSignal> SignalGenerated;
public event EventHandler<decimal> EquityUpdated;
public event EventHandler<bool> WaitingForMarketChanged;
// ?? stato runtime ?????????????????????????????????????????????????????
private CancellationTokenSource _cts;
private Task _loopTask;
private bool _isRunning;
private BotConfiguration _config;
private KalmanFilterState _kalman;
private BtcUsdAlgorithm _btcAlgo;
private PositionRiskManager _riskManager;
public bool IsRunning => _isRunning;
public AutomatedBotEngine(ITradingService tradingService)
{
_tradingService = tradingService ?? throw new ArgumentNullException(nameof(tradingService));
}
// ?? avvio / stop ??????????????????????????????????????????????????????
public Task StartAsync(BotConfiguration config)
{
if (_isRunning) return Task.CompletedTask;
_config = config ?? throw new ArgumentNullException(nameof(config));
_kalman = new KalmanFilterState(_config.KalmanDelta, _config.KalmanObservationVariance);
_btcAlgo = new BtcUsdAlgorithm();
_riskManager = new PositionRiskManager(_tradingService, _config, Info, Warn);
_cts = new CancellationTokenSource();
_isRunning = true;
_loopTask = Task.Run(() => BotMainLoopAsync(_cts.Token));
Info($"Bot avviato strategia: {_config.Strategy}");
return Task.CompletedTask;
}
public void Stop()
{
if (!_isRunning) return;
_cts?.Cancel();
_isRunning = false;
Info("Bot fermato.");
}
// ?? loop principale ???????????????????????????????????????????????????
private async Task BotMainLoopAsync(CancellationToken ct)
{
bool waitingState = false;
bool authErrorDetected = false; // Flag per tracciare errori di autorizzazione
while (!ct.IsCancellationRequested)
{
try
{
bool marketOpen = MarketHoursService.IsMarketOpen(AssetClass);
if (!marketOpen)
{
if (!waitingState) { waitingState = true; WaitingForMarketChanged?.Invoke(this, true); }
await Task.Delay(TimeSpan.FromSeconds(30), ct);
continue;
}
if (waitingState) { waitingState = false; WaitingForMarketChanged?.Invoke(this, false); }
await AnalyzeMarketAsync(ct);
authErrorDetected = false; // Reset il flag se una chiamata ha successo
}
catch (OperationCanceledException) { break; }
catch (Exception ex) when (ex.Message.Contains("401") || ex.Message.Contains("Unauthorized") || ex.Message.Contains("non autorizzato"))
{
if (!authErrorDetected)
{
Warn("⚠ Errore di autorizzazione rilevato. Il bot si fermerà automaticamente. Verifica API Key e Secret.");
authErrorDetected = true;
_isRunning = false;
break; // Ferma il loop
}
}
catch (Exception ex) { Warn($"Errore nel loop: {ex.Message}"); }
await Task.Delay(TimeSpan.FromSeconds(_config.CheckIntervalSeconds), ct);
}
}
// ?? dispatch analisi ??????????????????????????????????????????????????
private async Task AnalyzeMarketAsync(CancellationToken ct)
{
// Determina il timeframe e il numero di barre da richiedere
var timeFrame = _config.AnalysisTimeFrame;
int barCount;
if (_config.HistoricalBarCount > 0)
{
// Usa il valore configurato se specificato
barCount = _config.HistoricalBarCount;
}
else
{
// Calcolo automatico in base al timeframe e alla strategia
barCount = timeFrame == BarTimeFrame.Minute
? Math.Max(200, _config.SlowEmaPeriod * 10) // Per 1min: almeno 200 barre
: Math.Max(60, _config.SlowEmaPeriod * 3); // Per day/hour: almeno 60 barre
}
List<IBar> bars = await FetchBarsWithRetryAsync(_config.Symbol, timeFrame, barCount, ct);
if (bars == null) return; // retry esauriti, ciclo saltato
try { decimal eq = await _tradingService.GetAvailableEquityAsync(); EquityUpdated?.Invoke(this, eq); }
catch { /* non bloccante */ }
TradingSignal signal;
// BTC/USD: usa l'algoritmo avanzato dedicato
if (_config.Symbol.StartsWith("BTC", StringComparison.OrdinalIgnoreCase))
{
signal = AnalyzeBtcUsd(bars);
}
else
{
switch (_config.Strategy)
{
case TradingStrategy.EMA_CROSSOVER: signal = AnalyzeEmaCrossover(bars); break;
case TradingStrategy.RSI: signal = AnalyzeRsi(bars); break;
case TradingStrategy.MACD: signal = AnalyzeMacd(bars); break;
case TradingStrategy.VOLATILITY_BREAKOUT: signal = AnalyzeVolatilityBreakout(bars); break;
case TradingStrategy.KALMAN_MEAN_REVERSION: signal = AnalyzeKalmanMeanReversion(bars); break;
default: return;
}
}
// ── Gestione proattiva delle posizioni aperte (profit lock / stop loss dinamico) ──
await _riskManager.ManageOpenPositionsAsync();
if (signal == null || signal.Type == SignalType.None || signal.Type == SignalType.Hold) return;
SignalGenerated?.Invoke(this, signal);
Info($"SIGNAL {signal.Type}: {signal.Reason} (confidenza: {signal.Confidence}%)");
// Acquisisci il lock per serializzare le operazioni di ordine
await _orderExecutionLock.WaitAsync();
try
{
bool hasPos = await _tradingService.HasOpenPositionAsync(_config.Symbol);
if (signal.Type == SignalType.Buy && !hasPos)
{
// Verifica risk prima di aprire
bool canOpen = await _riskManager.CanOpenNewPositionAsync();
if (canOpen)
await ExecuteOrderAsync(signal);
}
if (signal.Type == SignalType.Sell && hasPos)
{
Info($"[SIGNAL] Segnale SELL: chiusura posizione {_config.Symbol}.");
await _tradingService.ClosePositionAsync(_config.Symbol);
}
}
finally
{
_orderExecutionLock.Release();
}
}
// -- Fetch con retry lineare -----------------------------------------
private async System.Threading.Tasks.Task<System.Collections.Generic.List<Alpaca.Markets.IBar>> FetchBarsWithRetryAsync(
string symbol, Alpaca.Markets.BarTimeFrame timeFrame, int barCount, System.Threading.CancellationToken ct)
{
const int MaxRetries = 10;
const int RetryStepSec = 10;
for (int attempt = 1; attempt <= MaxRetries; attempt++)
{
if (ct.IsCancellationRequested) return null;
var bars = await _tradingService.GetHistoricalBarsAsync(symbol, timeFrame, barCount);
if (bars != null && bars.Count >= 30)
return bars;
int waitSec = attempt * RetryStepSec;
Warn(string.Format("Dati storici insufficienti ({0} barre). Tentativo {1}/{2} tra {3}s...", bars?.Count ?? 0, attempt, MaxRetries, waitSec));
try { await System.Threading.Tasks.Task.Delay(System.TimeSpan.FromSeconds(waitSec), ct); }
catch (System.OperationCanceledException) { return null; }
}
Warn("Dati storici non disponibili dopo tutti i tentativi. Ciclo saltato.");
return null;
}
// ?? ALGORITMO BTC/USD (dispatcher verso BtcUsdAlgorithm) ??????????????
private TradingSignal AnalyzeBtcUsd(List<IBar> bars)
{
var result = _btcAlgo.Analyze(bars);
Info($"[BTC] {result.ToLogString()}");
if (result.Type == SignalType.Buy)
return MakeSignal(SignalType.Buy, result.Price, result.Reason,
result.Confidence, result.StopLoss, result.TakeProfit);
if (result.Type == SignalType.Sell)
return MakeSignal(SignalType.Sell, result.Price, result.Reason, result.Confidence);
return Neutral();
}
// ?? STRATEGIA 1: EMA CROSSOVER ?????????????????????????????????????????
// BUY : EMA veloce incrocia al rialzo EMA lenta (golden cross)
// SELL: EMA veloce incrocia al ribasso EMA lenta (death cross)
private TradingSignal AnalyzeEmaCrossover(List<IBar> bars)
{
var closes = bars.Select(b => b.Close).ToList();
var fastEma = CalculateEma(closes, _config.FastEmaPeriod);
var slowEma = CalculateEma(closes, _config.SlowEmaPeriod);
if (fastEma.Count < 2 || slowEma.Count < 2) return Neutral();
decimal fPrev = fastEma[fastEma.Count - 2], sPrev = slowEma[slowEma.Count - 2];
decimal fCurr = fastEma[fastEma.Count - 1], sCurr = slowEma[slowEma.Count - 1];
decimal price = closes.Last(), atr = CalculateAtr(bars, 14);
if (fPrev <= sPrev && fCurr > sCurr)
return MakeSignal(SignalType.Buy, price, $"Golden cross EMA{_config.FastEmaPeriod}/EMA{_config.SlowEmaPeriod}", 70,
atr > 0 ? price - atr * 1.5m : price * (1 - _config.StopLossPercentage),
atr > 0 ? price + atr * 2.5m : price * (1 + _config.TakeProfitPercentage));
if (fPrev >= sPrev && fCurr < sCurr)
return MakeSignal(SignalType.Sell, price, $"Death cross EMA{_config.FastEmaPeriod}/EMA{_config.SlowEmaPeriod}", 65);
return Neutral();
}
// ?? STRATEGIA 2: RSI ??????????????????????????????????????????????????
// BUY : RSI < soglia oversold
// SELL: RSI > soglia overbought
private TradingSignal AnalyzeRsi(List<IBar> bars)
{
var closes = bars.Select(b => b.Close).ToList();
decimal rsi = CalculateRsi(closes, _config.RsiPeriod);
decimal price = closes.Last(), atr = CalculateAtr(bars, 14);
if (rsi <= _config.RsiOversoldThreshold)
return MakeSignal(SignalType.Buy, price, $"RSI oversold ({rsi:F1})", 65,
atr > 0 ? price - atr * 1.5m : price * (1 - _config.StopLossPercentage),
atr > 0 ? price + atr * 2.0m : price * (1 + _config.TakeProfitPercentage));
if (rsi >= _config.RsiOverboughtThreshold)
return MakeSignal(SignalType.Sell, price, $"RSI overbought ({rsi:F1})", 65);
return Neutral();
}
// ?? STRATEGIA 3: MACD ?????????????????????????????????????????????????
// BUY : MACD line incrocia al rialzo la signal line
// SELL: MACD line incrocia al ribasso la signal line
private TradingSignal AnalyzeMacd(List<IBar> bars)
{
var closes = bars.Select(b => b.Close).ToList();
var emaFast = CalculateEma(closes, _config.MacdFastPeriod);
var emaSlow = CalculateEma(closes, _config.MacdSlowPeriod);
int minLen = Math.Min(emaFast.Count, emaSlow.Count);
if (minLen < _config.MacdSignalPeriod + 2) return Neutral();
var macdLine = new List<decimal>();
for (int i = 0; i < minLen; i++)
macdLine.Add(emaFast[emaFast.Count - minLen + i] - emaSlow[emaSlow.Count - minLen + i]);
var sigLine = CalculateEma(macdLine, _config.MacdSignalPeriod);
if (sigLine.Count < 2) return Neutral();
decimal mPrev = macdLine[macdLine.Count - 2], mCurr = macdLine[macdLine.Count - 1];
decimal sPrev = sigLine[sigLine.Count - 2], sCurr = sigLine[sigLine.Count - 1];
decimal price = closes.Last(), atr = CalculateAtr(bars, 14);
if (mPrev <= sPrev && mCurr > sCurr)
return MakeSignal(SignalType.Buy, price, "MACD crossover bullish", 72,
atr > 0 ? price - atr * 1.5m : price * (1 - _config.StopLossPercentage),
atr > 0 ? price + atr * 2.5m : price * (1 + _config.TakeProfitPercentage));
if (mPrev >= sPrev && mCurr < sCurr)
return MakeSignal(SignalType.Sell, price, "MACD crossover bearish", 68);
return Neutral();
}
// ?? STRATEGIA 4: VOLATILITY BREAKOUT (Keltner + RVOL) ????????????????
// Capitolo 3 del report: breakout banda Keltner + conferma volume relativo
// BUY : close > upper band AND RVOL >= RvolMinThreshold
// SELL: close < lower band
// SL = entry - AtrStopMultiplier*ATR | TP = entry + 2*AtrStopMultiplier*ATR
private TradingSignal AnalyzeVolatilityBreakout(List<IBar> bars)
{
int period = _config.KeltnerPeriod;
if (bars.Count < period + 5) return Neutral();
var recent = bars.Skip(bars.Count - period - 5).ToList();
var closes = recent.Select(b => b.Close).ToList();
var vols = recent.Select(b => b.Volume).ToList();
decimal atr = CalculateAtr(recent, period);
decimal midEma = CalculateEma(closes, period).Last();
decimal upper = midEma + _config.KeltnerMultiplier * atr;
decimal lower = midEma - _config.KeltnerMultiplier * atr;
decimal price = closes.Last();
decimal avgVol = vols.Take(vols.Count - 1).Average();
decimal rvol = avgVol > 0 ? vols.Last() / (decimal)avgVol : 0m;
Info($"[VBKOUT] Close={price:F2} Upper={upper:F2} Lower={lower:F2} RVOL={rvol:F2}");
if (price > upper && rvol >= _config.RvolMinThreshold)
{
decimal d = _config.AtrStopMultiplier * atr;
return MakeSignal(SignalType.Buy, price, $"Keltner breakout UP - RVOL={rvol:F2}x", 78, price - d, price + d * 2m);
}
if (price < lower)
return MakeSignal(SignalType.Sell, price, "Keltner breakout DOWN", 70);
return Neutral();
}
// ?? STRATEGIA 5: KALMAN MEAN REVERSION ???????????????????????????????
// Capitolo 4 del report: filtro di Kalman per stima del fair value.
// BUY : Z-Score <= -entryThreshold (prezzo molto sotto fair value)
// SELL: Z-Score >= +entryThreshold (prezzo molto sopra fair value)
// EXIT: |Z-Score| <= exitThreshold (convergenza alla media)
private TradingSignal AnalyzeKalmanMeanReversion(List<IBar> bars)
{
if (bars.Count < 30) return Neutral();
foreach (var bar in bars) _kalman.Update(bar.Close);
decimal price = bars.Last().Close, fair = (decimal)_kalman.StateEstimate;
double z = _kalman.SpreadStdDev > 0 ? (double)(price - fair) / _kalman.SpreadStdDev : 0.0;
Info($"[KALMAN] Price={price:F2} Fair={fair:F2} Z={z:F3}");
decimal atr = CalculateAtr(bars, 14);
if (z <= -_config.KalmanEntryZScore)
return MakeSignal(SignalType.Buy, price, $"Kalman BUY (z={z:F2})", Math.Min(95, (int)(Math.Abs(z) * 30)),
atr > 0 ? price - atr * 2m : price * (1 - _config.StopLossPercentage), fair);
if (z >= _config.KalmanEntryZScore)
return MakeSignal(SignalType.Sell, price, $"Kalman SELL (z={z:F2})", Math.Min(95, (int)(Math.Abs(z) * 30)));
if (Math.Abs(z) <= _config.KalmanExitZScore)
return MakeSignal(SignalType.Sell, price, $"Kalman EXIT convergenza (z={z:F2})", 80);
return Neutral();
}
// ?? esecuzione ordine bracket ?????????????????????????????????????????
private async Task ExecuteOrderAsync(TradingSignal signal)
{
try
{
// ── Verifica FINALE prima di piazzare ──
// Riconta le posizioni in tempo reale da Alpaca
IReadOnlyList<IPosition> positions = null;
try
{
positions = await _tradingService.GetBotPositionsAsync();
}
catch (Exception exPos)
{
Warn($"[VERIFY] Errore nel recupero posizioni: {exPos.Message}. Procedo comunque con caution.");
// Continua lo stesso, ma con più cautela
}
int openCount = positions?.Count ?? 0;
bool hasPosition = positions?.Any(p => string.Equals(p.Symbol, signal.Symbol, StringComparison.OrdinalIgnoreCase)) ?? false;
if (signal.Type == SignalType.Buy && hasPosition)
{
Warn($"[VERIFY] Posizione già aperta su {signal.Symbol}; ordine non piazzato.");
return;
}
// Verifica che non abbia superato il limite globale
if (signal.Type == SignalType.Buy && openCount >= _config.MaxOpenPositions)
{
Warn($"[VERIFY] Limite posizioni raggiunto ({openCount}/{_config.MaxOpenPositions}); ordine non piazzato.");
return;
}
// Verifica per-asset
int assetCount = positions?.Count(p => string.Equals(
p.Symbol, signal.Symbol, StringComparison.OrdinalIgnoreCase)) ?? 0;
if (signal.Type == SignalType.Buy && assetCount >= _config.MaxOpenPositionsPerAsset)
{
Warn($"[VERIFY] Limite posizioni per asset raggiunto ({assetCount}/{_config.MaxOpenPositionsPerAsset}); ordine non piazzato.");
return;
}
decimal equity = 0;
try
{
equity = await _tradingService.GetAvailableEquityAsync();
}
catch (Exception exEq)
{
Warn($"[VERIFY] Errore nel recupero equity: {exEq.Message}. Procedo con cautela.");
}
bool isCryptoOrder2 = !string.IsNullOrEmpty(AssetClass) &&
AssetClass.IndexOf("crypto", StringComparison.OrdinalIgnoreCase) >= 0;
decimal qty;
if (signal.Price > 0)
{
qty = await _riskManager.ComputeQuantityAsync(signal.Price, isCryptoOrder2);
}
else
{
qty = _config.Quantity;
}
if (qty <= 0)
{
Warn($"[VERIFY] Capitale insufficiente (equity: ${equity:F2}) o limite risk raggiunto. Ordine non piazzato.");
return;
}
// Log stato prima di piazzare
Info($"[PRE-ORDER] Posizioni aperte: {openCount}/{_config.MaxOpenPositions} | Equity: ${equity:F2} | Qty da piazzare: {qty}");
bool isCryptoOrder = isCryptoOrder2;
decimal sl = signal.StopLoss > 0 ? signal.StopLoss : signal.Price * (1 - _config.StopLossPercentage);
decimal tp = signal.TakeProfit > 0 ? signal.TakeProfit : signal.Price * (1 + _config.TakeProfitPercentage);
sl = Math.Max(sl, signal.Price * 0.80m);
tp = Math.Min(tp, signal.Price * 1.50m);
if (isCryptoOrder)
{
// Alpaca crypto non supporta bracket/OTOCO: piazza market + stop/limit separati
var entry = await _tradingService.PlaceMarketOrderAsync(signal.Symbol, qty, signal.Side);
Info($"✓ Market {signal.Side} {qty}x {signal.Symbol} @ ~${signal.Price:F2} | #{entry?.OrderId}");
// Stop-loss separato (lato opposto)
var stopSide = signal.Side == OrderSide.Buy ? OrderSide.Sell : OrderSide.Buy;
try
{
// arrotonda stop price a 2 decimali per passare la validazione Alpaca
decimal slRounded = Math.Round(sl, 2);
decimal tpRounded = Math.Round(tp, 2);
// Garantisce che SL < prezzo corrente per Buy (stop sell)
if (signal.Side == OrderSide.Buy && slRounded >= signal.Price)
slRounded = Math.Round(signal.Price * 0.99m, 2);
if (signal.Side == OrderSide.Sell && slRounded <= signal.Price)
slRounded = Math.Round(signal.Price * 1.01m, 2);
var slOrder = await _tradingService.PlaceStopOrderAsync(signal.Symbol, qty, slRounded, stopSide);
Info($"✓ Stop-loss {stopSide} @ ${slRounded:F2} | #{slOrder?.OrderId}");
var tpOrder = await _tradingService.PlaceLimitOrderAsync(signal.Symbol, qty, tpRounded, stopSide);
Info($"✓ Take-profit {stopSide} @ ${tpRounded:F2} | #{tpOrder?.OrderId}");
}
catch (Exception exProt) { Warn($"SL/TP separati non piazzati: {exProt.Message}"); }
}
else
{
var order = await _tradingService.PlaceBracketOrderAsync(
signal.Symbol, qty, signal.Price, tp, sl, signal.Side);
Info($"✓ Bracket {signal.Side} {qty}x {signal.Symbol} @ ${signal.Price:F2} | SL=${sl:F2} TP=${tp:F2} | #{order?.OrderId}");
}
}
catch (Exception ex)
{
if (ex.Message.Contains("401") || ex.Message.Contains("Unauthorized") || ex.Message.Contains("non autorizzato"))
Warn($"⚠ Errore di autorizzazione Alpaca: {ex.Message}. Verifica API Key e Secret.");
else
Warn($"Errore esecuzione ordine: {ex.Message}");
}
}
// ?? indicatori tecnici ????????????????????????????????????????????????
private static List<decimal> CalculateEma(List<decimal> v, int period)
{
var r = new List<decimal>();
if (v.Count < period) return r;
decimal k = 2m / (period + 1), cur = v.Take(period).Average();
r.Add(cur);
for (int i = period; i < v.Count; i++) { cur = v[i] * k + cur * (1 - k); r.Add(cur); }
return r;
}
private static decimal CalculateRsi(List<decimal> c, int period)
{
if (c.Count < period + 1) return 50m;
decimal ag = 0, al = 0;
for (int i = 1; i <= period; i++) { decimal d = c[i] - c[i - 1]; if (d > 0) ag += d; else al -= d; }
ag /= period; al /= period;
for (int i = period + 1; i < c.Count; i++)
{
decimal d = c[i] - c[i - 1], g = d > 0 ? d : 0, l = d < 0 ? -d : 0;
ag = (ag * (period - 1) + g) / period;
al = (al * (period - 1) + l) / period;
}
if (al == 0) return 100m;
return 100m - (100m / (1 + ag / al));
}
private static decimal CalculateAtr(List<IBar> bars, int period)
{
if (bars.Count < period + 1) return 0m;
decimal atr = 0;
for (int i = 1; i <= period; i++)
{
decimal hl = bars[i].High - bars[i].Low;
decimal hpc = Math.Abs(bars[i].High - bars[i - 1].Close);
decimal lpc = Math.Abs(bars[i].Low - bars[i - 1].Close);
atr += Math.Max(hl, Math.Max(hpc, lpc));
}
atr /= period;
for (int i = period + 1; i < bars.Count; i++)
{
decimal hl = bars[i].High - bars[i].Low;
decimal hpc = Math.Abs(bars[i].High - bars[i - 1].Close);
decimal lpc = Math.Abs(bars[i].Low - bars[i - 1].Close);
decimal tr = Math.Max(hl, Math.Max(hpc, lpc));
atr = (atr * (period - 1) + tr) / period;
}
return atr;
}
// ?????????????????????????????????????????????????????????????????????
private TradingSignal MakeSignal(SignalType t, decimal price, string reason, int conf,
decimal sl = 0, decimal tp = 0)
=> new TradingSignal
{
Type = t, Symbol = _config.Symbol, Price = price,
Timestamp = DateTime.UtcNow, Reason = reason, Confidence = conf,
StopLoss = sl, TakeProfit = tp
};
private static TradingSignal Neutral() => new TradingSignal { Type = SignalType.None };
private void Info(string msg) => LogGenerated?.Invoke(this, new BotLogEntry(LogLevel.Info, msg));
private void Warn(string msg) => LogGenerated?.Invoke(this, new BotLogEntry(LogLevel.Warning, msg));
}
// ???????????????????????????????????????????????????????????????????????????
// FILTRO DI KALMAN stato persistente single-asset
// Capitolo 4 del report: stima del fair value a guadagno variabile.
// Predict: P_pred = P + Q (Q = delta/(1-delta))
// Update : K = P_pred/(P_pred+R)
// x = x + K*(z-x) P = (1-K)*P_pred
// ???????????????????????????????????????????????????????????????????????????
internal sealed class KalmanFilterState
{
private readonly double _delta, _r;
private double _x, _p;
private readonly Queue<double> _spreads = new Queue<double>();
private const int SpreadWindow = 20;
public double StateEstimate => _x;
public double SpreadStdDev { get; private set; }
public KalmanFilterState(double delta, double observationVariance)
{ _delta = delta; _r = observationVariance; _x = 0; _p = 1; }
public void Update(decimal observedPrice)
{
double z = (double)observedPrice;
if (_x == 0) { _x = z; _p = 1; return; }
double q = _delta / (1 - _delta), pPred = _p + q, k = pPred / (pPred + _r);
_x = _x + k * (z - _x); _p = (1 - k) * pPred;
_spreads.Enqueue(z - _x);
if (_spreads.Count > SpreadWindow) _spreads.Dequeue();
SpreadStdDev = Std(_spreads);
}
private static double Std(IEnumerable<double> v)
{
var l = v.ToList(); if (l.Count < 2) return 1.0;
double m = l.Average();
return Math.Sqrt(l.Sum(x => (x - m) * (x - m)) / (l.Count - 1));
}
}
}
+459
View File
@@ -0,0 +1,459 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Alpaca.Markets;
using DesktopBot.Models;
namespace DesktopBot.Engine
{
// ═══════════════════════════════════════════════════════════════════════════
// BTC/USD ADVANCED ALGORITHM — v2.0
// Strategia combinata multi-segnale ad alta frequenza (barre da 1 minuto).
//
// ARCHITETTURA:
// ┌─────────────────────────────────────────────────────────────────────┐
// │ 1. REGIME DETECTOR (ADX + Trend Slope) │
// │ Classifica il mercato in: TREND_UP / TREND_DOWN / RANGING │
// │ Attiva le strategie più adatte al regime corrente. │
// │ │
// │ 2. ADAPTIVE MOMENTUM ENGINE (Dual-EMA + MACD + RSI divergence) │
// │ Confluenza di tre oscillatori su barre 1min. │
// │ Punteggio: 03. Richiede >= 2 per generare segnale. │
// │ │
// │ 3. KALMAN FAIR-VALUE FILTER (filtro adattivo di Kalman) │
// │ Stima il fair-value in tempo reale. Genera segnale quando │
// │ il prezzo si discosta di > KalmanZEntry deviazioni standard. │
// │ │
// │ 4. VOLATILITY BREAKOUT GATE (Keltner Channel + RVOL) │
// │ Filtra i breakout falsi richiedendo volume relativo >= 2x. │
// │ In regime RANGING sostituisce il momentum come segnale. │
// │ │
// │ 5. ATR DYNAMIC POSITION SIZING (ATR-based SL/TP) │
// │ Stop Loss = entry 1.5 × ATR(14) │
// │ Take Profit = entry + 2.5 × ATR(14) → Risk/Reward = 1:1.67 │
// │ Max drawdown del segnale capped al 4% del prezzo di entrata. │
// └─────────────────────────────────────────────────────────────────────┘
//
// LOGICA DI DECISIONE:
// ─────────────────────
// BUY se TUTTI i seguenti:
// • Regime == TREND_UP OPPURE (Regime == RANGING e breakout bullish)
// • MomentumScore >= 2
// • Kalman Z-Score <= KalmanZEntry (prezzo sotto fair-value)
// • Nessuna posizione aperta
//
// SELL se UNO dei seguenti:
// • Regime == TREND_DOWN AND MomentumScore <= 0 (tutti bearish)
// • Kalman Z-Score >= +KalmanZEntry (prezzo sopra fair-value)
// • Breakout della banda Keltner inferiore con RVOL >= 2x
// • Posizione aperta con perdita >= 4% (trailing risk-gate)
//
// PARAMETRI PRE-CALIBRATI PER BTC/USD 1-MINUTO:
// ───────────────────────────────────────────────
// FastEma = 5 (5 minuti)
// SlowEma = 20 (20 minuti)
// RsiPeriod = 14
// MacdFast = 8, MacdSlow = 21, MacdSignal = 5
// AdxPeriod = 14 (soglia trend: ADX > 25)
// AtrPeriod = 14
// KalmanDelta = 5e-6
// KalmanZ_Entry = 1.8
// KeltnerPeriod = 20, KeltnerMult = 2.0
// RvolMin = 2.0x
// ═══════════════════════════════════════════════════════════════════════════
public sealed class BtcUsdAlgorithm
{
// ── Parametri fissi ottimizzati per BTC/USD 1min ─────────────────────
private const int FastEmaPeriod = 5;
private const int SlowEmaPeriod = 20;
private const int RsiPeriod = 14;
private const int MacdFast = 8;
private const int MacdSlow = 21;
private const int MacdSignal = 5;
private const int AdxPeriod = 14;
private const int AtrPeriod = 14;
private const int KeltnerPeriod = 20;
private const double KalmanDelta = 5e-6;
private const double KalmanObsVar = 1.0;
private const double KalmanZEntry = 1.8;
private const double KalmanZExit = 0.3;
private const decimal KeltnerMult = 2.0m;
private const decimal RvolMin = 2.0m;
private const decimal AdxTrendLevel = 25m; // ADX > 25 → mercato in trend
private const decimal MaxDrawdownPct = 0.04m; // 4% max drawdown
// ── Stato del filtro di Kalman (persistente tra tick) ────────────────
private double _kalmanX;
private double _kalmanP = 1.0;
private readonly Queue<double> _spreads = new Queue<double>(25);
private double _kalmanStd = 1.0;
// ── Stato del regime corrente ────────────────────────────────────────
public MarketRegime CurrentRegime { get; private set; } = MarketRegime.Unknown;
// ── Ultimo segnale prodotto ──────────────────────────────────────────
public BtcSignalResult LastResult { get; private set; } = new BtcSignalResult();
// ════════════════════════════════════════════════════════════════════
// METODO PRINCIPALE
// ════════════════════════════════════════════════════════════════════
/// <summary>
/// Analizza le barre più recenti e restituisce il segnale di trading.
/// Richiede almeno 50 barre da 1 minuto per calcolare tutti gli indicatori.
/// </summary>
public BtcSignalResult Analyze(List<IBar> bars)
{
var result = new BtcSignalResult();
if (bars == null || bars.Count < 50)
{
result.Reason = "Dati insufficienti (richiesti min. 50 bar 1min)";
result.Type = SignalType.None;
return LastResult = result;
}
var closes = bars.Select(b => b.Close).ToList();
var highs = bars.Select(b => b.High).ToList();
var lows = bars.Select(b => b.Low).ToList();
var volumes = bars.Select(b => b.Volume).ToList();
decimal price = closes.Last();
// 1. ── REGIME DETECTOR ──────────────────────────────────────────
decimal adx = CalculateAdx(bars, AdxPeriod);
decimal trendSlope = CalculateTrendSlope(closes, 10); // slope EMA20 ultimi 10 bar
CurrentRegime = DetectRegime(adx, trendSlope);
result.Regime = CurrentRegime;
result.Adx = adx;
// 2. ── ADAPTIVE MOMENTUM ────────────────────────────────────────
int momentumScore = 0;
string momentumInfo = "";
// 2a. EMA dual-cross
var fastEma = CalculateEma(closes, FastEmaPeriod);
var slowEma = CalculateEma(closes, SlowEmaPeriod);
bool emaBull = fastEma.Count >= 2 && slowEma.Count >= 2
&& fastEma[fastEma.Count - 2] <= slowEma[slowEma.Count - 2] && fastEma.Last() > slowEma.Last();
bool emaBear = fastEma.Count >= 2 && slowEma.Count >= 2
&& fastEma[fastEma.Count - 2] >= slowEma[slowEma.Count - 2] && fastEma.Last() < slowEma.Last();
bool emaAbove = fastEma.Count > 0 && slowEma.Count > 0
&& fastEma.Last() > slowEma.Last();
if (emaAbove) { momentumScore++; momentumInfo += "EMA↑ "; }
else { momentumScore--; momentumInfo += "EMA↓ "; }
// 2b. MACD
decimal macdLine, signalLine;
CalculateMacd(closes, MacdFast, MacdSlow, MacdSignal, out macdLine, out signalLine);
bool macdBull = macdLine > signalLine && macdLine > 0;
bool macdBear = macdLine < signalLine && macdLine < 0;
if (macdBull) { momentumScore++; momentumInfo += "MACD↑ "; }
else if (macdBear) { momentumScore--; momentumInfo += "MACD↓ "; }
// 2c. RSI con divergenza
decimal rsi = CalculateRsi(closes, RsiPeriod);
bool rsiBull = rsi > 45 && rsi < 65; // RSI in territorio bullish ma non overbought
bool rsiBear = rsi < 55 && rsi > 35; // RSI in territorio bearish ma non oversold
bool rsiExtremeBull = rsi < 30; // Ipervenduto → opportunità contrarian
bool rsiExtremeBear = rsi > 70; // Ipercomprato → uscita
if (rsiBull || rsiExtremeBull) { momentumScore++; momentumInfo += $"RSI({rsi:F0})↑ "; }
else if (rsiExtremeBear) { momentumScore--; momentumInfo += $"RSI({rsi:F0})↓ "; }
result.MomentumScore = momentumScore;
result.MomentumInfo = momentumInfo.Trim();
result.Rsi = rsi;
result.MacdLine = macdLine;
// 3. ── KALMAN FAIR-VALUE ─────────────────────────────────────────
// Feed tutte le barre nel filtro (aggiorna lo stato persistente)
foreach (var bar in bars) UpdateKalman(bar.Close);
double fairValue = _kalmanX;
double zScore = _kalmanStd > 0
? (double)(price - (decimal)fairValue) / _kalmanStd
: 0.0;
result.FairValue = (decimal)fairValue;
result.KalmanZ = zScore;
// 4. ── VOLATILITY BREAKOUT ───────────────────────────────────────
decimal atr = CalculateAtr(bars, AtrPeriod);
decimal midEma = CalculateEma(closes.Skip(closes.Count - KeltnerPeriod - 5).ToList(), KeltnerPeriod).Last();
decimal upper = midEma + KeltnerMult * atr;
decimal lower = midEma - KeltnerMult * atr;
decimal avgVol = volumes.Count > 1
? volumes.Take(volumes.Count - 1)
.Select(v => (decimal)v).Average()
: 1m;
decimal rvol = avgVol > 0 ? (decimal)volumes.Last() / avgVol : 0m;
bool vbBull = price > upper && rvol >= RvolMin;
bool vbBear = price < lower;
result.Rvol = rvol;
result.Upper = upper;
result.Lower = lower;
// 5. ── DECISIONE FINALE ──────────────────────────────────────────
decimal sl = Math.Max(price - 1.5m * atr, price * (1 - MaxDrawdownPct));
decimal tp = price + 2.5m * atr;
// ── BUY conditions ───
bool buyByTrend = CurrentRegime == MarketRegime.TrendUp && momentumScore >= 2 && zScore <= -KalmanZEntry;
bool buyByBreakout = CurrentRegime == MarketRegime.Ranging && vbBull && momentumScore >= 1;
bool buyByKalman = zScore <= -(KalmanZEntry + 0.5) && momentumScore >= 1; // Strong Kalman segnale standalone
// ── SELL conditions ───
bool sellByTrend = CurrentRegime == MarketRegime.TrendDown && momentumScore <= -1;
bool sellByKalman = zScore >= KalmanZEntry;
bool sellByBreakout = vbBear && rvol >= RvolMin;
if (buyByTrend || buyByBreakout || buyByKalman)
{
result.Type = SignalType.Buy;
if (buyByTrend) result.Reason = $"[TREND_UP] Confluenza: {momentumInfo} | Z={zScore:F2}";
else if (buyByBreakout) result.Reason = $"[BREAKOUT] Keltner UP | RVOL={rvol:F1}x | Score={momentumScore}";
else result.Reason = $"[KALMAN] Forte deviazione Z={zScore:F2} | {momentumInfo}";
result.Confidence = CalculateConfidence(momentumScore, zScore, rvol, CurrentRegime, isBuy: true);
result.StopLoss = sl;
result.TakeProfit = tp;
}
else if (sellByTrend || sellByKalman || sellByBreakout)
{
result.Type = SignalType.Sell;
if (sellByTrend) result.Reason = $"[TREND_DOWN] Momentum={momentumInfo} | ADX={adx:F1}";
else if (sellByKalman) result.Reason = $"[KALMAN] Prezzo sopra fair-value Z={zScore:F2}";
else result.Reason = $"[BREAKOUT_DN] Keltner DOWN | RVOL={rvol:F1}x";
result.Confidence = CalculateConfidence(momentumScore, zScore, rvol, CurrentRegime, isBuy: false);
}
else
{
result.Type = SignalType.Hold;
result.Reason = $"HOLD — Regime={CurrentRegime} Score={momentumScore} Z={zScore:F2} ADX={adx:F1}";
}
result.Price = price;
result.Atr = atr;
return LastResult = result;
}
// ════════════════════════════════════════════════════════════════════
// REGIME DETECTOR
// ════════════════════════════════════════════════════════════════════
private static MarketRegime DetectRegime(decimal adx, decimal trendSlope)
{
if (adx < AdxTrendLevel) return MarketRegime.Ranging;
if (trendSlope > 0) return MarketRegime.TrendUp;
if (trendSlope < 0) return MarketRegime.TrendDown;
return MarketRegime.Ranging;
}
// ════════════════════════════════════════════════════════════════════
// FILTRO DI KALMAN (persistente tra i tick)
// ════════════════════════════════════════════════════════════════════
private void UpdateKalman(decimal observed)
{
double z = (double)observed;
if (_kalmanX == 0) { _kalmanX = z; return; }
double q = KalmanDelta / (1.0 - KalmanDelta);
double pPred = _kalmanP + q;
double k = pPred / (pPred + KalmanObsVar);
_kalmanX = _kalmanX + k * (z - _kalmanX);
_kalmanP = (1.0 - k) * pPred;
_spreads.Enqueue(z - _kalmanX);
if (_spreads.Count > 20) _spreads.Dequeue();
if (_spreads.Count >= 2)
{
var list = _spreads.ToArray();
double mean = list.Average();
_kalmanStd = Math.Sqrt(list.Sum(v => (v - mean) * (v - mean)) / (list.Length - 1));
if (_kalmanStd < 1e-9) _kalmanStd = 1.0;
}
}
// ════════════════════════════════════════════════════════════════════
// INDICATORI TECNICI
// ════════════════════════════════════════════════════════════════════
private static List<decimal> CalculateEma(List<decimal> prices, int period)
{
var r = new List<decimal>();
if (prices.Count < period) return r;
decimal k = 2m / (period + 1);
decimal cur = prices.Take(period).Average();
r.Add(cur);
for (int i = period; i < prices.Count; i++)
{ cur = prices[i] * k + cur * (1 - k); r.Add(cur); }
return r;
}
private static decimal CalculateRsi(List<decimal> closes, int period)
{
if (closes.Count < period + 1) return 50m;
decimal ag = 0, al = 0;
for (int i = 1; i <= period; i++)
{ decimal d = closes[i] - closes[i - 1]; if (d > 0) ag += d; else al -= d; }
ag /= period; al /= period;
for (int i = period + 1; i < closes.Count; i++)
{
decimal d = closes[i] - closes[i - 1];
ag = (ag * (period - 1) + Math.Max(d, 0)) / period;
al = (al * (period - 1) + Math.Max(-d, 0)) / period;
}
return al == 0 ? 100m : 100m - (100m / (1 + ag / al));
}
private static void CalculateMacd(List<decimal> closes, int fast, int slow, int signal,
out decimal macdLine, out decimal signalLine)
{
macdLine = 0; signalLine = 0;
var emaF = CalculateEma(closes, fast);
var emaS = CalculateEma(closes, slow);
int len = Math.Min(emaF.Count, emaS.Count);
if (len < signal + 2) return;
var macd = new List<decimal>();
for (int i = 0; i < len; i++)
macd.Add(emaF[emaF.Count - len + i] - emaS[emaS.Count - len + i]);
var sig = CalculateEma(macd, signal);
macdLine = macd.Last();
signalLine = sig.Count > 0 ? sig.Last() : 0;
}
private static decimal CalculateAtr(List<IBar> bars, int period)
{
if (bars.Count < period + 1) return 0m;
decimal atr = 0;
for (int i = 1; i <= period; i++)
{
decimal tr = Math.Max(bars[i].High - bars[i].Low,
Math.Max(Math.Abs(bars[i].High - bars[i - 1].Close),
Math.Abs(bars[i].Low - bars[i - 1].Close)));
atr += tr;
}
atr /= period;
for (int i = period + 1; i < bars.Count; i++)
{
decimal tr = Math.Max(bars[i].High - bars[i].Low,
Math.Max(Math.Abs(bars[i].High - bars[i - 1].Close),
Math.Abs(bars[i].Low - bars[i - 1].Close)));
atr = (atr * (period - 1) + tr) / period;
}
return atr;
}
/// <summary>ADX basato su True Range e Directional Movement.</summary>
private static decimal CalculateAdx(List<IBar> bars, int period)
{
if (bars.Count < period * 2) return 0m;
var dmPlus = new List<decimal>();
var dmMinus = new List<decimal>();
var trList = new List<decimal>();
for (int i = 1; i < bars.Count; i++)
{
decimal upMove = bars[i].High - bars[i - 1].High;
decimal downMove = bars[i - 1].Low - bars[i].Low;
decimal tr = Math.Max(bars[i].High - bars[i].Low,
Math.Max(Math.Abs(bars[i].High - bars[i - 1].Close),
Math.Abs(bars[i].Low - bars[i - 1].Close)));
trList.Add(tr);
dmPlus.Add(upMove > downMove && upMove > 0 ? upMove : 0);
dmMinus.Add(downMove > upMove && downMove > 0 ? downMove : 0);
}
// Wilder smoothing
decimal smoothTr = trList.Take(period).Sum();
decimal smoothPlus = dmPlus.Take(period).Sum();
decimal smoothMinus = dmMinus.Take(period).Sum();
var dx = new List<decimal>();
for (int i = period; i < trList.Count; i++)
{
smoothTr = smoothTr - smoothTr / period + trList[i];
smoothPlus = smoothPlus - smoothPlus / period + dmPlus[i];
smoothMinus = smoothMinus - smoothMinus / period + dmMinus[i];
if (smoothTr == 0) { dx.Add(0); continue; }
decimal diPlus = 100m * smoothPlus / smoothTr;
decimal diMinus = 100m * smoothMinus / smoothTr;
decimal diSum = diPlus + diMinus;
dx.Add(diSum == 0 ? 0 : 100m * Math.Abs(diPlus - diMinus) / diSum);
}
return dx.Count >= period ? dx.Skip(dx.Count - period).Average() : 0m;
}
/// <summary>Slope della EMA(period) negli ultimi n bar (normalizzato per prezzo).</summary>
private static decimal CalculateTrendSlope(List<decimal> closes, int lookback)
{
var ema = CalculateEma(closes, SlowEmaPeriod);
if (ema.Count < lookback + 1) return 0m;
decimal first = ema[ema.Count - lookback - 1];
decimal last = ema.Last();
return first == 0 ? 0m : (last - first) / first;
}
// ════════════════════════════════════════════════════════════════════
// CONFIDENCE SCORE (0100)
// ════════════════════════════════════════════════════════════════════
private static int CalculateConfidence(int momentumScore, double z,
decimal rvol, MarketRegime regime, bool isBuy)
{
int score = 50;
score += momentumScore * 10;
score += (int)(Math.Min(Math.Abs(z), 3.0) * 8);
score += (int)(Math.Min((double)rvol, 4.0) * 4);
if (isBuy && regime == MarketRegime.TrendUp) score += 10;
if (!isBuy && regime == MarketRegime.TrendDown) score += 10;
return Math.Min(Math.Max(score, 1), 99);
}
}
// ══════════════════════════════════════════════════════════════════════
// TIPI DI SUPPORTO
// ══════════════════════════════════════════════════════════════════════
/// <summary>Regime del mercato classificato dal Regime Detector.</summary>
public enum MarketRegime { Unknown, TrendUp, TrendDown, Ranging }
/// <summary>Risultato dettagliato prodotto da BtcUsdAlgorithm.Analyze().</summary>
public sealed class BtcSignalResult
{
public SignalType Type { get; set; } = SignalType.None;
public string Reason { get; set; } = "";
public int Confidence { get; set; }
public decimal Price { get; set; }
public decimal StopLoss { get; set; }
public decimal TakeProfit { get; set; }
// Diagnostica indicatori
public MarketRegime Regime { get; set; }
public decimal Adx { get; set; }
public int MomentumScore { get; set; }
public string MomentumInfo { get; set; } = "";
public decimal Rsi { get; set; }
public decimal MacdLine { get; set; }
public decimal FairValue { get; set; }
public double KalmanZ { get; set; }
public decimal Rvol { get; set; }
public decimal Upper { get; set; }
public decimal Lower { get; set; }
public decimal Atr { get; set; }
/// <summary>Stringa diagnostica completa per il log operativo.</summary>
public string ToLogString() =>
$"[{Type}] {Reason} | " +
$"Regime={Regime} ADX={Adx:F1} Score={MomentumScore} " +
$"RSI={Rsi:F1} MACD={MacdLine:F2} " +
$"FV={FairValue:F1} Z={KalmanZ:F2} RVOL={Rvol:F1}x ATR={Atr:F1} " +
$"Conf={Confidence}%";
}
}
+218
View File
@@ -0,0 +1,218 @@
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}");
}
}
}
}
+248
View File
@@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using DesktopBot.Models;
namespace DesktopBot.Engine
{
/// <summary>
/// Motore di raccomandazione strategia.
/// Per ogni combinazione asset-class / volatilità / tipologia di mercato restituisce
/// la strategia ottimale e i parametri pre-calibrati.
///
/// Logica ispirata agli approcci professionali:
/// Crypto → alta volatilità 24/7 → VOLATILITY_BREAKOUT
/// Equity → orario di mercato → EMA_CROSSOVER o MACD
/// ETF → bassa volatilità → KALMAN_MEAN_REVERSION
/// FX/FX-alike → mean-reverting → RSI
/// </summary>
public static class StrategyAdvisor
{
// ── Profili per asset class ─────────────────────────────────────────
private static readonly Dictionary<string, StrategyProfile[]> _profiles
= new Dictionary<string, StrategyProfile[]>(StringComparer.OrdinalIgnoreCase)
{
// ── Crypto ──────────────────────────────────────────────────────
["crypto"] = new[]
{
new StrategyProfile(
TradingStrategy.VOLATILITY_BREAKOUT,
"Volatility Breakout (1min)",
"Strategia ad alta frequenza per crypto: breakout su barre da 1 minuto con filtro RVOL e CVD.",
"🚀", "#FF6D00",
isRecommended: true,
cfg =>
{
cfg.KeltnerPeriod = 20;
cfg.KeltnerMultiplier = 2.0m;
cfg.RvolMinThreshold = 2.5m; // Soglia RVOL più alta per crypto ad alta volatilità
cfg.AtrStopMultiplier = 1.0m;
cfg.CheckIntervalSeconds = 60; // Tick ogni 60 secondi
cfg.AnalysisTimeFrame = Alpaca.Markets.BarTimeFrame.Minute; // Barre da 1 minuto
cfg.HistoricalBarCount = 200; // 200 barre = 3.3 ore di storico
cfg.StopLossPercentage = 0.03m; // 3% SL per volatilità crypto
cfg.TakeProfitPercentage = 0.06m; // 6% TP, ratio 1:2
}),
new StrategyProfile(
TradingStrategy.KALMAN_MEAN_REVERSION,
"Kalman Mean Reversion (1min)",
"Mean-reversion adattiva su crypto range-bound con analisi a 1 minuto.",
"🔬", "#40C4FF",
isRecommended: false,
cfg =>
{
cfg.KalmanDelta = 1e-5;
cfg.KalmanObservationVariance = 1.0;
cfg.KalmanEntryZScore = 2.0;
cfg.KalmanExitZScore = 0.25;
cfg.CheckIntervalSeconds = 120;
cfg.AnalysisTimeFrame = Alpaca.Markets.BarTimeFrame.Minute;
cfg.HistoricalBarCount = 300; // 5 ore di storico
cfg.StopLossPercentage = 0.03m;
cfg.TakeProfitPercentage = 0.06m;
}),
new StrategyProfile(
TradingStrategy.EMA_CROSSOVER,
"EMA Crossover (1min)",
"Trend-following veloce su barre da 1 minuto — ottimale per BTC/USD in trend forte.",
"⚡", "#00E676",
isRecommended: false,
cfg =>
{
cfg.FastEmaPeriod = 5; // EMA veloce 5 periodi (5 minuti)
cfg.SlowEmaPeriod = 15; // EMA lenta 15 periodi (15 minuti)
cfg.CheckIntervalSeconds = 60; // Tick ogni 60 secondi
cfg.AnalysisTimeFrame = Alpaca.Markets.BarTimeFrame.Minute;
cfg.HistoricalBarCount = 150; // 2.5 ore di storico
cfg.StopLossPercentage = 0.025m; // 2.5% SL
cfg.TakeProfitPercentage = 0.05m; // 5% TP
}),
},
// ── US Equity ────────────────────────────────────────────────────
["us_equity"] = new[]
{
new StrategyProfile(
TradingStrategy.EMA_CROSSOVER,
"EMA Crossover",
"Strategia trend-following classica, ottimale su azioni con trend chiari.",
"📈", "#00E676",
isRecommended: true,
cfg =>
{
cfg.FastEmaPeriod = 9;
cfg.SlowEmaPeriod = 21;
cfg.CheckIntervalSeconds = 60;
cfg.StopLossPercentage = 0.02m;
cfg.TakeProfitPercentage = 0.04m;
}),
new StrategyProfile(
TradingStrategy.MACD,
"MACD",
"Momentum con istogramma MACD — eccellente su mid/large cap con trend.",
"⚡", "#EA80FC",
isRecommended: false,
cfg =>
{
cfg.MacdFastPeriod = 12;
cfg.MacdSlowPeriod = 26;
cfg.MacdSignalPeriod = 9;
cfg.CheckIntervalSeconds = 60;
cfg.StopLossPercentage = 0.02m;
cfg.TakeProfitPercentage = 0.04m;
}),
new StrategyProfile(
TradingStrategy.RSI,
"RSI Reversal",
"Ottimale per azioni in range o in correzione — compra ipervenduto.",
"📊", "#FFFF00",
isRecommended: false,
cfg =>
{
cfg.RsiPeriod = 14;
cfg.RsiOversoldThreshold = 30m;
cfg.RsiOverboughtThreshold = 70m;
cfg.CheckIntervalSeconds = 120;
cfg.StopLossPercentage = 0.02m;
cfg.TakeProfitPercentage = 0.035m;
}),
},
// ── ETF (mappato come us_equity con parametri diversi) ───────────
["etf"] = new[]
{
new StrategyProfile(
TradingStrategy.KALMAN_MEAN_REVERSION,
"Kalman Mean Reversion",
"ETF tendono a mean-revert: il filtro Kalman ne stima il fair value.",
"🔬", "#40C4FF",
isRecommended: true,
cfg =>
{
cfg.KalmanDelta = 1e-5;
cfg.KalmanObservationVariance = 0.8;
cfg.KalmanEntryZScore = 1.8;
cfg.KalmanExitZScore = 0.2;
cfg.CheckIntervalSeconds = 300;
cfg.StopLossPercentage = 0.015m;
cfg.TakeProfitPercentage = 0.03m;
}),
new StrategyProfile(
TradingStrategy.EMA_CROSSOVER,
"EMA Crossover",
"Trend-following conservativo su ETF ad alta liquidità.",
"📈", "#00E676",
isRecommended: false,
cfg =>
{
cfg.FastEmaPeriod = 12;
cfg.SlowEmaPeriod = 26;
cfg.CheckIntervalSeconds = 120;
cfg.StopLossPercentage = 0.015m;
cfg.TakeProfitPercentage = 0.03m;
}),
},
};
// ── Fallback: usa equity ─────────────────────────────────────────────
private static StrategyProfile[] GetProfiles(string assetClass)
{
if (string.IsNullOrEmpty(assetClass)) return _profiles["us_equity"];
var key = NormalizeClass(assetClass);
return _profiles.TryGetValue(key, out var p) ? p : _profiles["us_equity"];
}
/// <summary>Restituisce tutti i profili strategia disponibili per la classe dell'asset.</summary>
public static StrategyProfile[] GetAvailableProfiles(string assetClass)
=> GetProfiles(assetClass);
/// <summary>Restituisce il profilo raccomandato (primo marcato IsRecommended).</summary>
public static StrategyProfile GetRecommended(string assetClass)
{
var profiles = GetProfiles(assetClass);
foreach (var p in profiles)
if (p.IsRecommended) return p;
return profiles[0];
}
/// <summary>
/// Applica i parametri ottimali del profilo raccomandato alla configurazione.
/// Mantiene Symbol, Quantity e Name invariati.
/// </summary>
public static void ApplyOptimalConfig(BotConfiguration cfg, string assetClass)
{
var profile = GetRecommended(assetClass);
cfg.Strategy = profile.Strategy;
profile.ApplyParameters(cfg);
}
private static string NormalizeClass(string assetClass)
{
// Alpaca restituisce "us_equity", "crypto", ecc.
var lower = assetClass.ToLowerInvariant().Replace("-", "_");
if (lower.Contains("crypto")) return "crypto";
if (lower.Contains("etf")) return "etf";
return lower;
}
}
// ── StrategyProfile ──────────────────────────────────────────────────────
/// <summary>Descrive una strategia disponibile per una classe di asset, con i parametri pre-calibrati.</summary>
public sealed class StrategyProfile
{
public TradingStrategy Strategy { get; }
public string DisplayName { get; }
public string Description { get; }
public string Icon { get; }
public string AccentColor { get; }
public bool IsRecommended { get; }
private readonly Action<BotConfiguration> _applyParameters;
public StrategyProfile(
TradingStrategy strategy,
string displayName,
string description,
string icon,
string accentColor,
bool isRecommended,
Action<BotConfiguration> applyParameters)
{
Strategy = strategy;
DisplayName = displayName;
Description = description;
Icon = icon;
AccentColor = accentColor;
IsRecommended = isRecommended;
_applyParameters = applyParameters;
}
public void ApplyParameters(BotConfiguration cfg) => _applyParameters(cfg);
}
}