using System; using System.Collections.ObjectModel; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Input; using DesktopBot.Engine; using DesktopBot.Models; using DesktopBot.Services; namespace DesktopBot.ViewModels { public class BotInstanceViewModel : BaseViewModel { private readonly ITradingService _tradingService; private AutomatedBotEngine _engine; private bool _isRunning; private bool _isWaitingMarket; private string _statusMessage = "Inattivo"; private string _lastSignal = "---"; private decimal _lastPrice; public ObservableCollection BotLog { get; } = new ObservableCollection(); public ObservableCollection OpenPositions { get; } = new ObservableCollection(); public ObservableCollection TradeHistory { get; } = new ObservableCollection(); // CancellationToken per il polling periodico delle posizioni private CancellationTokenSource _positionPollCts; private void AppendLog(string message, Models.LogLevel level = Models.LogLevel.Info) { var entry = new BotLogEntry(level, message); var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher != null && !dispatcher.CheckAccess()) dispatcher.Invoke(() => { BotLog.Add(entry); TrimLog(); }); else { BotLog.Add(entry); TrimLog(); } } private void TrimLog() { int maxEntries = Model?.Config?.LoggingConfig?.MaxBotLogEntries ?? 5000; while (BotLog.Count > maxEntries) BotLog.RemoveAt(0); } public BotInstance Model { get; } public string BotId => Model.BotId; public string BadgeColor => Model.BadgeColor; public string Name { get => Model.Name; set { Model.Name = value; OnPropertyChanged(); OnPropertyChanged(nameof(DisplayTitle)); } } public string Symbol { get => Model.Symbol; set { // Impedisce la modifica del simbolo se il bot è bloccato if (Model.IsAssetLocked) { AppendLog($"⚠ Tentativo di modifica del simbolo ignorato: il bot è bloccato su {Model.Symbol}", Models.LogLevel.Warning); return; } Model.Symbol = value; Model.Config.Symbol = value; OnPropertyChanged(); OnPropertyChanged(nameof(DisplayTitle)); RefreshMarketStatus(); } } public string AssetName => Model.AssetName; public string AssetClass { get => Model.AssetClass; set { // Impedisce la modifica della classe se il bot è bloccato if (Model.IsAssetLocked) { AppendLog($"⚠ Tentativo di modifica della classe asset ignorato: il bot è bloccato", Models.LogLevel.Warning); return; } Model.AssetClass = value; OnPropertyChanged(); RefreshMarketStatus(); RefreshStrategyProfiles(); } } public bool IsEnabled { get => Model.IsEnabled; set { Model.IsEnabled = value; OnPropertyChanged(); } } public string Notes { get => Model.Notes; set { Model.Notes = value; OnPropertyChanged(); } } public BotConfiguration Config => Model.Config; /// /// Indica se il bot è bloccato a un asset specifico (immutabile). /// public bool IsAssetLocked => Model.IsAssetLocked; /// /// Timestamp del blocco dell'asset. /// public string LockedAtLabel => Model.LockedAt?.ToString("dd/MM/yyyy HH:mm") ?? "---"; /// /// Indica se la configurazione della strategia è bloccata. /// public bool IsConfigLocked => Model.Config.IsLocked; /// /// Aggiorna le proprietà relative al lock status. /// Chiamato dopo l'associazione dell'asset. /// public void RefreshLockStatus() { OnPropertyChanged(nameof(IsAssetLocked)); OnPropertyChanged(nameof(IsConfigLocked)); OnPropertyChanged(nameof(LockedAtLabel)); } public bool IsRunning { get => _isRunning; private set { SetProperty(ref _isRunning, value); OnPropertyChanged(nameof(StatusColor)); OnPropertyChanged(nameof(RunButtonLabel)); OnPropertyChanged(nameof(StatusBadge)); } } public bool IsWaitingMarket { get => _isWaitingMarket; private set { SetProperty(ref _isWaitingMarket, value); OnPropertyChanged(nameof(StatusColor)); OnPropertyChanged(nameof(StatusBadge)); } } public string StatusMessage { get => _statusMessage; set => SetProperty(ref _statusMessage, value); } public string LastSignal { get => _lastSignal; private set => SetProperty(ref _lastSignal, value); } public decimal LastPrice { get => _lastPrice; private set => SetProperty(ref _lastPrice, value); } public bool IsMarketOpen => MarketHoursService.IsMarketOpen(Model.AssetClass); public string MarketStatusLabel => MarketHoursService.GetMarketStatusLabel(Model.AssetClass); public string MarketStatusColor => MarketHoursService.GetMarketStatusColor(Model.AssetClass); private void RefreshMarketStatus() { OnPropertyChanged(nameof(IsMarketOpen)); OnPropertyChanged(nameof(MarketStatusLabel)); OnPropertyChanged(nameof(MarketStatusColor)); } public string StatusColor { get { if (!IsRunning) return "#555566"; if (IsWaitingMarket) return "#FFC107"; return "#00E676"; } } public string StatusBadge { get { if (!IsRunning) return "FERMO"; if (IsWaitingMarket) return "IN ATTESA"; return "ATTIVO"; } } public bool IsSelected { get => _isSelected; set => SetProperty(ref _isSelected, value); } private bool _isSelected; public string RunButtonLabel => IsRunning ? "STOP" : "START"; public string DisplayTitle => $"{Name} [{Symbol}]"; public string CreatedAtLabel => Model.CreatedAt.ToString("dd/MM/yyyy HH:mm"); public ObservableCollection StrategyProfiles { get; } = new ObservableCollection(); public bool IsUsingRecommendedStrategy => Model.Config.IsOptimizedForAsset; public string RecommendedStrategyLabel { get { if (!Model.Config.RecommendedStrategy.HasValue) return string.Empty; var profiles = StrategyAdvisor.GetAvailableProfiles(Model.AssetClass); foreach (var p in profiles) if (p.Strategy == Model.Config.RecommendedStrategy.Value) return $"{p.Icon} {p.DisplayName}"; return Model.Config.RecommendedStrategy.Value.ToString(); } } private void RefreshStrategyProfiles() { var profiles = StrategyAdvisor.GetAvailableProfiles(Model.AssetClass); StrategyProfiles.Clear(); foreach (var p in profiles) StrategyProfiles.Add(new StrategyProfileViewModel(p, this)); OnPropertyChanged(nameof(RecommendedStrategyLabel)); OnPropertyChanged(nameof(IsUsingRecommendedStrategy)); RefreshStrategySelection(); } public void RefreshStrategySelection() { foreach (var sp in StrategyProfiles) sp.RefreshIsActive(); OnPropertyChanged(nameof(IsUsingRecommendedStrategy)); OnPropertyChanged(nameof(StrategyDescription)); OnPropertyChanged(nameof(RecommendedStrategyLabel)); } internal void ApplyStrategyProfile(StrategyProfile profile) { // Impedisce il cambio di strategia se il bot è bloccato if (Model.Config.IsLocked) { AppendLog($"⚠ Tentativo di cambio strategia ignorato: la configurazione è bloccata su {Model.Config.Strategy}", Models.LogLevel.Warning); StatusMessage = $"❌ Impossibile cambiare strategia: bot bloccato su {Model.Config.Strategy}"; return; } profile.ApplyParameters(Model.Config); Model.Config.Strategy = profile.Strategy; if (profile.IsRecommended) Model.Config.RecommendedStrategy = profile.Strategy; RefreshStrategySelection(); OnPropertyChanged(nameof(Config)); } public string StrategyDescription { get { var c = Model.Config; switch (c.Strategy) { case TradingStrategy.EMA_CROSSOVER: return $"EMA CROSSOVER -- EMA veloce ({c.FastEmaPeriod} p.) vs EMA lenta ({c.SlowEmaPeriod} p.).\n" + $"ACQUISTO al crossover rialzista -- VENDITA al ribassista.\n" + $"SL: {c.StopLossPercentage * 100:N1}% x TP: {c.TakeProfitPercentage * 100:N1}% x Qty: {c.Quantity} x Ogni {c.CheckIntervalSeconds}s"; case TradingStrategy.RSI: return $"RSI ({c.RsiPeriod} p.) -- oscillatore di momentum 0-100.\n" + $"ACQUISTO: RSI < {c.RsiOversoldThreshold} x VENDITA: RSI > {c.RsiOverboughtThreshold}.\n" + $"SL: {c.StopLossPercentage * 100:N1}% x TP: {c.TakeProfitPercentage * 100:N1}% x Qty: {c.Quantity} x Ogni {c.CheckIntervalSeconds}s"; case TradingStrategy.MACD: return $"MACD -- EMA({c.MacdFastPeriod}) - EMA({c.MacdSlowPeriod}) con Signal EMA({c.MacdSignalPeriod}).\n" + $"ACQUISTO al crossover rialzista MACD > Signal.\n" + $"SL: {c.StopLossPercentage * 100:N1}% x TP: {c.TakeProfitPercentage * 100:N1}% x Qty: {c.Quantity} x Ogni {c.CheckIntervalSeconds}s"; case TradingStrategy.VOLATILITY_BREAKOUT: return $"VOLATILITY BREAKOUT -- Keltner EMA({c.KeltnerPeriod}) +/- {c.KeltnerMultiplier}xATR.\n" + $"Breakout: close > banda AND RVOL >= {c.RvolMinThreshold}x AND CVD slope > 0.\n" + $"SL = min barra - {c.AtrStopMultiplier}xATR x TP simmetrico x Qty: {c.Quantity} x Ogni {c.CheckIntervalSeconds}s"; case TradingStrategy.KALMAN_MEAN_REVERSION: return $"KALMAN MEAN REVERSION -- fair value dinamico via filtro di Kalman.\n" + $"ACQUISTO: Z-Score < -{c.KalmanEntryZScore:F1} x USCITA: |Z| < {c.KalmanExitZScore:F2}.\n" + $"delta={c.KalmanDelta:G3} x SL: {c.StopLossPercentage * 100:N1}% x TP: {c.TakeProfitPercentage * 100:N1}% x Qty: {c.Quantity} x Ogni {c.CheckIntervalSeconds}s"; default: return "Strategia non riconosciuta."; } } } public void RefreshStrategyDescription() => OnPropertyChanged(nameof(StrategyDescription)); public ICommand ToggleRunCommand { get; } public ICommand RemoveCommand { get; } public ICommand CopyLogCommand { get; } public event EventHandler RemoveRequested; public BotInstanceViewModel(BotInstance model, ITradingService tradingService) { Model = model ?? throw new ArgumentNullException(nameof(model)); _tradingService = tradingService ?? throw new ArgumentNullException(nameof(tradingService)); _engine = new AutomatedBotEngine(_tradingService); _engine.LogGenerated += (s, log) => { StatusMessage = $"[{log.Level}] {log.Message}"; AppendLog(log.Message, log.Level); }; _engine.SignalGenerated += (s, signal) => { var reason = signal.Reason ?? signal.Type.ToString(); LastSignal = reason; AppendLog($"SEGNALE: {reason}", Models.LogLevel.Success); // Dopo ogni segnale rilevante aggiorna le posizioni chiedendo conferma ad Alpaca if (signal.Type == SignalType.Buy || signal.Type == SignalType.Sell) _ = RefreshOpenPositionsAsync(signal); }; _engine.EquityUpdated += (s, eq) => { LastPrice = eq; AppendLog($"Equity: {eq:N2}", Models.LogLevel.Info); }; _engine.WaitingForMarketChanged += (s, waiting) => { IsWaitingMarket = waiting; AppendLog(waiting ? $"Mercato chiuso per {Model.Symbol} -- in attesa apertura" : $"Mercato aperto -- ripresa operativita su {Model.Symbol}", Models.LogLevel.Info); }; ToggleRunCommand = new RelayCommand(_ => _ = ToggleRunAsync()); RemoveCommand = new RelayCommand( _ => RemoveRequested?.Invoke(this, EventArgs.Empty), _ => !Model.IsAssetLocked); // il bot fisso BTC/USD non può essere rimosso CopyLogCommand = new RelayCommand(_ => { if (BotLog.Count == 0) return; var sb = new StringBuilder(); foreach (var entry in BotLog) sb.AppendLine($"[{entry.Timestamp:dd-MM-yyyy HH:mm:ss}] [{entry.Level}] {entry.Message}"); Clipboard.SetText(sb.ToString()); }); RefreshStrategyProfiles(); } private async Task ToggleRunAsync() { if (IsRunning) StopBot(); else await StartBotAsync(); } private async Task StartBotAsync() { if (string.IsNullOrWhiteSpace(Model.Symbol)) { StatusMessage = "Nessun asset associato. Seleziona un simbolo prima di avviare."; AppendLog("Avvio fallito: nessun asset associato.", Models.LogLevel.Error); return; } try { IsRunning = true; _engine.AssetClass = Model.AssetClass ?? "us_equity"; StatusMessage = $"Avvio... {Model.Symbol}"; AppendLog($"Avvio bot su {Model.Symbol} -- strategia: {Model.Config.Strategy}", Models.LogLevel.Info); // Prima di avviare: leggi le posizioni correnti da Alpaca await RefreshOpenPositionsAsync(); // Avvia polling periodico posizioni (ogni 30 secondi) _positionPollCts = new CancellationTokenSource(); _ = StartPositionPollingAsync(_positionPollCts.Token); await _engine.StartAsync(Model.Config); } catch (Exception ex) { StatusMessage = $"Errore: {ex.Message}"; AppendLog($"Errore durante l avvio: {ex.Message}", Models.LogLevel.Error); IsRunning = false; } } private void StopBot() { _engine.Stop(); _positionPollCts?.Cancel(); _positionPollCts = null; IsRunning = false; IsWaitingMarket = false; StatusMessage = "Fermato manualmente"; AppendLog("Bot fermato manualmente.", Models.LogLevel.Warning); // Refresh finale per mostrare lo stato reale dopo lo stop _ = RefreshOpenPositionsAsync(); } /// /// Interroga Alpaca per le posizioni aperte sul simbolo del bot e aggiorna OpenPositions. /// Se viene fornito un segnale di chiusura (Sell), registra il trade nello storico. /// Nessuno stato viene mantenuto in locale: la sorgente di verità è sempre Alpaca. /// private async Task RefreshOpenPositionsAsync(TradingSignal closedSignal = null) { try { var position = await _tradingService.GetPositionAsync(Model.Symbol); var d = System.Windows.Application.Current?.Dispatcher; void RunOnUi(Action a) { if (d != null && !d.CheckAccess()) d.Invoke(a); else a(); } RunOnUi(() => { if (position != null && Math.Abs(position.Quantity) > 0) { // Posizione aperta confermata da Alpaca var existing = OpenPositions.FirstOrDefault(p => p.Symbol == Model.Symbol); if (existing == null) { // Nuova posizione: aggiungila var record = new BotTradeRecord { Symbol = position.Symbol, Side = position.Quantity > 0 ? "BUY" : "SELL", EntryPrice = position.AverageEntryPrice, Quantity = Math.Abs(position.Quantity), OpenedAt = DateTime.Now }; OpenPositions.Insert(0, record); } else { // Aggiorna quantità e prezzo medio (possono cambiare) existing.Quantity = Math.Abs(position.Quantity); existing.EntryPrice = position.AverageEntryPrice; } } else { // Nessuna posizione aperta su Alpaca: rimuovila dalla lista // Se c'era una posizione aperta la registriamo come chiusa nello storico var wasOpen = OpenPositions.FirstOrDefault(p => p.Symbol == Model.Symbol); if (wasOpen != null) { OpenPositions.Remove(wasOpen); wasOpen.ClosedAt = DateTime.Now; wasOpen.ExitPrice = closedSignal?.Price > 0 ? closedSignal.Price : wasOpen.EntryPrice; wasOpen.PnL = wasOpen.Quantity > 0 ? (wasOpen.ExitPrice - wasOpen.EntryPrice) * wasOpen.Quantity : wasOpen.ExitPrice - wasOpen.EntryPrice; TradeHistory.Insert(0, wasOpen); int maxTradeEntries = Model?.Config?.LoggingConfig?.MaxTradeHistoryEntries ?? 2000; if (TradeHistory.Count > maxTradeEntries) TradeHistory.RemoveAt(TradeHistory.Count - 1); } } }); } catch (Exception ex) { AppendLog($"[Warn] Impossibile aggiornare posizioni da Alpaca: {ex.Message}", Models.LogLevel.Warning); } } /// /// Polling periodico: aggiorna le posizioni ogni 30 secondi durante l'esecuzione del bot. /// private async Task StartPositionPollingAsync(CancellationToken ct) { try { while (!ct.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false); if (!ct.IsCancellationRequested) await RefreshOpenPositionsAsync().ConfigureAwait(false); } } catch (TaskCanceledException) { /* atteso alla cancellazione */ } } public void ForceStop() { if (IsRunning) StopBot(); } public void StartBot() { if (!IsRunning) _ = StartBotAsync(); } } public class StrategyProfileViewModel : BaseViewModel { private readonly StrategyProfile _profile; private readonly BotInstanceViewModel _owner; public string Icon => _profile.Icon; public string DisplayName => _profile.DisplayName; public string Description => _profile.Description; public string AccentColor => _profile.AccentColor; public bool IsRecommended => _profile.IsRecommended; public TradingStrategy Strategy => _profile.Strategy; private bool _isActive; public bool IsActive { get => _isActive; private set => SetProperty(ref _isActive, value); } public ICommand SelectCommand { get; } public StrategyProfileViewModel(StrategyProfile profile, BotInstanceViewModel owner) { _profile = profile; _owner = owner; SelectCommand = new RelayCommand(_ => _owner.ApplyStrategyProfile(_profile)); RefreshIsActive(); } public void RefreshIsActive() => IsActive = _owner.Config.Strategy == _profile.Strategy; } }