Sviluppo TradingBot
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using Alpaca.Markets;
|
||||
using DesktopBot.Services;
|
||||
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
// ─── BalanceViewModel ────────────────────────────────────────────────────────
|
||||
|
||||
public class BalanceViewModel : BaseViewModel
|
||||
{
|
||||
private readonly ITradingService _tradingService;
|
||||
private bool _isLoading;
|
||||
private string _errorMessage;
|
||||
private bool _hasError;
|
||||
|
||||
public bool IsLoading
|
||||
{
|
||||
get => _isLoading;
|
||||
set => SetProperty(ref _isLoading, value);
|
||||
}
|
||||
public string ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
set => SetProperty(ref _errorMessage, value);
|
||||
}
|
||||
public bool HasError
|
||||
{
|
||||
get => _hasError;
|
||||
set => SetProperty(ref _hasError, value);
|
||||
}
|
||||
|
||||
public ObservableCollection<BalanceRowViewModel> BalanceRows { get; }
|
||||
= new ObservableCollection<BalanceRowViewModel>();
|
||||
|
||||
public ICommand RefreshCommand { get; }
|
||||
|
||||
public BalanceViewModel(ITradingService tradingService)
|
||||
{
|
||||
_tradingService = tradingService;
|
||||
RefreshCommand = new RelayCommand(async _ => await LoadAsync());
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
HasError = false;
|
||||
try
|
||||
{
|
||||
var account = await _tradingService.GetAccountAsync();
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
BalanceRows.Clear();
|
||||
AddSection("Buying Power");
|
||||
AddRow("RegT Buying Power", account.RegulationBuyingPower, account.RegulationBuyingPower);
|
||||
AddRow("Day Trading Buying Power", account.DayTradingBuyingPower, account.DayTradingBuyingPower);
|
||||
AddRow("Effective Buying Power", account.BuyingPower, account.BuyingPower);
|
||||
AddRow("Non-Marginable Buying Power", account.NonMarginableBuyingPower, account.NonMarginableBuyingPower);
|
||||
AddSection("Margin");
|
||||
AddRow("Initial Margin", account.InitialMargin, account.InitialMargin);
|
||||
AddRow("Maintenance Margin", account.MaintenanceMargin, account.MaintenanceMargin);
|
||||
AddSection("Cash");
|
||||
AddRow("Cash", account.TradableCash, account.TradableCash);
|
||||
AddSection("Positions");
|
||||
AddRow("Equity", account.LastEquity, account.Equity ?? 0m, bold: true);
|
||||
AddRow("Long Market Value", account.LongMarketValue, account.LongMarketValue);
|
||||
AddRow("Short Market Value", account.ShortMarketValue, account.ShortMarketValue);
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
HasError = true;
|
||||
ErrorMessage = "Errore: " + ex.Message;
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => IsLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSection(string title)
|
||||
=> BalanceRows.Add(new BalanceRowViewModel { Label = title, IsSection = true });
|
||||
private void AddRow(string label, decimal? last, decimal? curr, bool bold = false)
|
||||
=> BalanceRows.Add(new BalanceRowViewModel
|
||||
{
|
||||
Label = label,
|
||||
LastClose = last.HasValue ? "$" + last.Value.ToString("N2") : "-",
|
||||
Current = curr.HasValue ? "$" + curr.Value.ToString("N2") : "-",
|
||||
IsBold = bold
|
||||
});
|
||||
private void AddRow(string label, decimal last, decimal curr, bool bold = false)
|
||||
=> BalanceRows.Add(new BalanceRowViewModel
|
||||
{
|
||||
Label = label,
|
||||
LastClose = "$" + last.ToString("N2"),
|
||||
Current = "$" + curr.ToString("N2"),
|
||||
IsBold = bold
|
||||
});
|
||||
}
|
||||
|
||||
// ─── PositionsViewModel ──────────────────────────────────────────────────────
|
||||
|
||||
public class PositionsViewModel : BaseViewModel
|
||||
{
|
||||
private readonly ITradingService _tradingService;
|
||||
private bool _isLoading;
|
||||
private string _errorMessage;
|
||||
private bool _hasError;
|
||||
|
||||
public bool IsLoading
|
||||
{
|
||||
get => _isLoading;
|
||||
set => SetProperty(ref _isLoading, value);
|
||||
}
|
||||
public string ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
set => SetProperty(ref _errorMessage, value);
|
||||
}
|
||||
public bool HasError
|
||||
{
|
||||
get => _hasError;
|
||||
set => SetProperty(ref _hasError, value);
|
||||
}
|
||||
|
||||
public ObservableCollection<PositionViewModel> Positions { get; }
|
||||
= new ObservableCollection<PositionViewModel>();
|
||||
|
||||
public ICommand RefreshCommand { get; }
|
||||
public ICommand CloseAllCommand { get; }
|
||||
public ICommand ClosePositionCommand { get; }
|
||||
|
||||
public PositionsViewModel(ITradingService tradingService)
|
||||
{
|
||||
_tradingService = tradingService;
|
||||
RefreshCommand = new RelayCommand(async _ => await LoadAsync());
|
||||
CloseAllCommand = new RelayCommand(async _ =>
|
||||
{
|
||||
await _tradingService.CloseAllPositionsAsync();
|
||||
await LoadAsync();
|
||||
});
|
||||
ClosePositionCommand = new RelayCommand(async p =>
|
||||
{
|
||||
if (p is string symbol)
|
||||
{
|
||||
await _tradingService.ClosePositionAsync(symbol);
|
||||
await LoadAsync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
HasError = false;
|
||||
try
|
||||
{
|
||||
var positions = await _tradingService.GetAllPositionsAsync();
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
Positions.Clear();
|
||||
foreach (var p in positions)
|
||||
Positions.Add(new PositionViewModel(p));
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
HasError = true;
|
||||
ErrorMessage = "Errore: " + ex.Message;
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => IsLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── OrdersViewModel ────────────────────────────────────────────────────────
|
||||
|
||||
public class OrdersViewModel : BaseViewModel
|
||||
{
|
||||
private readonly ITradingService _tradingService;
|
||||
private bool _isLoading;
|
||||
private string _errorMessage;
|
||||
private bool _hasError;
|
||||
|
||||
public bool IsLoading
|
||||
{
|
||||
get => _isLoading;
|
||||
set => SetProperty(ref _isLoading, value);
|
||||
}
|
||||
public string ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
set => SetProperty(ref _errorMessage, value);
|
||||
}
|
||||
public bool HasError
|
||||
{
|
||||
get => _hasError;
|
||||
set => SetProperty(ref _hasError, value);
|
||||
}
|
||||
|
||||
public ObservableCollection<OrderViewModel> Orders { get; }
|
||||
= new ObservableCollection<OrderViewModel>();
|
||||
|
||||
public ICommand RefreshCommand { get; }
|
||||
public ICommand CancelOrderCommand { get; }
|
||||
|
||||
public OrdersViewModel(ITradingService tradingService)
|
||||
{
|
||||
_tradingService = tradingService;
|
||||
RefreshCommand = new RelayCommand(async _ => await LoadAsync());
|
||||
CancelOrderCommand = new RelayCommand(async p =>
|
||||
{
|
||||
if (p is Guid id)
|
||||
{
|
||||
await _tradingService.CancelOrderAsync(id);
|
||||
await LoadAsync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
HasError = false;
|
||||
try
|
||||
{
|
||||
var orders = await _tradingService.GetOrdersAsync(OrderStatusFilter.All, 100);
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
Orders.Clear();
|
||||
foreach (var o in orders)
|
||||
Orders.Add(new OrderViewModel(o));
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
HasError = true;
|
||||
ErrorMessage = "Errore: " + ex.Message;
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => IsLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows.Input;
|
||||
using DesktopBot.Models;
|
||||
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// Base ViewModel con implementazione INotifyPropertyChanged
|
||||
/// </summary>
|
||||
public class BaseViewModel : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
|
||||
{
|
||||
if (Equals(field, value))
|
||||
return false;
|
||||
|
||||
field = value;
|
||||
OnPropertyChanged(propertyName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementazione semplice di ICommand
|
||||
/// </summary>
|
||||
public class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action<object> _execute;
|
||||
private readonly Func<object, bool> _canExecute;
|
||||
|
||||
public event EventHandler CanExecuteChanged
|
||||
{
|
||||
add => CommandManager.RequerySuggested += value;
|
||||
remove => CommandManager.RequerySuggested -= value;
|
||||
}
|
||||
|
||||
public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
|
||||
{
|
||||
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public bool CanExecute(object parameter) => _canExecute == null || _canExecute(parameter);
|
||||
|
||||
public void Execute(object parameter) => _execute(parameter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command senza parametri
|
||||
/// </summary>
|
||||
public class RelayCommand<T> : ICommand
|
||||
{
|
||||
private readonly Action<T> _execute;
|
||||
private readonly Func<T, bool> _canExecute;
|
||||
|
||||
public event EventHandler CanExecuteChanged
|
||||
{
|
||||
add => CommandManager.RequerySuggested += value;
|
||||
remove => CommandManager.RequerySuggested -= value;
|
||||
}
|
||||
|
||||
public RelayCommand(Action<T> execute, Func<T, bool> canExecute = null)
|
||||
{
|
||||
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public bool CanExecute(object parameter) => _canExecute == null || _canExecute((T)parameter);
|
||||
|
||||
public void Execute(object parameter) => _execute((T)parameter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Windows.Input;
|
||||
using DesktopBot.Engine;
|
||||
using DesktopBot.Models;
|
||||
using DesktopBot.Services;
|
||||
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// ViewModel per la configurazione del bot
|
||||
/// </summary>
|
||||
public class BotConfigViewModel : BaseViewModel
|
||||
{
|
||||
private readonly ITradingService _tradingService;
|
||||
private readonly AutomatedBotEngine _botEngine;
|
||||
|
||||
private BotConfiguration _config = new BotConfiguration();
|
||||
private bool _isRunning;
|
||||
private string _statusMessage;
|
||||
|
||||
public BotConfiguration Config
|
||||
{
|
||||
get => _config;
|
||||
set => SetProperty(ref _config, value);
|
||||
}
|
||||
|
||||
public bool IsRunning
|
||||
{
|
||||
get => _isRunning;
|
||||
set => SetProperty(ref _isRunning, value);
|
||||
}
|
||||
|
||||
public string StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
set => SetProperty(ref _statusMessage, value);
|
||||
}
|
||||
|
||||
public ICommand StartBotCommand { get; }
|
||||
public ICommand StopBotCommand { get; }
|
||||
|
||||
public BotConfigViewModel(ITradingService tradingService, AutomatedBotEngine botEngine)
|
||||
{
|
||||
_tradingService = tradingService;
|
||||
_botEngine = botEngine;
|
||||
|
||||
StartBotCommand = new RelayCommand(
|
||||
async _ => await StartBotAsync(),
|
||||
_ => !IsRunning
|
||||
);
|
||||
|
||||
StopBotCommand = new RelayCommand(
|
||||
_ => StopBot(),
|
||||
_ => IsRunning
|
||||
);
|
||||
}
|
||||
|
||||
public void LoadConfiguration()
|
||||
{
|
||||
Config = new BotConfiguration();
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task StartBotAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
IsRunning = true;
|
||||
StatusMessage = $"Bot avviato - {Config.Symbol} - Strategia: {Config.Strategy}";
|
||||
await _botEngine.StartAsync(Config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Errore: {ex.Message}";
|
||||
IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void StopBot()
|
||||
{
|
||||
_botEngine.Stop();
|
||||
IsRunning = false;
|
||||
StatusMessage = "Bot arrestato";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Alpaca.Markets;
|
||||
using DesktopBot.Engine;
|
||||
using DesktopBot.Models;
|
||||
using DesktopBot.Services;
|
||||
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// ViewModel del pannello Bot Manager.
|
||||
/// Contiene un unico bot BTC/USD fisso, precaricato all'avvio.
|
||||
/// L'utente può soltanto avviarlo e fermarlo.
|
||||
/// </summary>
|
||||
public class BotsManagerViewModel : BaseViewModel
|
||||
{
|
||||
private readonly ITradingService _tradingService;
|
||||
|
||||
// ── Collezione (sempre 1 elemento: il bot BTC/USD fisso) ─────────────
|
||||
public ObservableCollection<BotInstanceViewModel> Bots { get; }
|
||||
= new ObservableCollection<BotInstanceViewModel>();
|
||||
|
||||
/// <summary>Shortcut diretto al bot fisso BTC/USD.</summary>
|
||||
public BotInstanceViewModel BtcBot => Bots.FirstOrDefault();
|
||||
|
||||
// ── Grafico prezzi BTC/USD ─────────────────────────────────────────
|
||||
public PriceChartViewModel ChartVM { get; }
|
||||
|
||||
// ── Stato UI ─────────────────────────────────────────────────────────
|
||||
private string _statusMessage;
|
||||
public string StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
set => SetProperty(ref _statusMessage, value);
|
||||
}
|
||||
|
||||
// ── Comandi ──────────────────────────────────────────────────────────
|
||||
public ICommand StartBotCommand { get; }
|
||||
public ICommand StopBotCommand { get; }
|
||||
public ICommand SaveAllCommand { get; }
|
||||
|
||||
// ── Ctor ─────────────────────────────────────────────────────────────
|
||||
public BotsManagerViewModel(ITradingService tradingService)
|
||||
{
|
||||
_tradingService = tradingService ?? throw new ArgumentNullException(nameof(tradingService));
|
||||
|
||||
ChartVM = new PriceChartViewModel(tradingService);
|
||||
|
||||
StartBotCommand = new RelayCommand(_ => BtcBot?.StartBot(), _ => BtcBot != null && !BtcBot.IsRunning);
|
||||
StopBotCommand = new RelayCommand(_ => BtcBot?.ForceStop(), _ => BtcBot != null && BtcBot.IsRunning);
|
||||
SaveAllCommand = new RelayCommand(_ => SaveAll());
|
||||
|
||||
EnsureBtcBotExists();
|
||||
|
||||
// Avvia streaming grafico in background
|
||||
_ = ChartVM.StartStreamingAsync();
|
||||
}
|
||||
|
||||
// ── Preload bot BTC/USD fisso ─────────────────────────────────────────
|
||||
private void EnsureBtcBotExists()
|
||||
{
|
||||
// Tenta di caricare da disco
|
||||
var saved = BotInstanceStore.Load();
|
||||
var btcModel = saved.FirstOrDefault(m =>
|
||||
string.Equals(m.Symbol, "BTCUSD", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (btcModel == null)
|
||||
{
|
||||
// Prima esecuzione: crea il bot BTC/USD con parametri ottimali
|
||||
btcModel = CreateBtcUsdBot();
|
||||
BotInstanceStore.Save(new[] { btcModel });
|
||||
}
|
||||
|
||||
var vm = new BotInstanceViewModel(btcModel, _tradingService);
|
||||
// Il bot fisso non può essere rimosso — ignoriamo RemoveRequested
|
||||
Bots.Add(vm);
|
||||
StatusMessage = "Bot BTC/USD caricato — pronto all'avvio.";
|
||||
}
|
||||
|
||||
private static BotInstance CreateBtcUsdBot()
|
||||
{
|
||||
var config = new BotConfiguration
|
||||
{
|
||||
Symbol = "BTCUSD",
|
||||
Quantity = 1,
|
||||
CheckIntervalSeconds = 60,
|
||||
AnalysisTimeFrame = BarTimeFrame.Minute,
|
||||
HistoricalBarCount = 200,
|
||||
|
||||
// EMA ottimizzate per 1min BTC
|
||||
FastEmaPeriod = 5,
|
||||
SlowEmaPeriod = 20,
|
||||
|
||||
// RSI
|
||||
RsiPeriod = 14,
|
||||
RsiOversoldThreshold = 30,
|
||||
RsiOverboughtThreshold = 70,
|
||||
|
||||
// MACD
|
||||
MacdFastPeriod = 8,
|
||||
MacdSlowPeriod = 21,
|
||||
MacdSignalPeriod = 5,
|
||||
|
||||
// Keltner / RVOL
|
||||
KeltnerPeriod = 20,
|
||||
KeltnerMultiplier = 2.0m,
|
||||
RvolMinThreshold = 2.0m,
|
||||
AtrStopMultiplier = 1.5m,
|
||||
|
||||
// Kalman
|
||||
KalmanDelta = 5e-6,
|
||||
KalmanObservationVariance = 1.0,
|
||||
KalmanEntryZScore = 1.8,
|
||||
KalmanExitZScore = 0.3,
|
||||
|
||||
// Risk
|
||||
StopLossPercentage = 0.02m,
|
||||
TakeProfitPercentage = 0.04m,
|
||||
MaxPositionSizePercent = 0.10m,
|
||||
|
||||
// Algoritmo BTC/USD avanzato (dispatch automatico nell'engine)
|
||||
Strategy = TradingStrategy.VOLATILITY_BREAKOUT,
|
||||
RecommendedStrategy = TradingStrategy.VOLATILITY_BREAKOUT,
|
||||
IsLocked = true,
|
||||
LockedAt = DateTime.Now
|
||||
};
|
||||
|
||||
var model = new BotInstance
|
||||
{
|
||||
Name = "BTC/USD — Algoritmo Avanzato",
|
||||
BadgeColor = "#F7931A", // arancione Bitcoin
|
||||
Config = config,
|
||||
Notes = "Bot predefinito BTC/USD. Algoritmo multi-segnale: Regime Detector + Kalman + ATR Sizing.",
|
||||
CreatedAt = DateTime.Now,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
// Blocca asset e config (bot fisso)
|
||||
model.Symbol = "BTCUSD";
|
||||
model.AssetName = "Bitcoin / US Dollar";
|
||||
model.AssetClass = "crypto";
|
||||
model.LockToAsset();
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
// ── Persistenza ──────────────────────────────────────────────────────
|
||||
public void SaveAll()
|
||||
{
|
||||
BotInstanceStore.Save(Bots.Select(vm => vm.Model));
|
||||
StatusMessage = $"Salvato — {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
|
||||
// ── Start / Stop esposto ai comandi ───────────────────────────────────
|
||||
private async Task StartAllAsync()
|
||||
{
|
||||
if (BtcBot != null && !BtcBot.IsRunning)
|
||||
BtcBot.StartBot();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void StopAll()
|
||||
{
|
||||
if (BtcBot?.IsRunning == true)
|
||||
BtcBot.ForceStop();
|
||||
StatusMessage = "Bot fermato.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using Alpaca.Markets;
|
||||
using DesktopBot.Engine;
|
||||
using DesktopBot.Services;
|
||||
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
/// <summary>Voce del log attività mostrata nella dashboard.</summary>
|
||||
public class ActivityLogEntry
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string Level { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string LevelColor { get; set; }
|
||||
public string LevelBg { get; set; }
|
||||
|
||||
public static ActivityLogEntry Create(string level, string message)
|
||||
{
|
||||
string color, bg;
|
||||
switch (level.ToUpperInvariant())
|
||||
{
|
||||
case "SUCCESS": color = "#00E676"; bg = "#1A00E676"; break;
|
||||
case "ERROR": color = "#FF1744"; bg = "#1AFF1744"; break;
|
||||
case "WARNING": color = "#FFC107"; bg = "#1AFFC107"; break;
|
||||
case "BUY": color = "#00E676"; bg = "#1A003A00"; break;
|
||||
case "SELL": color = "#FF1744"; bg = "#1A3A0000"; break;
|
||||
default: color = "#9E9EAF"; bg = "#1A3A3A4A"; break;
|
||||
}
|
||||
return new ActivityLogEntry
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
Level = level.ToUpperInvariant(),
|
||||
Message = message,
|
||||
LevelColor = color,
|
||||
LevelBg = bg
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class BalanceRowViewModel : BaseViewModel
|
||||
{
|
||||
public string Label { get; set; }
|
||||
public string LastClose { get; set; }
|
||||
public string Current { get; set; }
|
||||
public bool IsBold { get; set; }
|
||||
public bool IsSection { get; set; }
|
||||
}
|
||||
|
||||
public class OrderViewModel : BaseViewModel
|
||||
{
|
||||
public Guid OrderId { get; }
|
||||
public string Symbol { get; }
|
||||
public string OrderType { get; }
|
||||
public string Side { get; }
|
||||
public decimal Qty { get; }
|
||||
public decimal FilledQty { get; }
|
||||
public string AvgFillPrice { get; }
|
||||
public string Status { get; }
|
||||
public string SubmittedAt { get; }
|
||||
public string FilledAt { get; }
|
||||
public string LimitPrice { get; }
|
||||
public string StopPrice { get; }
|
||||
public bool IsOpen { get; }
|
||||
public string SideBadgeColor { get; }
|
||||
|
||||
public OrderViewModel(IOrder order)
|
||||
{
|
||||
OrderId = order.OrderId;
|
||||
Symbol = order.Symbol;
|
||||
OrderType = order.OrderType.ToString();
|
||||
Side = order.OrderSide.ToString().ToUpperInvariant();
|
||||
Qty = order.Quantity ?? 0m;
|
||||
FilledQty = order.FilledQuantity;
|
||||
AvgFillPrice = order.AverageFillPrice.HasValue ? "$" + order.AverageFillPrice.Value.ToString("N2") : "-";
|
||||
Status = order.OrderStatus.ToString();
|
||||
SubmittedAt = order.SubmittedAtUtc.HasValue ? order.SubmittedAtUtc.Value.ToLocalTime().ToString("dd/MM HH:mm:ss") : "-";
|
||||
FilledAt = order.FilledAtUtc.HasValue ? order.FilledAtUtc.Value.ToLocalTime().ToString("dd/MM HH:mm:ss") : "-";
|
||||
LimitPrice = order.LimitPrice.HasValue ? "$" + order.LimitPrice.Value.ToString("N2") : "-";
|
||||
StopPrice = order.StopPrice.HasValue ? "$" + order.StopPrice.Value.ToString("N2") : "-";
|
||||
IsOpen = order.OrderStatus == OrderStatus.New
|
||||
|| order.OrderStatus == OrderStatus.PartiallyFilled
|
||||
|| order.OrderStatus == OrderStatus.PendingNew
|
||||
|| order.OrderStatus == OrderStatus.Accepted;
|
||||
SideBadgeColor = order.OrderSide == OrderSide.Buy ? "#1A3A1A" : "#3A1A1A";
|
||||
}
|
||||
}
|
||||
|
||||
public class DashboardViewModel : BaseViewModel
|
||||
{
|
||||
private readonly ITradingService _tradingService;
|
||||
private readonly AutomatedBotEngine _botEngine;
|
||||
|
||||
private decimal _equity;
|
||||
private decimal _buyingPower;
|
||||
private decimal _dailyPnL;
|
||||
private decimal _dailyPnLPercent;
|
||||
private int _openPositionsCount;
|
||||
private int _openOrdersCount;
|
||||
private string _botStatus;
|
||||
private bool _isBotRunning;
|
||||
private bool _isLoading;
|
||||
private string _accountStatus;
|
||||
private bool _isPaperTrading;
|
||||
private string _accountNumber;
|
||||
private string _currency;
|
||||
private string _errorMessage;
|
||||
private bool _hasError;
|
||||
private int _selectedTabIndex;
|
||||
|
||||
public int SelectedTabIndex
|
||||
{
|
||||
get => _selectedTabIndex;
|
||||
set => SetProperty(ref _selectedTabIndex, value);
|
||||
}
|
||||
public decimal Equity
|
||||
{
|
||||
get => _equity;
|
||||
set => SetProperty(ref _equity, value);
|
||||
}
|
||||
public decimal BuyingPower
|
||||
{
|
||||
get => _buyingPower;
|
||||
set => SetProperty(ref _buyingPower, value);
|
||||
}
|
||||
public decimal DailyPnL
|
||||
{
|
||||
get => _dailyPnL;
|
||||
set { if (SetProperty(ref _dailyPnL, value)) OnPropertyChanged(nameof(IsPnLPositive)); }
|
||||
}
|
||||
public decimal DailyPnLPercent
|
||||
{
|
||||
get => _dailyPnLPercent;
|
||||
set => SetProperty(ref _dailyPnLPercent, value);
|
||||
}
|
||||
public bool IsPnLPositive => DailyPnL >= 0;
|
||||
public int OpenPositionsCount
|
||||
{
|
||||
get => _openPositionsCount;
|
||||
set => SetProperty(ref _openPositionsCount, value);
|
||||
}
|
||||
public int OpenOrdersCount
|
||||
{
|
||||
get => _openOrdersCount;
|
||||
set => SetProperty(ref _openOrdersCount, value);
|
||||
}
|
||||
public string BotStatus
|
||||
{
|
||||
get => _botStatus;
|
||||
set => SetProperty(ref _botStatus, value);
|
||||
}
|
||||
public bool IsBotRunning
|
||||
{
|
||||
get => _isBotRunning;
|
||||
set => SetProperty(ref _isBotRunning, value);
|
||||
}
|
||||
public bool IsLoading
|
||||
{
|
||||
get => _isLoading;
|
||||
set => SetProperty(ref _isLoading, value);
|
||||
}
|
||||
public string AccountStatus
|
||||
{
|
||||
get => _accountStatus;
|
||||
set => SetProperty(ref _accountStatus, value);
|
||||
}
|
||||
public bool IsPaperTrading
|
||||
{
|
||||
get => _isPaperTrading;
|
||||
set => SetProperty(ref _isPaperTrading, value);
|
||||
}
|
||||
public string AccountNumber
|
||||
{
|
||||
get => _accountNumber;
|
||||
set => SetProperty(ref _accountNumber, value);
|
||||
}
|
||||
public string Currency
|
||||
{
|
||||
get => _currency;
|
||||
set => SetProperty(ref _currency, value);
|
||||
}
|
||||
public string ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
set => SetProperty(ref _errorMessage, value);
|
||||
}
|
||||
public bool HasError
|
||||
{
|
||||
get => _hasError;
|
||||
set => SetProperty(ref _hasError, value);
|
||||
}
|
||||
|
||||
public ObservableCollection<BalanceRowViewModel> BalanceRows { get; } = new ObservableCollection<BalanceRowViewModel>();
|
||||
public ObservableCollection<PositionViewModel> Positions { get; } = new ObservableCollection<PositionViewModel>();
|
||||
public ObservableCollection<OrderViewModel> Orders { get; } = new ObservableCollection<OrderViewModel>();
|
||||
public ObservableCollection<ActivityLogEntry> ActivityLog { get; } = new ObservableCollection<ActivityLogEntry>();
|
||||
|
||||
/// <summary>
|
||||
/// Statistiche e rate-limiter per le chiamate API Alpaca.
|
||||
/// Disponibile per il binding in dashboard (null se il servizio non è ancora inizializzato).
|
||||
/// </summary>
|
||||
public ApiCallCounterService ApiCounter =>
|
||||
(_tradingService as AlpacaTradingService)?.ApiCounter;
|
||||
|
||||
public System.Windows.Input.ICommand RefreshCommand { get; }
|
||||
public System.Windows.Input.ICommand CancelOrderCommand { get; }
|
||||
public System.Windows.Input.ICommand CloseAllCommand { get; }
|
||||
public System.Windows.Input.ICommand CopyActivityLogCommand { get; }
|
||||
|
||||
public DashboardViewModel(ITradingService tradingService, AutomatedBotEngine botEngine)
|
||||
{
|
||||
_tradingService = tradingService;
|
||||
_botEngine = botEngine;
|
||||
BotStatus = "In attesa";
|
||||
AccountStatus = "--";
|
||||
|
||||
RefreshCommand = new RelayCommand(async _ => await LoadAsync());
|
||||
CancelOrderCommand = new RelayCommand(async p =>
|
||||
{
|
||||
if (p is Guid id) await CancelOrderAsync(id);
|
||||
});
|
||||
CloseAllCommand = new RelayCommand(async _ =>
|
||||
{
|
||||
await _tradingService.CloseAllPositionsAsync();
|
||||
await LoadAsync();
|
||||
});
|
||||
CopyActivityLogCommand = new RelayCommand(_ =>
|
||||
{
|
||||
if (ActivityLog.Count == 0) return;
|
||||
var sb = new StringBuilder();
|
||||
foreach (var entry in ActivityLog.Reverse())
|
||||
sb.AppendLine(string.Format("[{0:HH:mm:ss dd/MM/yy}] [{1}] {2}",
|
||||
entry.Timestamp, entry.Level, entry.Message));
|
||||
Clipboard.SetText(sb.ToString());
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
HasError = false;
|
||||
try
|
||||
{
|
||||
var accountTask = _tradingService.GetAccountAsync();
|
||||
var positionsTask = _tradingService.GetAllPositionsAsync();
|
||||
var ordersTask = _tradingService.GetOrdersAsync(OrderStatusFilter.All, 100);
|
||||
|
||||
await Task.WhenAll(accountTask, positionsTask, ordersTask);
|
||||
|
||||
var account = accountTask.Result;
|
||||
var positions = positionsTask.Result;
|
||||
var orders = ordersTask.Result;
|
||||
|
||||
var equity = account.Equity ?? 0m;
|
||||
var lastEquity = account.LastEquity;
|
||||
var pnl = equity - lastEquity;
|
||||
var pnlPct = lastEquity > 0 ? (pnl / lastEquity) * 100m : 0m;
|
||||
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
Equity = equity;
|
||||
BuyingPower = account.BuyingPower ?? 0m;
|
||||
DailyPnL = pnl;
|
||||
DailyPnLPercent = pnlPct;
|
||||
OpenPositionsCount = positions.Count;
|
||||
AccountStatus = account.IsTradingBlocked ? "BLOCCATO" : "ATTIVO";
|
||||
AccountNumber = account.AccountNumber;
|
||||
Currency = account.Currency;
|
||||
|
||||
BalanceRows.Clear();
|
||||
AddSection("Buying Power");
|
||||
AddRow("RegT Buying Power", account.RegulationBuyingPower, account.RegulationBuyingPower);
|
||||
AddRow("Day Trading Buying Power", account.DayTradingBuyingPower, account.DayTradingBuyingPower);
|
||||
AddRow("Effective Buying Power", account.BuyingPower, account.BuyingPower);
|
||||
AddRow("Non-Marginable Buying Power", account.NonMarginableBuyingPower, account.NonMarginableBuyingPower);
|
||||
AddSection("Margin");
|
||||
AddRow("Initial Margin", account.InitialMargin, account.InitialMargin);
|
||||
AddRow("Maintenance Margin", account.MaintenanceMargin, account.MaintenanceMargin);
|
||||
AddSection("Cash");
|
||||
AddRow("Cash", account.TradableCash, account.TradableCash);
|
||||
AddSection("Positions");
|
||||
AddRow("Equity", lastEquity, equity, bold: true);
|
||||
AddRow("Long Market Value", account.LongMarketValue, account.LongMarketValue);
|
||||
AddRow("Short Market Value", account.ShortMarketValue, account.ShortMarketValue);
|
||||
|
||||
Positions.Clear();
|
||||
foreach (var p in positions)
|
||||
Positions.Add(new PositionViewModel(p));
|
||||
|
||||
Orders.Clear();
|
||||
int openCount = 0;
|
||||
foreach (var o in orders)
|
||||
{
|
||||
var ovm = new OrderViewModel(o);
|
||||
Orders.Add(ovm);
|
||||
if (ovm.IsOpen) openCount++;
|
||||
}
|
||||
OpenOrdersCount = openCount;
|
||||
AddActivityLog("SUCCESS", $"Dati aggiornati — Equity: ${equity:N2} | Posizioni: {positions.Count} | Ordini aperti: {openCount}");
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
HasError = true;
|
||||
ErrorMessage = "Errore: " + ex.Message;
|
||||
AddActivityLog("ERROR", ex.Message);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => IsLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateEquity(decimal equity)
|
||||
{
|
||||
Equity = equity;
|
||||
AddActivityLog("INFO", $"Equity aggiornata: ${equity:N2}");
|
||||
}
|
||||
|
||||
/// <summary>Aggiunge una voce al log attività (thread-safe via Dispatcher, limiti parametrizzati).</summary>
|
||||
public void AddActivityLog(string level, string message)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
ActivityLog.Insert(0, ActivityLogEntry.Create(level, message));
|
||||
// Recupera il limite dalla configurazione del bot, fallback a 5000
|
||||
int maxActivityEntries = _botEngine?.Model?.Config?.LoggingConfig?.MaxActivityLogEntries ?? 5000;
|
||||
while (ActivityLog.Count > maxActivityEntries)
|
||||
ActivityLog.RemoveAt(ActivityLog.Count - 1);
|
||||
});
|
||||
}
|
||||
|
||||
private void AddSection(string title)
|
||||
=> BalanceRows.Add(new BalanceRowViewModel { Label = title, IsSection = true });
|
||||
|
||||
private void AddRow(string label, decimal? last, decimal? curr, bool bold = false)
|
||||
=> BalanceRows.Add(new BalanceRowViewModel
|
||||
{
|
||||
Label = label,
|
||||
LastClose = last.HasValue ? "$" + last.Value.ToString("N2") : "-",
|
||||
Current = curr.HasValue ? "$" + curr.Value.ToString("N2") : "-",
|
||||
IsBold = bold
|
||||
});
|
||||
|
||||
private void AddRow(string label, decimal last, decimal curr, bool bold = false)
|
||||
=> BalanceRows.Add(new BalanceRowViewModel
|
||||
{
|
||||
Label = label,
|
||||
LastClose = "$" + last.ToString("N2"),
|
||||
Current = "$" + curr.ToString("N2"),
|
||||
IsBold = bold
|
||||
});
|
||||
|
||||
private async Task CancelOrderAsync(Guid orderId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _tradingService.CancelOrderAsync(orderId);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
HasError = true;
|
||||
ErrorMessage = "Errore cancellazione: " + ex.Message;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using DesktopBot.Engine;
|
||||
using DesktopBot.Models;
|
||||
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// ViewModel per il Live Log
|
||||
/// </summary>
|
||||
public class LiveLogViewModel : BaseViewModel
|
||||
{
|
||||
private ObservableCollection<BotLogEntry> _logs = new ObservableCollection<BotLogEntry>();
|
||||
|
||||
public ObservableCollection<BotLogEntry> Logs
|
||||
{
|
||||
get => _logs;
|
||||
set => SetProperty(ref _logs, value);
|
||||
}
|
||||
|
||||
public ICommand ClearLogsCommand { get; }
|
||||
public ICommand CopyLogsCommand { get; }
|
||||
|
||||
public LiveLogViewModel(AutomatedBotEngine botEngine)
|
||||
{
|
||||
ClearLogsCommand = new RelayCommand(_ => Logs.Clear());
|
||||
CopyLogsCommand = new RelayCommand(_ =>
|
||||
{
|
||||
if (Logs.Count == 0) return;
|
||||
var sb = new StringBuilder();
|
||||
// I log sono inseriti in testa (più recente prima): li copiamo in ordine cronologico
|
||||
foreach (var entry in Logs.Reverse())
|
||||
sb.AppendLine(string.Format("[{0:HH:mm:ss dd/MM}] [{1}] {2}",
|
||||
entry.Timestamp, entry.Level, entry.Message));
|
||||
Clipboard.SetText(sb.ToString());
|
||||
});
|
||||
AddLog(new BotLogEntry(LogLevel.Info, "Sistema avviato - in attesa di configurazione"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiunge un log entry in cima alla lista, sul UI thread (limiti parametrizzati)
|
||||
/// </summary>
|
||||
public void AddLog(BotLogEntry logEntry)
|
||||
{
|
||||
Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
Logs.Insert(0, logEntry);
|
||||
// Limite massimo per il live log (default 10000)
|
||||
int maxLiveLogEntries = 10000;
|
||||
while (Logs.Count > maxLiveLogEntries)
|
||||
Logs.RemoveAt(Logs.Count - 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Windows.Input;
|
||||
using DesktopBot.Models;
|
||||
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// ViewModel per la configurazione dei limiti di logging.
|
||||
/// </summary>
|
||||
public class LoggingSettingsViewModel : BaseViewModel
|
||||
{
|
||||
private LoggingConfiguration _config;
|
||||
|
||||
public LoggingSettingsViewModel(LoggingConfiguration config)
|
||||
{
|
||||
_config = config?.Clone() ?? new LoggingConfiguration();
|
||||
}
|
||||
|
||||
/// <summary>Numero massimo di elementi nel log del bot.</summary>
|
||||
public int MaxBotLogEntries
|
||||
{
|
||||
get => _config.MaxBotLogEntries;
|
||||
set { _config.MaxBotLogEntries = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
/// <summary>Numero massimo di elementi nello storico trade.</summary>
|
||||
public int MaxTradeHistoryEntries
|
||||
{
|
||||
get => _config.MaxTradeHistoryEntries;
|
||||
set { _config.MaxTradeHistoryEntries = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
/// <summary>Numero massimo di elementi nel log attività della dashboard.</summary>
|
||||
public int MaxActivityLogEntries
|
||||
{
|
||||
get => _config.MaxActivityLogEntries;
|
||||
set { _config.MaxActivityLogEntries = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
/// <summary>Numero massimo di elementi nel log live globale.</summary>
|
||||
public int MaxLiveLogEntries
|
||||
{
|
||||
get => _config.MaxLiveLogEntries;
|
||||
set { _config.MaxLiveLogEntries = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
/// <summary>Numero massimo di punti dati nel grafico dei prezzi.</summary>
|
||||
public int MaxPriceDataPoints
|
||||
{
|
||||
get => _config.MaxPriceDataPoints;
|
||||
set { _config.MaxPriceDataPoints = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
/// <summary>Restituisce la configurazione modificata.</summary>
|
||||
public LoggingConfiguration GetUpdatedConfig() => _config.Clone();
|
||||
|
||||
/// <summary>Ripristina i valori di default.</summary>
|
||||
public void ResetToDefaults()
|
||||
{
|
||||
var defaults = new LoggingConfiguration();
|
||||
MaxBotLogEntries = defaults.MaxBotLogEntries;
|
||||
MaxTradeHistoryEntries = defaults.MaxTradeHistoryEntries;
|
||||
MaxActivityLogEntries = defaults.MaxActivityLogEntries;
|
||||
MaxLiveLogEntries = defaults.MaxLiveLogEntries;
|
||||
MaxPriceDataPoints = defaults.MaxPriceDataPoints;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using DesktopBot.Engine;
|
||||
using DesktopBot.Models;
|
||||
using DesktopBot.Services;
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
public class MainViewModel : BaseViewModel
|
||||
{
|
||||
private readonly ITradingService _tradingService;
|
||||
private readonly AutomatedBotEngine _botEngine;
|
||||
private readonly AlpacaPingService _pingService;
|
||||
|
||||
public PingViewModel PingVM { get; } = new PingViewModel();
|
||||
|
||||
// ── Auto-refresh periodico ─────────────────────────────────────────
|
||||
/// <summary>Intervallo (secondi) per l'aggiornamento frequente: posizioni e ordini.</summary>
|
||||
private const int FastRefreshSeconds = 30;
|
||||
/// <summary>Intervallo (secondi) per l'aggiornamento lento: balance, wallet, dashboard.</summary>
|
||||
private const int SlowRefreshSeconds = 60;
|
||||
|
||||
private CancellationTokenSource _autoRefreshCts;
|
||||
private int _refreshCycle; // contatore cicli per sfalsare il refresh lento
|
||||
|
||||
private BaseViewModel _currentViewModel;
|
||||
private string _selectedTab;
|
||||
|
||||
public DashboardViewModel DashboardVM { get; }
|
||||
public BotsManagerViewModel BotsVM { get; }
|
||||
public LiveLogViewModel LiveLogVM { get; }
|
||||
public WalletViewModel WalletVM { get; }
|
||||
public SettingsViewModel SettingsVM { get; }
|
||||
public BalanceViewModel BalanceVM { get; }
|
||||
public PositionsViewModel PositionsVM { get; }
|
||||
public OrdersViewModel OrdersVM { get; }
|
||||
|
||||
public BaseViewModel CurrentViewModel
|
||||
{
|
||||
get => _currentViewModel;
|
||||
set => SetProperty(ref _currentViewModel, value);
|
||||
}
|
||||
|
||||
public string SelectedTab
|
||||
{
|
||||
get => _selectedTab;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _selectedTab, value))
|
||||
NavigateToTab(value);
|
||||
}
|
||||
}
|
||||
|
||||
public ICommand NavigateCommand { get; }
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
_tradingService = new AlpacaTradingService();
|
||||
_botEngine = new AutomatedBotEngine(_tradingService);
|
||||
_pingService = new AlpacaPingService();
|
||||
_pingService.PingCompleted += (_, r) => PingVM.Update(r);
|
||||
|
||||
DashboardVM = new DashboardViewModel(_tradingService, _botEngine);
|
||||
BotsVM = new BotsManagerViewModel(_tradingService);
|
||||
LiveLogVM = new LiveLogViewModel(_botEngine);
|
||||
WalletVM = new WalletViewModel(_tradingService);
|
||||
SettingsVM = new SettingsViewModel(_tradingService, OnCredentialsSaved);
|
||||
BalanceVM = new BalanceViewModel(_tradingService);
|
||||
PositionsVM = new PositionsViewModel(_tradingService);
|
||||
OrdersVM = new OrdersViewModel(_tradingService);
|
||||
|
||||
NavigateCommand = new RelayCommand(param => SelectedTab = param?.ToString());
|
||||
SubscribeToBotEvents();
|
||||
|
||||
var saved = CredentialService.LoadCredentials();
|
||||
if (saved != null)
|
||||
{
|
||||
_tradingService.Initialize(saved.ApiKey, saved.ApiSecret, saved.IsPaper);
|
||||
_pingService.SetEnvironment(saved.IsPaper);
|
||||
_pingService.Start(10);
|
||||
CurrentViewModel = DashboardVM;
|
||||
SelectedTab = "Dashboard";
|
||||
_ = LoadInitialDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
CurrentViewModel = SettingsVM;
|
||||
SelectedTab = "Settings";
|
||||
}
|
||||
}
|
||||
|
||||
private void NavigateToTab(string tabName)
|
||||
{
|
||||
CurrentViewModel = tabName switch
|
||||
{
|
||||
"Dashboard" => (BaseViewModel)DashboardVM,
|
||||
"Bot" => BotsVM,
|
||||
"LiveLog" => LiveLogVM,
|
||||
"Wallet" => WalletVM,
|
||||
"Settings" => SettingsVM,
|
||||
"Balance" => BalanceVM,
|
||||
"Positions" => PositionsVM,
|
||||
"Orders" => OrdersVM,
|
||||
_ => DashboardVM
|
||||
};
|
||||
}
|
||||
|
||||
private void SubscribeToBotEvents()
|
||||
{
|
||||
_botEngine.LogGenerated += (sender, log) => LiveLogVM.AddLog(log);
|
||||
_botEngine.EquityUpdated += (sender, equity) => DashboardVM.UpdateEquity(equity);
|
||||
}
|
||||
|
||||
private async Task LoadInitialDataAsync()
|
||||
{
|
||||
await Task.WhenAll(
|
||||
DashboardVM.LoadAsync(),
|
||||
WalletVM.LoadAsync(),
|
||||
BalanceVM.LoadAsync(),
|
||||
PositionsVM.LoadAsync(),
|
||||
OrdersVM.LoadAsync()
|
||||
);
|
||||
StartAutoRefresh();
|
||||
}
|
||||
|
||||
// ── Auto-refresh periodico ─────────────────────────────────────────
|
||||
private void StartAutoRefresh()
|
||||
{
|
||||
_autoRefreshCts?.Cancel();
|
||||
_autoRefreshCts = new CancellationTokenSource();
|
||||
_refreshCycle = 0;
|
||||
_ = AutoRefreshLoopAsync(_autoRefreshCts.Token);
|
||||
}
|
||||
|
||||
private async Task AutoRefreshLoopAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(FastRefreshSeconds), ct);
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
_refreshCycle++;
|
||||
|
||||
// Refresh frequente: posizioni aperte e ordini recenti
|
||||
var fastTasks = new System.Collections.Generic.List<Task>
|
||||
{
|
||||
SafeRefresh(PositionsVM.LoadAsync),
|
||||
SafeRefresh(OrdersVM.LoadAsync)
|
||||
};
|
||||
|
||||
// Refresh lento (ogni 2 cicli = 60s): balance, wallet, dashboard
|
||||
if (_refreshCycle % 2 == 0)
|
||||
{
|
||||
fastTasks.Add(SafeRefresh(BalanceVM.LoadAsync));
|
||||
fastTasks.Add(SafeRefresh(WalletVM.LoadAsync));
|
||||
fastTasks.Add(SafeRefresh(DashboardVM.LoadAsync));
|
||||
}
|
||||
|
||||
await Task.WhenAll(fastTasks);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* atteso */ }
|
||||
}
|
||||
|
||||
private static async Task SafeRefresh(Func<Task> loader)
|
||||
{
|
||||
try { await loader(); }
|
||||
catch { /* non bloccare il ciclo per errori singoli di rete */ }
|
||||
}
|
||||
|
||||
private void OnCredentialsSaved(string apiKey, string apiSecret, bool isPaper)
|
||||
{
|
||||
SelectedTab = "Dashboard";
|
||||
_ = LoadInitialDataAsync();
|
||||
}
|
||||
public void InitializeWithCredentials(string apiKey, string apiSecret, bool isPaper)
|
||||
{
|
||||
_tradingService.Initialize(apiKey, apiSecret, isPaper);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Windows.Media;
|
||||
using DesktopBot.Services;
|
||||
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// ViewModel per l'indicatore di ping verso Alpaca.
|
||||
/// Esposto in MainViewModel e mostrato nella sidebar.
|
||||
/// </summary>
|
||||
public class PingViewModel : BaseViewModel
|
||||
{
|
||||
private int _latencyMs = -1;
|
||||
private bool _isOnline;
|
||||
private string _statusText = "---";
|
||||
private Brush _statusColor;
|
||||
|
||||
private static readonly Brush BrushGood = new SolidColorBrush(Color.FromRgb(0x00, 0xC8, 0x5A)); // verde
|
||||
private static readonly Brush BrushFair = new SolidColorBrush(Color.FromRgb(0xFF, 0xBF, 0x00)); // giallo
|
||||
private static readonly Brush BrushPoor = new SolidColorBrush(Color.FromRgb(0xFF, 0x60, 0x00)); // arancione
|
||||
private static readonly Brush BrushOffline = new SolidColorBrush(Color.FromRgb(0xFF, 0x33, 0x33)); // rosso
|
||||
private static readonly Brush BrushIdle = new SolidColorBrush(Color.FromRgb(0x66, 0x66, 0x80)); // grigio
|
||||
|
||||
public int LatencyMs
|
||||
{
|
||||
get => _latencyMs;
|
||||
private set => SetProperty(ref _latencyMs, value);
|
||||
}
|
||||
|
||||
public bool IsOnline
|
||||
{
|
||||
get => _isOnline;
|
||||
private set => SetProperty(ref _isOnline, value);
|
||||
}
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get => _statusText;
|
||||
private set => SetProperty(ref _statusText, value);
|
||||
}
|
||||
|
||||
public Brush StatusColor
|
||||
{
|
||||
get => _statusColor ??= BrushIdle;
|
||||
private set => SetProperty(ref _statusColor, value);
|
||||
}
|
||||
|
||||
public void Update(PingResult result)
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
{
|
||||
dispatcher.Invoke(() => Apply(result));
|
||||
return;
|
||||
}
|
||||
Apply(result);
|
||||
}
|
||||
|
||||
private void Apply(PingResult result)
|
||||
{
|
||||
LatencyMs = result.LatencyMs;
|
||||
IsOnline = result.IsSuccess;
|
||||
|
||||
switch (result.Status)
|
||||
{
|
||||
case PingStatus.Good:
|
||||
StatusText = $"{result.LatencyMs} ms";
|
||||
StatusColor = BrushGood;
|
||||
break;
|
||||
case PingStatus.Fair:
|
||||
StatusText = $"{result.LatencyMs} ms";
|
||||
StatusColor = BrushFair;
|
||||
break;
|
||||
case PingStatus.Poor:
|
||||
StatusText = $"{result.LatencyMs} ms";
|
||||
StatusColor = BrushPoor;
|
||||
break;
|
||||
default:
|
||||
StatusText = "OFFLINE";
|
||||
StatusColor = BrushOffline;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Alpaca.Markets;
|
||||
using DesktopBot.Services;
|
||||
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// ViewModel per la visualizzazione del grafico dei prezzi in tempo reale.
|
||||
/// Supporta streaming via WebSocket per crypto e azioni, con buffer storico.
|
||||
/// </summary>
|
||||
public class PriceChartViewModel : BaseViewModel
|
||||
{
|
||||
private readonly ITradingService _tradingService;
|
||||
private CancellationTokenSource _streamCts;
|
||||
private Task _streamTask;
|
||||
|
||||
// ── Dati del grafico ─────────────────────────────────────────────────
|
||||
/// <summary>Collezione di punti prezzo per il grafico (timestamp, prezzo)</summary>
|
||||
public ObservableCollection<PricePoint> PriceData { get; }
|
||||
= new ObservableCollection<PricePoint>();
|
||||
|
||||
/// <summary>Limite massimo dei punti dati nel grafico (parametrizzato, default 3000)</summary>
|
||||
public int MaxDataPoints { get; set; } = 3000;
|
||||
|
||||
// ── Simbolo monitorato ───────────────────────────────────────────────
|
||||
private string _symbol = "BTCUSD";
|
||||
public string Symbol
|
||||
{
|
||||
get => _symbol;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _symbol, value))
|
||||
{
|
||||
_ = RestartStreamAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prezzo corrente ──────────────────────────────────────────────────
|
||||
private decimal _currentPrice;
|
||||
public decimal CurrentPrice
|
||||
{
|
||||
get => _currentPrice;
|
||||
private set => SetProperty(ref _currentPrice, value);
|
||||
}
|
||||
|
||||
private decimal _priceChange;
|
||||
public decimal PriceChange
|
||||
{
|
||||
get => _priceChange;
|
||||
private set => SetProperty(ref _priceChange, value);
|
||||
}
|
||||
|
||||
private decimal _priceChangePercent;
|
||||
public decimal PriceChangePercent
|
||||
{
|
||||
get => _priceChangePercent;
|
||||
private set => SetProperty(ref _priceChangePercent, value);
|
||||
}
|
||||
|
||||
public string PriceChangeColor => PriceChange >= 0 ? "#00E676" : "#FF5252";
|
||||
public string PriceChangeIcon => PriceChange >= 0 ? "▲" : "▼";
|
||||
|
||||
public System.Windows.Media.Brush PriceChangeBrush =>
|
||||
new System.Windows.Media.SolidColorBrush(
|
||||
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(PriceChangeColor));
|
||||
|
||||
// ── Stato streaming ──────────────────────────────────────────────────
|
||||
private bool _isStreaming;
|
||||
public bool IsStreaming
|
||||
{
|
||||
get => _isStreaming;
|
||||
private set => SetProperty(ref _isStreaming, value);
|
||||
}
|
||||
|
||||
private string _statusMessage = "Pronto";
|
||||
public string StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
set => SetProperty(ref _statusMessage, value);
|
||||
}
|
||||
|
||||
// ── Timeframe ────────────────────────────────────────────────────────
|
||||
private ChartTimeFrame _selectedTimeFrame = ChartTimeFrame.OneMinute;
|
||||
public ChartTimeFrame SelectedTimeFrame
|
||||
{
|
||||
get => _selectedTimeFrame;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _selectedTimeFrame, value))
|
||||
{
|
||||
_ = LoadHistoricalDataAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Comandi ──────────────────────────────────────────────────────────
|
||||
public ICommand StartStreamCommand { get; }
|
||||
public ICommand StopStreamCommand { get; }
|
||||
|
||||
// ── Costruttore ──────────────────────────────────────────────────────
|
||||
public PriceChartViewModel(ITradingService tradingService)
|
||||
{
|
||||
_tradingService = tradingService ?? throw new ArgumentNullException(nameof(tradingService));
|
||||
|
||||
StartStreamCommand = new RelayCommand(_ => _ = StartStreamingAsync(), _ => !IsStreaming);
|
||||
StopStreamCommand = new RelayCommand(_ => StopStreaming(), _ => IsStreaming);
|
||||
}
|
||||
|
||||
// ── Caricamento dati storici ─────────────────────────────────────────
|
||||
private async Task LoadHistoricalDataAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
StatusMessage = $"Caricamento storico {Symbol}...";
|
||||
|
||||
var timeFrame = SelectedTimeFrame == ChartTimeFrame.OneMinute
|
||||
? BarTimeFrame.Minute
|
||||
: BarTimeFrame.Hour;
|
||||
|
||||
int barCount = SelectedTimeFrame == ChartTimeFrame.OneMinute ? 200 : 100;
|
||||
|
||||
var bars = await _tradingService.GetHistoricalBarsAsync(Symbol, timeFrame, barCount);
|
||||
if (bars == null || bars.Count == 0)
|
||||
{
|
||||
StatusMessage = "Nessun dato storico disponibile";
|
||||
return;
|
||||
}
|
||||
|
||||
PriceData.Clear();
|
||||
decimal? firstPrice = null;
|
||||
|
||||
foreach (var bar in bars.OrderBy(b => b.TimeUtc))
|
||||
{
|
||||
if (!firstPrice.HasValue) firstPrice = bar.Close;
|
||||
PriceData.Add(new PricePoint
|
||||
{
|
||||
Timestamp = bar.TimeUtc.ToLocalTime(),
|
||||
Price = bar.Close
|
||||
});
|
||||
}
|
||||
|
||||
if (PriceData.Count > 0)
|
||||
{
|
||||
var last = PriceData.Last();
|
||||
CurrentPrice = last.Price;
|
||||
if (firstPrice.HasValue && firstPrice.Value > 0)
|
||||
{
|
||||
PriceChange = CurrentPrice - firstPrice.Value;
|
||||
PriceChangePercent = (PriceChange / firstPrice.Value) * 100m;
|
||||
}
|
||||
}
|
||||
|
||||
StatusMessage = $"Caricati {bars.Count} punti storici";
|
||||
OnPropertyChanged(nameof(PriceChangeColor));
|
||||
OnPropertyChanged(nameof(PriceChangeBrush));
|
||||
OnPropertyChanged(nameof(PriceChangeIcon));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Errore caricamento: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming real-time ──────────────────────────────────────────────
|
||||
public async Task StartStreamingAsync()
|
||||
{
|
||||
if (IsStreaming) return;
|
||||
|
||||
await LoadHistoricalDataAsync();
|
||||
|
||||
IsStreaming = true;
|
||||
_streamCts = new CancellationTokenSource();
|
||||
StatusMessage = $"Streaming {Symbol} attivo...";
|
||||
|
||||
// TODO: Implementare WebSocket streaming con Alpaca
|
||||
// Per ora: polling ogni 5 secondi come fallback
|
||||
_streamTask = Task.Run(() => PollingLoopAsync(_streamCts.Token));
|
||||
}
|
||||
|
||||
private async Task PollingLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), ct);
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
decimal price = await _tradingService.GetLatestPriceAsync(Symbol);
|
||||
UpdatePrice(price);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Errore stream: {ex.Message}";
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePrice(decimal price)
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
{
|
||||
dispatcher.Invoke(() => UpdatePriceInternal(price));
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdatePriceInternal(price);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePriceInternal(decimal price)
|
||||
{
|
||||
var firstPrice = PriceData.Count > 0 ? PriceData.First().Price : price;
|
||||
PriceChange = price - CurrentPrice;
|
||||
CurrentPrice = price;
|
||||
|
||||
if (firstPrice > 0)
|
||||
{
|
||||
PriceChangePercent = ((price - firstPrice) / firstPrice) * 100m;
|
||||
}
|
||||
|
||||
PriceData.Add(new PricePoint
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
Price = price
|
||||
});
|
||||
|
||||
// Limita dimensione buffer
|
||||
while (PriceData.Count > MaxDataPoints)
|
||||
PriceData.RemoveAt(0);
|
||||
|
||||
OnPropertyChanged(nameof(PriceChangeColor));
|
||||
OnPropertyChanged(nameof(PriceChangeBrush));
|
||||
OnPropertyChanged(nameof(PriceChangeIcon));
|
||||
}
|
||||
|
||||
public void StopStreaming()
|
||||
{
|
||||
_streamCts?.Cancel();
|
||||
IsStreaming = false;
|
||||
StatusMessage = "Stream fermato";
|
||||
}
|
||||
|
||||
private async Task RestartStreamAsync()
|
||||
{
|
||||
if (IsStreaming)
|
||||
{
|
||||
StopStreaming();
|
||||
await Task.Delay(500);
|
||||
await StartStreamingAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────────
|
||||
public void Dispose()
|
||||
{
|
||||
StopStreaming();
|
||||
_streamCts?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modello dati punto prezzo ────────────────────────────────────────────
|
||||
public class PricePoint
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
}
|
||||
|
||||
// ── Enum timeframe grafico ───────────────────────────────────────────────
|
||||
public enum ChartTimeFrame
|
||||
{
|
||||
OneMinute,
|
||||
FiveMinutes,
|
||||
OneHour
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Windows.Input;
|
||||
using DesktopBot.Models;
|
||||
using DesktopBot.Services;
|
||||
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// ViewModel per la schermata di configurazione credenziali Alpaca e logging
|
||||
/// </summary>
|
||||
public class SettingsViewModel : BaseViewModel
|
||||
{
|
||||
private readonly ITradingService _tradingService;
|
||||
private readonly Action<string, string, bool> _onCredentialsSaved;
|
||||
private readonly Action<LoggingConfiguration> _onLoggingConfigSaved;
|
||||
private LoggingSettingsViewModel _loggingSettings;
|
||||
|
||||
private string _apiKey;
|
||||
private string _apiSecret;
|
||||
private bool _isPaper = true;
|
||||
private string _statusMessage;
|
||||
private bool _isConnecting;
|
||||
private bool _isConnected;
|
||||
|
||||
public string ApiKey
|
||||
{
|
||||
get => _apiKey;
|
||||
set => SetProperty(ref _apiKey, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Il secret non è bindabile direttamente (PasswordBox non supporta binding).
|
||||
/// Viene impostato dal code-behind tramite questa proprietà.
|
||||
/// </summary>
|
||||
public string ApiSecret
|
||||
{
|
||||
get => _apiSecret;
|
||||
set => SetProperty(ref _apiSecret, value);
|
||||
}
|
||||
|
||||
public bool IsPaper
|
||||
{
|
||||
get => _isPaper;
|
||||
set => SetProperty(ref _isPaper, value);
|
||||
}
|
||||
|
||||
public string StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
set => SetProperty(ref _statusMessage, value);
|
||||
}
|
||||
|
||||
public bool IsConnecting
|
||||
{
|
||||
get => _isConnecting;
|
||||
set => SetProperty(ref _isConnecting, value);
|
||||
}
|
||||
|
||||
public bool IsConnected
|
||||
{
|
||||
get => _isConnected;
|
||||
set => SetProperty(ref _isConnected, value);
|
||||
}
|
||||
|
||||
/// <summary>ViewModel per la configurazione dei log</summary>
|
||||
public LoggingSettingsViewModel LoggingSettings
|
||||
{
|
||||
get => _loggingSettings;
|
||||
set => SetProperty(ref _loggingSettings, value);
|
||||
}
|
||||
|
||||
public ICommand SaveCommand { get; }
|
||||
public ICommand TestConnectionCommand { get; }
|
||||
public ICommand DeleteCredentialsCommand { get; }
|
||||
public ICommand SaveLoggingCommand { get; }
|
||||
public ICommand ResetLoggingCommand { get; }
|
||||
|
||||
// ── Informazioni applicazione ──────────────────────────────────────────
|
||||
public string AppVersion { get; } = Assembly.GetExecutingAssembly()
|
||||
.GetName().Version?.ToString() ?? "1.0.0.0";
|
||||
public string BuildDate { get; } = System.IO.File.GetLastWriteTime(
|
||||
Assembly.GetExecutingAssembly().Location)
|
||||
.ToString("dd/MM/yyyy HH:mm");
|
||||
public string Author { get; } = "Alberto Balbo";
|
||||
public string Framework { get; } = ".NET Framework 4.8.1";
|
||||
public string Description { get; } = "Trading Bot automatico per Alpaca Markets API v2. " +
|
||||
"Supporta paper trading e live trading con strategia EMA Crossover + RSI.";
|
||||
|
||||
public SettingsViewModel(ITradingService tradingService,
|
||||
Action<string, string, bool> onCredentialsSaved,
|
||||
Action<LoggingConfiguration> onLoggingConfigSaved = null)
|
||||
{
|
||||
_tradingService = tradingService;
|
||||
_onCredentialsSaved = onCredentialsSaved;
|
||||
_onLoggingConfigSaved = onLoggingConfigSaved;
|
||||
|
||||
SaveCommand = new RelayCommand(
|
||||
_ => SaveCredentials(),
|
||||
_ => !string.IsNullOrWhiteSpace(ApiKey) && !string.IsNullOrWhiteSpace(ApiSecret)
|
||||
);
|
||||
|
||||
TestConnectionCommand = new RelayCommand(
|
||||
async _ => await TestConnectionAsync(),
|
||||
_ => !string.IsNullOrWhiteSpace(ApiKey) && !string.IsNullOrWhiteSpace(ApiSecret) && !IsConnecting
|
||||
);
|
||||
|
||||
DeleteCredentialsCommand = new RelayCommand(_ => DeleteCredentials());
|
||||
|
||||
SaveLoggingCommand = new RelayCommand(_ => SaveLoggingConfiguration());
|
||||
ResetLoggingCommand = new RelayCommand(_ => ResetLoggingConfiguration());
|
||||
|
||||
// Carica credenziali esistenti (mostra solo la key, non il secret)
|
||||
var saved = CredentialService.LoadCredentials();
|
||||
if (saved != null)
|
||||
{
|
||||
ApiKey = saved.ApiKey;
|
||||
ApiSecret = saved.ApiSecret;
|
||||
IsPaper = saved.IsPaper;
|
||||
StatusMessage = "✓ Credenziali caricate da configurazione salvata";
|
||||
IsConnected = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = "Inserisci le credenziali API di Alpaca";
|
||||
}
|
||||
|
||||
// Inizializza logging settings con configurazione di default
|
||||
LoggingSettings = new LoggingSettingsViewModel(new LoggingConfiguration());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imposta la configurazione di logging iniziale dal bot
|
||||
/// </summary>
|
||||
public void SetLoggingConfiguration(LoggingConfiguration config)
|
||||
{
|
||||
LoggingSettings = new LoggingSettingsViewModel(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Salva la configurazione di logging
|
||||
/// </summary>
|
||||
private void SaveLoggingConfiguration()
|
||||
{
|
||||
var updatedConfig = LoggingSettings.GetUpdatedConfig();
|
||||
_onLoggingConfigSaved?.Invoke(updatedConfig);
|
||||
StatusMessage = "✓ Configurazione log salvata";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ripristina i limiti di logging ai valori di default
|
||||
/// </summary>
|
||||
private void ResetLoggingConfiguration()
|
||||
{
|
||||
LoggingSettings.ResetToDefaults();
|
||||
StatusMessage = "✓ Limiti log ripristinati ai valori di default";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Salva le credenziali cifrate e notifica il ViewModel principale
|
||||
/// </summary>
|
||||
private void SaveCredentials()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ApiKey) || string.IsNullOrWhiteSpace(ApiSecret))
|
||||
{
|
||||
StatusMessage = "⚠ API Key e Secret sono obbligatori";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
CredentialService.SaveCredentials(ApiKey, ApiSecret, IsPaper);
|
||||
_tradingService.Initialize(ApiKey, ApiSecret, IsPaper);
|
||||
_onCredentialsSaved?.Invoke(ApiKey, ApiSecret, IsPaper);
|
||||
StatusMessage = "✓ Credenziali salvate e connessione configurata";
|
||||
IsConnected = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"✗ Errore nel salvataggio: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Testa la connessione alle API Alpaca con le credenziali inserite
|
||||
/// </summary>
|
||||
private async System.Threading.Tasks.Task TestConnectionAsync()
|
||||
{
|
||||
IsConnecting = true;
|
||||
IsConnected = false;
|
||||
StatusMessage = "⟳ Test connessione in corso...";
|
||||
|
||||
try
|
||||
{
|
||||
_tradingService.Initialize(ApiKey, ApiSecret, IsPaper);
|
||||
var equity = await _tradingService.GetAvailableEquityAsync();
|
||||
StatusMessage = $"✓ Connessione riuscita! Equity disponibile: ${equity:N2}";
|
||||
IsConnected = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"✗ Connessione fallita: {ex.Message}";
|
||||
IsConnected = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Elimina le credenziali salvate
|
||||
/// </summary>
|
||||
private void DeleteCredentials()
|
||||
{
|
||||
CredentialService.DeleteCredentials();
|
||||
ApiKey = string.Empty;
|
||||
ApiSecret = string.Empty;
|
||||
IsConnected = false;
|
||||
StatusMessage = "Credenziali eliminate. Inserisci nuove credenziali.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using DesktopBot.Services;
|
||||
|
||||
namespace DesktopBot.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// ViewModel per il Wallet e le posizioni aperte
|
||||
/// </summary>
|
||||
public class WalletViewModel : BaseViewModel
|
||||
{
|
||||
private readonly ITradingService _tradingService;
|
||||
|
||||
private decimal _equity;
|
||||
private bool _isLoading;
|
||||
private string _errorMessage;
|
||||
private bool _hasError;
|
||||
private ObservableCollection<PositionViewModel> _positions = new ObservableCollection<PositionViewModel>();
|
||||
|
||||
public decimal Equity
|
||||
{
|
||||
get => _equity;
|
||||
set => SetProperty(ref _equity, value);
|
||||
}
|
||||
|
||||
public bool IsLoading
|
||||
{
|
||||
get => _isLoading;
|
||||
set => SetProperty(ref _isLoading, value);
|
||||
}
|
||||
|
||||
public string ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
set => SetProperty(ref _errorMessage, value);
|
||||
}
|
||||
|
||||
public bool HasError
|
||||
{
|
||||
get => _hasError;
|
||||
set => SetProperty(ref _hasError, value);
|
||||
}
|
||||
|
||||
public ObservableCollection<PositionViewModel> Positions
|
||||
{
|
||||
get => _positions;
|
||||
set => SetProperty(ref _positions, value);
|
||||
}
|
||||
|
||||
public ICommand RefreshCommand { get; }
|
||||
public ICommand CloseAllCommand { get; }
|
||||
|
||||
public WalletViewModel(ITradingService tradingService)
|
||||
{
|
||||
_tradingService = tradingService;
|
||||
RefreshCommand = new RelayCommand(async _ => await RefreshAsync());
|
||||
CloseAllCommand = new RelayCommand(async _ => await CloseAllPositionsAsync());
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carica equity e posizioni aperte da Alpaca
|
||||
/// </summary>
|
||||
public async System.Threading.Tasks.Task LoadAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
HasError = false;
|
||||
try
|
||||
{
|
||||
var accountTask = _tradingService.GetAccountAsync();
|
||||
var positionsTask = _tradingService.GetAllPositionsAsync();
|
||||
|
||||
await System.Threading.Tasks.Task.WhenAll(accountTask, positionsTask);
|
||||
|
||||
var account = accountTask.Result;
|
||||
var positions = positionsTask.Result;
|
||||
|
||||
Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
Equity = account.Equity ?? 0m;
|
||||
Positions.Clear();
|
||||
foreach (var pos in positions)
|
||||
Positions.Add(new PositionViewModel(pos));
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
HasError = true;
|
||||
ErrorMessage = "Errore: " + ex.Message;
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
Application.Current?.Dispatcher.Invoke(() => IsLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task CloseAllPositionsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _tradingService.CloseAllPositionsAsync();
|
||||
await RefreshAsync();
|
||||
}
|
||||
catch { /* Gestito silenziosamente */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel per una singola posizione aperta
|
||||
/// </summary>
|
||||
public class PositionViewModel : BaseViewModel
|
||||
{
|
||||
public string Symbol { get; }
|
||||
public decimal Quantity { get; }
|
||||
public string Side { get; }
|
||||
public decimal EntryPrice { get; }
|
||||
public decimal CurrentPrice { get; }
|
||||
public decimal MarketValue { get; }
|
||||
public decimal UnrealizedPnL { get; }
|
||||
public decimal UnrealizedPnLPercent { get; }
|
||||
public bool IsProfit { get; }
|
||||
|
||||
public PositionViewModel(Alpaca.Markets.IPosition position)
|
||||
{
|
||||
Symbol = position.Symbol;
|
||||
Quantity = Math.Abs(position.Quantity);
|
||||
Side = position.Quantity >= 0 ? "LONG" : "SHORT";
|
||||
EntryPrice = position.AverageEntryPrice;
|
||||
CurrentPrice = position.AssetCurrentPrice ?? 0m;
|
||||
MarketValue = position.MarketValue ?? 0m;
|
||||
UnrealizedPnL = position.UnrealizedProfitLoss ?? 0m;
|
||||
var cost = position.CostBasis != 0 ? position.CostBasis : 1m;
|
||||
UnrealizedPnLPercent = (cost != 0) ? (UnrealizedPnL / Math.Abs(cost)) * 100m : 0m;
|
||||
IsProfit = UnrealizedPnL >= 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user