612 lines
28 KiB
C#
612 lines
28 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|