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

549 lines
22 KiB
C#

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<BotLogEntry> BotLog { get; } = new ObservableCollection<BotLogEntry>();
public ObservableCollection<BotTradeRecord> OpenPositions { get; } = new ObservableCollection<BotTradeRecord>();
public ObservableCollection<BotTradeRecord> TradeHistory { get; } = new ObservableCollection<BotTradeRecord>();
// 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;
/// <summary>
/// Indica se il bot è bloccato a un asset specifico (immutabile).
/// </summary>
public bool IsAssetLocked => Model.IsAssetLocked;
/// <summary>
/// Timestamp del blocco dell'asset.
/// </summary>
public string LockedAtLabel => Model.LockedAt?.ToString("dd/MM/yyyy HH:mm") ?? "---";
/// <summary>
/// Indica se la configurazione della strategia è bloccata.
/// </summary>
public bool IsConfigLocked => Model.Config.IsLocked;
/// <summary>
/// Aggiorna le proprietà relative al lock status.
/// Chiamato dopo l'associazione dell'asset.
/// </summary>
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<StrategyProfileViewModel> StrategyProfiles { get; }
= new ObservableCollection<StrategyProfileViewModel>();
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();
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Polling periodico: aggiorna le posizioni ogni 30 secondi durante l'esecuzione del bot.
/// </summary>
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;
}
}