Files
Encelado/Encelado/src/Encelado.Bot/MainWindow.xaml.cs
T

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();
}
}