577 lines
18 KiB
C#
577 lines
18 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Interop;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Threading;
|
|
using Encelado.Bot.Configuration;
|
|
using Encelado.Bot.Engine;
|
|
using Encelado.Bot.Logging;
|
|
using Encelado.Bot.Ui;
|
|
using Encelado.Bot.Ui.Pages;
|
|
|
|
namespace Encelado.Bot;
|
|
|
|
/// <summary>
|
|
/// The shell: side navigation on the left, one page at a time on the right, and the
|
|
/// start/stop button always reachable at the bottom of the nav.
|
|
/// <para>
|
|
/// Pages are plain <see cref="UserControl"/>s that know nothing about the supervisor.
|
|
/// Anything they need done is asked for through <see cref="IUiActions"/>, which this
|
|
/// window implements — so the credential store, the file system and the engine are
|
|
/// touched from exactly one place.
|
|
/// </para>
|
|
/// </summary>
|
|
public partial class MainWindow : Window, IUiActions
|
|
{
|
|
private readonly BotConfig _config = App.Config;
|
|
private readonly MainViewModel _vm;
|
|
private readonly BotSupervisor _supervisor;
|
|
private readonly DispatcherTimer _timer;
|
|
private readonly List<ChartWindow> _chartWindows = [];
|
|
|
|
private readonly StatusPage _status = new();
|
|
private readonly PositionsPage _positions = new();
|
|
private readonly ChartsPage _charts = new();
|
|
private readonly LogPage _log = new();
|
|
private readonly OrdersPage _orders = new();
|
|
private readonly SettingsPage _settings = new();
|
|
private readonly AccountPage _account = new();
|
|
|
|
private bool _busy;
|
|
private bool _closing;
|
|
|
|
public MainWindow()
|
|
{
|
|
InitializeComponent();
|
|
|
|
_vm = new MainViewModel
|
|
{
|
|
StatusLines = _config.Logging.StatusLines,
|
|
Log = new LogViewModel(_config.Logging.BufferedLines),
|
|
};
|
|
|
|
_supervisor = new BotSupervisor(_config);
|
|
_supervisor.AttachLogSink();
|
|
_supervisor.EventLogged += _vm.Log.Enqueue;
|
|
|
|
DataContext = _vm;
|
|
|
|
foreach (UserControl page in new UserControl[]
|
|
{ _status, _positions, _charts, _log, _settings, _account, _orders })
|
|
{
|
|
page.DataContext = _vm;
|
|
}
|
|
|
|
_status.Actions = this;
|
|
_positions.Actions = this;
|
|
_charts.Actions = this;
|
|
_log.Actions = this;
|
|
_settings.Actions = this;
|
|
|
|
BuildNavigation();
|
|
|
|
FrameChip.Text = _config.Engine.TimeFrame;
|
|
VersionText.Text = $"v{Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}";
|
|
LoadLogo();
|
|
RefreshSettings();
|
|
|
|
// One snapshot per second: fast enough to feel live, cheap enough that the UI
|
|
// never competes with the trading loop for CPU.
|
|
_timer = new DispatcherTimer(DispatcherPriority.Background)
|
|
{
|
|
Interval = TimeSpan.FromSeconds(1),
|
|
};
|
|
|
|
_timer.Tick += (_, _) => Refresh();
|
|
_timer.Start();
|
|
|
|
Loaded += OnLoaded;
|
|
Closing += OnClosing;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Navigation
|
|
// -----------------------------------------------------------------------
|
|
|
|
private void BuildNavigation()
|
|
{
|
|
// Glyphs are Segoe MDL2 Assets code points, which ships with Windows.
|
|
NavItem[] items =
|
|
[
|
|
new("Stato", "\uE80F", () => _status),
|
|
new("Conto", "\uE8C7", () => _account),
|
|
new("Posizioni", "\uE8A1", () => _positions),
|
|
new("Ordini", "\uE8A5", () => _orders),
|
|
new("Grafici", "\uE9D2", () => _charts),
|
|
new("Log", "\uE81C", () => _log),
|
|
new("Impostazioni", "\uE713", () => _settings),
|
|
];
|
|
|
|
Nav.ItemsSource = items;
|
|
Nav.SelectedIndex = 0;
|
|
}
|
|
|
|
private void OnNavigated(object sender, SelectionChangedEventArgs e)
|
|
{
|
|
if (Nav.SelectedItem is NavItem item)
|
|
{
|
|
PageHost.Content = item.Page;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Startup
|
|
// -----------------------------------------------------------------------
|
|
|
|
private void OnLoaded(object sender, RoutedEventArgs e)
|
|
{
|
|
ApplyNativeDarkTitleBar();
|
|
Refresh();
|
|
|
|
foreach (string warning in App.ConfigWarnings)
|
|
{
|
|
Log.Warn($"config: {warning}");
|
|
}
|
|
|
|
Log.Info($"Encelado avviato — configurazione {App.ConfigPath}");
|
|
Log.Info($"log in {_config.Logging.ResolveDirectory()}");
|
|
|
|
CredentialLookup lookup = CredentialResolver.Resolve(_config);
|
|
if (!lookup.Found)
|
|
{
|
|
Log.Info("nessuna credenziale trovata: apro la finestra di login");
|
|
PromptForCredentials();
|
|
}
|
|
else
|
|
{
|
|
Log.Info($"credenziali: {lookup.Describe()}");
|
|
}
|
|
|
|
RefreshSettings();
|
|
}
|
|
|
|
private void LoadLogo()
|
|
{
|
|
try
|
|
{
|
|
LogoImage.Source = new BitmapImage(
|
|
new Uri("pack://application:,,,/Assets/encelado.ico", UriKind.Absolute));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Debug($"logo non caricato: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asks the desktop window manager to draw <b>its own</b> title bar dark, so the
|
|
/// standard Windows frame does not sit in light grey on top of a near-black window.
|
|
/// <para>
|
|
/// This is the opposite of custom chrome: the frame stays entirely Windows', with
|
|
/// its real buttons, snap layouts, rounded corners and accessibility behaviour. The
|
|
/// only thing being set is which of the two colour schemes Windows uses to paint it.
|
|
/// Ignored on builds that predate the attribute, which simply leaves it light.
|
|
/// </para>
|
|
/// </summary>
|
|
private void ApplyNativeDarkTitleBar()
|
|
{
|
|
const int DwmwaUseImmersiveDarkMode = 20;
|
|
|
|
try
|
|
{
|
|
nint handle = new WindowInteropHelper(this).Handle;
|
|
int enabled = 1;
|
|
_ = DwmSetWindowAttribute(handle, DwmwaUseImmersiveDarkMode, ref enabled, sizeof(int));
|
|
}
|
|
catch (DllNotFoundException)
|
|
{
|
|
// Not Windows, or a stripped image. Nothing to do.
|
|
}
|
|
}
|
|
|
|
[DllImport("dwmapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
private static extern int DwmSetWindowAttribute(nint hwnd, int attribute, ref int value, int size);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Live refresh
|
|
// -----------------------------------------------------------------------
|
|
|
|
private void Refresh()
|
|
{
|
|
_vm.Apply(_supervisor.Snapshot());
|
|
_vm.Log.Flush();
|
|
|
|
if (Nav.ItemsSource is IEnumerable<NavItem> items)
|
|
{
|
|
foreach (NavItem item in items)
|
|
{
|
|
item.Badge = item.Title switch
|
|
{
|
|
"Posizioni" when _vm.OpenPositions > 0 =>
|
|
_vm.OpenPositions.ToString(System.Globalization.CultureInfo.CurrentCulture),
|
|
_ => string.Empty,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Bot control
|
|
// -----------------------------------------------------------------------
|
|
|
|
private async void OnTogglePower(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_busy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!_vm.IsRunning && !EnsureCredentials())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!_vm.IsRunning && !_config.Alpaca.Paper && !ConfirmLiveTrading())
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Never touch PowerBtn.IsEnabled here. It is bound to CanToggle, and assigning a
|
|
// dependency property imperatively replaces the binding with a local value — the
|
|
// button then stays disabled for ever and the bot cannot be stopped from the
|
|
// window. The view model owns the whole thing.
|
|
_busy = true;
|
|
_vm.IsBusy = true;
|
|
|
|
try
|
|
{
|
|
CommandResult result = _vm.IsRunning
|
|
? await _supervisor.StopAsync().ConfigureAwait(true)
|
|
: await _supervisor.StartAsync().ConfigureAwait(true);
|
|
|
|
if (!result.Ok)
|
|
{
|
|
MessageBox.Show(this, result.Message, "Encelado",
|
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_busy = false;
|
|
_vm.IsBusy = false;
|
|
Refresh();
|
|
}
|
|
}
|
|
|
|
private bool ConfirmLiveTrading() =>
|
|
MessageBox.Show(
|
|
this,
|
|
"Questa configurazione opera sul conto LIVE con denaro reale.\n\nAvviare comunque?",
|
|
"Attenzione — denaro reale",
|
|
MessageBoxButton.YesNo,
|
|
MessageBoxImage.Warning,
|
|
MessageBoxResult.No) == MessageBoxResult.Yes;
|
|
|
|
private bool EnsureCredentials() =>
|
|
CredentialResolver.Resolve(_config).Found || PromptForCredentials();
|
|
|
|
private bool PromptForCredentials()
|
|
{
|
|
LoginWindow dialog = new(_config) { Owner = this };
|
|
bool ok = dialog.ShowDialog() == true;
|
|
RefreshSettings();
|
|
return ok;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// IUiActions — everything the pages can ask the shell to do
|
|
// -----------------------------------------------------------------------
|
|
|
|
public async Task ClosePositionAsync(string symbol)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(symbol))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (MessageBox.Show(
|
|
this,
|
|
$"Chiudere la posizione su {symbol} al prezzo di mercato?",
|
|
"Chiusura posizione",
|
|
MessageBoxButton.YesNo,
|
|
MessageBoxImage.Question,
|
|
MessageBoxResult.No) != MessageBoxResult.Yes)
|
|
{
|
|
return;
|
|
}
|
|
|
|
CommandResult result = await _supervisor
|
|
.ClosePositionAsync(symbol, CancellationToken.None)
|
|
.ConfigureAwait(true);
|
|
|
|
if (!result.Ok)
|
|
{
|
|
MessageBox.Show(this, result.Message, "Encelado", MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
}
|
|
}
|
|
|
|
public void ShowLogin() => PromptForCredentials();
|
|
|
|
public void ForgetCredentials()
|
|
{
|
|
if (MessageBox.Show(
|
|
this,
|
|
$"Rimuovere le chiavi salvate per l'ambiente {(_config.Alpaca.Paper ? "PAPER" : "LIVE")}?",
|
|
"Rimozione credenziali",
|
|
MessageBoxButton.YesNo,
|
|
MessageBoxImage.Question,
|
|
MessageBoxResult.No) != MessageBoxResult.Yes)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool removed = CredentialStore.Clear(_config.Alpaca.Paper);
|
|
Log.Info(removed ? "credenziali salvate rimosse" : "non c'erano credenziali salvate da rimuovere");
|
|
RefreshSettings();
|
|
}
|
|
|
|
public void OpenConfigFile() => OpenInShell(App.ConfigPath);
|
|
|
|
public void OpenLogFolder()
|
|
{
|
|
string directory = _config.Logging.ResolveDirectory();
|
|
try
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
Log.Warn($"impossibile creare {directory}: {ex.Message}");
|
|
return;
|
|
}
|
|
|
|
OpenInShell(directory);
|
|
}
|
|
|
|
public void OpenLogFile()
|
|
{
|
|
string? path = Log.FilePath ?? _config.Logging.ResolvePath(_config.Logging.File);
|
|
|
|
if (path is null || !File.Exists(path))
|
|
{
|
|
MessageBox.Show(this,
|
|
"Il file di log non esiste ancora.\n\nViene creato alla prima riga scritta su disco.",
|
|
"Encelado", MessageBoxButton.OK, MessageBoxImage.Information);
|
|
return;
|
|
}
|
|
|
|
OpenInShell(path);
|
|
}
|
|
|
|
public void ChangeLogDirectory()
|
|
{
|
|
Microsoft.Win32.OpenFolderDialog dialog = new()
|
|
{
|
|
Title = "Dove salvare i log di Encelado",
|
|
InitialDirectory = SafeInitialDirectory(),
|
|
Multiselect = false,
|
|
};
|
|
|
|
if (dialog.ShowDialog(this) != true)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string chosen = dialog.FolderName;
|
|
|
|
// Refuse before writing rather than after: a directory we cannot write to would
|
|
// leave the bot logging nowhere, and the logger fails quietly by design.
|
|
if (!IsWritable(chosen, out string problem))
|
|
{
|
|
MessageBox.Show(this,
|
|
$"Non posso scrivere in questa cartella:\n\n{chosen}\n\n{problem}",
|
|
"Cartella non utilizzabile", MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
ConfigWriter.SetLogDirectory(App.ConfigPath, chosen);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
|
or InvalidOperationException or FileNotFoundException)
|
|
{
|
|
MessageBox.Show(this,
|
|
$"Non sono riuscito a salvare la configurazione:\n\n{ex.Message}",
|
|
"Encelado", MessageBoxButton.OK, MessageBoxImage.Error);
|
|
return;
|
|
}
|
|
|
|
_config.Logging.Directory = chosen;
|
|
Log.Info($"cartella dei log impostata su {chosen} — attiva al prossimo avvio");
|
|
RefreshSettings();
|
|
|
|
MessageBox.Show(this,
|
|
$"I log verranno salvati in:\n\n{chosen}\n\n" +
|
|
"I file attualmente aperti restano dove sono fino al prossimo avvio dell'applicazione.",
|
|
"Impostazione salvata", MessageBoxButton.OK, MessageBoxImage.Information);
|
|
}
|
|
|
|
private string SafeInitialDirectory()
|
|
{
|
|
try
|
|
{
|
|
string current = _config.Logging.ResolveDirectory();
|
|
return Directory.Exists(current) ? current : AppContext.BaseDirectory;
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return AppContext.BaseDirectory;
|
|
}
|
|
}
|
|
|
|
private static bool IsWritable(string directory, out string problem)
|
|
{
|
|
problem = string.Empty;
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
|
|
string probe = Path.Combine(directory, $".encelado-{Guid.NewGuid():N}");
|
|
File.WriteAllText(probe, string.Empty);
|
|
File.Delete(probe);
|
|
return true;
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
|
or ArgumentException or NotSupportedException)
|
|
{
|
|
problem = ex.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public void OpenChartWindow(string symbol)
|
|
{
|
|
SymbolChartViewModel? chart = null;
|
|
foreach (SymbolChartViewModel candidate in _vm.Charts)
|
|
{
|
|
if (string.Equals(candidate.Symbol, symbol, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
chart = candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (chart is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Raise the existing one rather than stacking duplicates on top of each other.
|
|
foreach (ChartWindow open in _chartWindows)
|
|
{
|
|
if (string.Equals(open.Symbol, symbol, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (open.WindowState == WindowState.Minimized)
|
|
{
|
|
open.WindowState = WindowState.Normal;
|
|
}
|
|
|
|
open.Activate();
|
|
return;
|
|
}
|
|
}
|
|
|
|
ChartWindow window = new(chart) { Owner = this };
|
|
_chartWindows.Add(window);
|
|
window.Closed += (_, _) => _chartWindows.Remove(window);
|
|
window.Show();
|
|
}
|
|
|
|
private static void OpenInShell(string path)
|
|
{
|
|
try
|
|
{
|
|
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warn($"impossibile aprire {path}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Settings
|
|
// -----------------------------------------------------------------------
|
|
|
|
private void RefreshSettings()
|
|
{
|
|
CredentialLookup lookup = CredentialResolver.Resolve(_config);
|
|
|
|
string status = lookup.Found
|
|
? $"Origine: {lookup.Describe()} — ambiente {(_config.Alpaca.Paper ? "PAPER" : "LIVE")}."
|
|
: "Nessuna credenziale configurata. Il bot non può partire finché non ne inserisci una coppia.";
|
|
|
|
string store = CredentialStore.Exists
|
|
? $"Archivio: {CredentialStore.FilePath}" +
|
|
(CredentialStore.IsEncrypted ? " (cifrato con DPAPI)" : " (in chiaro, permessi ristretti)")
|
|
: $"Nessun archivio salvato. Verrebbe creato in {CredentialStore.FilePath}.";
|
|
|
|
string about =
|
|
"Encelado — motore di trading automatico su Alpaca.\n" +
|
|
$"Versione {Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}\n" +
|
|
$"Configurazione: {App.ConfigPath}\n" +
|
|
$"Endpoint: {_config.Alpaca.TradingBaseUrl} feed dati: {_config.Alpaca.DataFeed}";
|
|
|
|
_settings.Refresh(_config, status, store, about);
|
|
_account.Describe(_config.Risk);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Shutdown
|
|
// -----------------------------------------------------------------------
|
|
|
|
private async void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
|
{
|
|
if (_closing)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_vm.IsRunning &&
|
|
MessageBox.Show(
|
|
this,
|
|
"Il bot è in esecuzione. Chiudere l'applicazione lo ferma.\n\n" +
|
|
"Le posizioni aperte restano aperte sul conto Alpaca.\n\nContinuare?",
|
|
"Chiusura",
|
|
MessageBoxButton.YesNo,
|
|
MessageBoxImage.Warning,
|
|
MessageBoxResult.No) != MessageBoxResult.Yes)
|
|
{
|
|
e.Cancel = true;
|
|
return;
|
|
}
|
|
|
|
// Stopping the engine is asynchronous, so cancel this close and re-issue it
|
|
// once the shutdown has actually finished.
|
|
e.Cancel = true;
|
|
_closing = true;
|
|
_timer.Stop();
|
|
_supervisor.EventLogged -= _vm.Log.Enqueue;
|
|
|
|
foreach (ChartWindow window in _chartWindows.ToArray())
|
|
{
|
|
window.Close();
|
|
}
|
|
|
|
await _supervisor.DisposeAsync().ConfigureAwait(true);
|
|
|
|
Close();
|
|
}
|
|
}
|