diff --git a/Encelado/Directory.Build.props b/Encelado/Directory.Build.props
index 26c7be0..70c0ab5 100644
--- a/Encelado/Directory.Build.props
+++ b/Encelado/Directory.Build.props
@@ -13,7 +13,12 @@
false
Encelado
Encelado
- 3.2.0
+
+ 3.5.0
+ ToolTip="{Binding FullTooltip}"
+ Visibility="{Binding IsFreeText, Converter={StaticResource BoolVis}}">
+
+
+
Choices { get; init; } = [];
+ ///
+ /// I valori fra cui si può scegliere, o vuoto se il campo è a testo libero.
+ ///
+ /// I booleani entrano qui da sé: "sì" e "no" scritti a mano sono due modi per
+ /// sbagliare, e nessuno dei due aggiunge niente rispetto a sceglierli.
+ ///
+ ///
+ public IReadOnlyList Options => Kind switch
+ {
+ SettingKind.Choice => Choices,
+ SettingKind.Boolean => ["sì", "no"],
+ _ => [],
+ };
+
+ /// Vero se il campo si compila da un elenco. Vedi .
+ public bool IsList => Options.Count > 0;
+
+ /// Vero se il campo si scrive. È l'opposto di .
+ public bool IsFreeText => !IsList;
+
+ ///
+ /// Vero quando il valore salvato non è più fra quelli ammessi — tipicamente dopo un
+ /// aggiornamento che ha tolto una strategia. Il campo resta visibile con il suo
+ /// errore, ma l'elenco non lo ripropone: da lì si esce solo scegliendo un valore
+ /// che esiste.
+ ///
+ public bool IsObsolete => IsList && !IsReadOnly &&
+ !Options.Contains(_value, StringComparer.OrdinalIgnoreCase);
+
/// Unit suffix shown after the box, e.g. "%" or "secondi".
public string Suffix { get; init; } = string.Empty;
@@ -87,6 +116,7 @@ public sealed class SettingField : INotifyPropertyChanged
_value = value;
Raise();
Raise(nameof(IsDirty));
+ Raise(nameof(IsObsolete));
Validate();
}
}
@@ -121,9 +151,13 @@ public sealed class SettingField : INotifyPropertyChanged
{
_value = value;
Original = value;
- Error = null;
Raise(nameof(Value));
Raise(nameof(IsDirty));
+
+ // Validato subito, non solo quando qualcuno lo tocca: un valore diventato non
+ // valido perché l'aggiornamento ha tolto una strategia deve segnalarsi da sé
+ // all'apertura della pagina, non restare lì con l'aria di andare bene.
+ Validate();
}
public void Revert() => Load(Original);
@@ -176,7 +210,7 @@ public sealed class SettingField : INotifyPropertyChanged
case SettingKind.Choice:
if (Choices.Count > 0 && !Choices.Contains(_value, StringComparer.OrdinalIgnoreCase))
{
- Error = $"valori ammessi: {string.Join(", ", Choices)}";
+ Error = $"'{_value}' non è più disponibile — scegli fra: {string.Join(", ", Choices)}";
}
break;
diff --git a/Encelado/src/Encelado.Bot/Ui/SettingsCatalogue.cs b/Encelado/src/Encelado.Bot/Ui/SettingsCatalogue.cs
index 5421bbd..b857ef4 100644
--- a/Encelado/src/Encelado.Bot/Ui/SettingsCatalogue.cs
+++ b/Encelado/src/Encelado.Bot/Ui/SettingsCatalogue.cs
@@ -1,5 +1,6 @@
using System.Globalization;
using Encelado.Bot.Configuration;
+using Encelado.Core.Strategies;
namespace Encelado.Bot.Ui;
@@ -21,7 +22,7 @@ public static class SettingsCatalogue
{
ArgumentNullException.ThrowIfNull(config);
- return
+ SettingGroup[] gruppi =
[
Strategy(config, strategyName),
Sizing(config),
@@ -30,6 +31,17 @@ public static class SettingsCatalogue
Engine(config),
Logging(config),
];
+
+ // Validati subito, non alla prima modifica. Un valore diventato non valido
+ // perché un aggiornamento ha tolto una strategia deve segnalarsi da sé
+ // all'apertura della pagina: aspettare che qualcuno lo tocchi significa non
+ // dirglielo mai, visto che è proprio il campo che nessuno guarda.
+ foreach (SettingField campo in gruppi.SelectMany(static g => g.Fields))
+ {
+ campo.Validate();
+ }
+
+ return gruppi;
}
// ------------------------------------------------------------------------
@@ -64,21 +76,29 @@ public static class SettingsCatalogue
"mai stati validati su dati ETH.",
});
+ // Scelta e non sola lettura, anche se l'elenco ha una voce sola. Un
+ // aggiornamento che toglie una strategia lascia nel file dell'utente un nome
+ // che non esiste più, e con il campo bloccato l'unico modo per uscirne sarebbe
+ // aprire il JSON a mano. L'elenco propone solo ciò che l'applicazione sa
+ // costruire, quindi da qui non si può scrivere un nome sbagliato.
g.Fields.Add(new SettingField
{
Path = "symbols[0].strategy",
Label = "Strategia",
Initial = strategyName,
- Kind = SettingKind.Text,
- IsReadOnly = true,
- ReadOnlyReason = "ne esiste una sola. Le altre sono state cancellate, non disattivate.",
+ Kind = SettingKind.Choice,
+ Choices = StrategyFactory.Available,
Tooltip =
"Il modello che genera i segnali.\n\n" +
"'trend-filter' compra quando il prezzo sta sopra la media a 100 giorni di una " +
"certa percentuale, e vende quando scende sotto della stessa percentuale. Nient'altro: " +
"nessun trailing stop, nessun target, nessun filtro di volatilità.\n\n" +
"Sette modelli più elaborati sono stati scritti e misurati prima di questo. Tutti " +
- "hanno perso, o contro il mercato o contro il semplice comprare e tenere.",
+ "hanno perso, o contro il mercato o contro il semplice comprare e tenere: per questo " +
+ "l'elenco ne contiene una sola.\n\n" +
+ "Se qui compare un errore, la configurazione porta il nome di una strategia rimossa " +
+ "da un aggiornamento — succede perché l'installazione conserva il tuo encelado.json. " +
+ "Scegli quella disponibile e salva.",
});
g.Fields.Add(new SettingField
diff --git a/Encelado/src/Encelado.Bot/Ui/Theme.xaml b/Encelado/src/Encelado.Bot/Ui/Theme.xaml
index b0de6ae..0e41cde 100644
--- a/Encelado/src/Encelado.Bot/Ui/Theme.xaml
+++ b/Encelado/src/Encelado.Bot/Ui/Theme.xaml
@@ -223,9 +223,13 @@
RelativeSource={RelativeSource TemplatedParent}}">
+
+ BorderBrush="{Binding BorderBrush,
+ RelativeSource={RelativeSource AncestorType=ComboBox}}"
+ BorderThickness="1" CornerRadius="7">
diff --git a/Encelado/tests/Encelado.Tests/EngineTests.cs b/Encelado/tests/Encelado.Tests/EngineTests.cs
index 8355f12..a8276d9 100644
--- a/Encelado/tests/Encelado.Tests/EngineTests.cs
+++ b/Encelado/tests/Encelado.Tests/EngineTests.cs
@@ -188,7 +188,12 @@ public class ConfigValidationTests
config.Symbols[0].Strategy = "moon-phase";
InvalidOperationException ex = Assert.Throws(config.Validate);
- Assert.Contains("unknown strategy", ex.Message, StringComparison.OrdinalIgnoreCase);
+
+ // Il messaggio deve nominare il colpevole, le alternative e la via d'uscita:
+ // questo errore lo incontra chi aggiorna, non chi sviluppa.
+ Assert.Contains("moon-phase", ex.Message, StringComparison.Ordinal);
+ Assert.Contains("trend-filter", ex.Message, StringComparison.Ordinal);
+ Assert.Contains("Impostazioni", ex.Message, StringComparison.Ordinal);
}
[Fact]
diff --git a/Encelado/tests/Encelado.Tests/SettingsFormTests.cs b/Encelado/tests/Encelado.Tests/SettingsFormTests.cs
index d923eda..b4335c0 100644
--- a/Encelado/tests/Encelado.Tests/SettingsFormTests.cs
+++ b/Encelado/tests/Encelado.Tests/SettingsFormTests.cs
@@ -161,6 +161,32 @@ public class ConfigPathWriterTests : IDisposable
Assert.Equal(before, File.ReadAllText(path));
}
+
+ [Fact]
+ public void LoStessoLottoSiPuoApplicareDueVolte()
+ {
+ // La pagina delle impostazioni lo fa sempre: una volta su una copia temporanea
+ // per validare, una sul file vero. Senza clonare, la seconda falliva con
+ // «The node already has a parent» — un JsonNode appartiene a un albero solo.
+ string primo = Write(Sample);
+ string secondo = Write(Sample);
+
+ Dictionary modifiche = new()
+ {
+ ["symbols[0].strategy"] = JsonValue.Create("trend-filter"),
+ ["risk.stakePct"] = JsonValue.Create(0.5),
+ };
+
+ ConfigWriter.Apply(primo, modifiche);
+ ConfigWriter.Apply(secondo, modifiche);
+
+ foreach (string percorso in new[] { primo, secondo })
+ {
+ BotConfig c = ConfigLoader.Load(percorso, out _);
+ Assert.Equal("trend-filter", c.Symbols[0].Strategy);
+ Assert.Equal(0.5, c.Risk.StakePct, 9);
+ }
+ }
}
///
diff --git a/Encelado/tests/Encelado.Tests/ShutdownAndStrategyTests.cs b/Encelado/tests/Encelado.Tests/ShutdownAndStrategyTests.cs
new file mode 100644
index 0000000..07b7041
--- /dev/null
+++ b/Encelado/tests/Encelado.Tests/ShutdownAndStrategyTests.cs
@@ -0,0 +1,355 @@
+using System.ComponentModel;
+using System.Windows;
+using Encelado.Bot.Configuration;
+using Encelado.Bot.Ui;
+using Encelado.Core.Strategies;
+
+namespace Encelado.Tests;
+
+///
+/// Chiudere una finestra mentre uno spegnimento asincrono è in corso.
+///
+/// Il difetto: il gestore annullava la chiusura, aspettava lo spegnimento e poi
+/// richiamava Close(). Un secondo clic sulla X durante l'attesa usciva dal
+/// gestore senza annullare, la finestra entrava nella propria sequenza di
+/// chiusura, e la Close() del primo tentativo ci finiva dentro:
+/// «Non è possibile […] chiamare Close durante la chiusura di un oggetto Window».
+///
+///
+[Collection("wpf")]
+public class WindowShutdownTests
+{
+ ///
+ /// Il meccanismo esatto del difetto: Close() chiamata mentre la finestra è
+ /// dentro la propria sequenza di chiusura non è ammessa, e non lo è nemmeno dopo
+ /// aver annullato — l'annullamento vale per l'uscita dal gestore, non per il tempo
+ /// in cui il gestore sta girando.
+ ///
+ [Fact]
+ public void ChiudereDentroIlGestoreDiChiusuraSollevaEccezione()
+ {
+ WpfRunner.Run(() =>
+ {
+ Window finestra = new();
+ Exception? errore = null;
+
+ finestra.Closing += (_, e) =>
+ {
+ e.Cancel = true;
+
+ try
+ {
+ // È qui che finiva la Close() del primo tentativo quando un secondo
+ // clic la faceva riprendere troppo presto.
+ finestra.Close();
+ }
+ catch (InvalidOperationException ex)
+ {
+ errore = ex;
+ }
+ };
+
+ finestra.Close();
+
+ Assert.NotNull(errore);
+ Assert.Contains("Close", errore!.Message, StringComparison.OrdinalIgnoreCase);
+ });
+ }
+
+ ///
+ /// La correzione: la chiusura vera si rimanda a un frame nuovo del dispatcher, così
+ /// non può mai eseguire dentro il gestore.
+ ///
+ [Fact]
+ public void RimandarlaAUnFrameNuovoNonSollevaNiente()
+ {
+ WpfRunner.Run(() =>
+ {
+ Window finestra = new();
+ Exception? errore = null;
+ bool chiusa = false;
+
+ finestra.Closing += (_, e) =>
+ {
+ if (chiusa)
+ {
+ return;
+ }
+
+ e.Cancel = true;
+
+ _ = finestra.Dispatcher.BeginInvoke(new Action(() =>
+ {
+ try
+ {
+ chiusa = true;
+ finestra.Close();
+ }
+ catch (InvalidOperationException ex)
+ {
+ errore = ex;
+ }
+ }));
+ };
+
+ finestra.Close();
+
+ // Fa girare la coda del dispatcher fino a quando la chiusura rimandata è
+ // stata eseguita.
+ finestra.Dispatcher.Invoke(() => { }, System.Windows.Threading.DispatcherPriority.ApplicationIdle);
+
+ Assert.Null(errore);
+ Assert.True(chiusa);
+ });
+ }
+
+ ///
+ /// La distinzione che serve fra i due tipi di chiusura: quelle dell'utente durante
+ /// lo spegnimento vanno annullate, quella finale no.
+ ///
+ /// Annullarle tutte è il modo ovvio di correggere il difetto originale, ed è
+ /// sbagliato: la chiusura finale ripassa dallo stesso gestore, viene annullata
+ /// anche lei, e la finestra non si chiude più. Il bot resta aperto per sempre —
+ /// un difetto peggiore di quello che si voleva correggere.
+ ///
+ ///
+ [Fact]
+ public void LaChiusuraFinalePassaMentreQuelleDellUtenteNo()
+ {
+ WpfRunner.Run(() =>
+ {
+ Window finestra = new();
+ bool inChiusura = false;
+ bool finale = false;
+ int annullate = 0;
+
+ finestra.Closing += (_, e) =>
+ {
+ if (inChiusura)
+ {
+ if (!finale)
+ {
+ annullate++;
+ e.Cancel = true;
+ }
+
+ return;
+ }
+
+ inChiusura = true;
+ e.Cancel = true;
+ };
+
+ finestra.Close(); // primo tentativo: prende in carico
+ finestra.Close(); // l'utente insiste
+ finestra.Close(); // e ancora
+ Assert.Equal(2, annullate);
+
+ finale = true;
+ finestra.Close(); // la chiusura finale dello spegnimento
+
+ Assert.Equal(2, annullate);
+ Assert.False(finestra.IsVisible);
+ });
+ }
+
+ [Fact]
+ public void UnaChiusuraAnnullataLasciaLaFinestraUtilizzabile()
+ {
+ WpfRunner.Run(() =>
+ {
+ Window finestra = new();
+ int tentativi = 0;
+
+ finestra.Closing += (_, e) =>
+ {
+ tentativi++;
+ e.Cancel = true;
+ };
+
+ finestra.Close();
+ finestra.Close();
+ finestra.Close();
+
+ Assert.Equal(3, tentativi);
+ });
+ }
+}
+
+///
+/// Una strategia rimasta in configurazione dopo che un aggiornamento l'ha rimossa.
+///
+/// Capita perché l'installazione conserva l'encelado.json dell'utente — che è
+/// giusto, le tarature sono sue — quindi un nome tolto dal programma sopravvive nel
+/// file. Deve essere una cosa che si vede e si corregge, non un vicolo cieco.
+///
+///
+public class StrategiaObsoletaTests
+{
+ private static BotConfig ConfigCon(string strategia)
+ {
+ BotConfig c = new();
+
+ // Validate() controlla le credenziali per prime: senza, il test si fermerebbe
+ // lì invece di arrivare al controllo sulla strategia.
+ c.Alpaca.KeyId = "PKTESTTESTTESTTESTTE";
+ c.Alpaca.SecretKey = "segretosegretosegretosegretosegretosegre";
+
+ c.Symbols.Add(new SymbolConfig
+ {
+ Symbol = "BTC/USD",
+ Strategy = strategia,
+ Enabled = true,
+ Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["period"] = 100,
+ ["band"] = 0.02,
+ },
+ });
+
+ return c;
+ }
+
+ private static SettingField CampoStrategia(BotConfig config) =>
+ SettingsCatalogue.Build(config, config.Symbols[0].Strategy)
+ .SelectMany(static g => g.Fields)
+ .First(static f => f.Path == "symbols[0].strategy");
+
+ [Fact]
+ public void IlCampoStrategiaSiSceglieDaUnElenco()
+ {
+ SettingField campo = CampoStrategia(ConfigCon("trend-filter"));
+
+ Assert.True(campo.IsList, "la strategia deve essere un elenco, non un testo libero");
+ Assert.False(campo.IsFreeText);
+ Assert.Equal(StrategyFactory.Available, campo.Options);
+ }
+
+ [Fact]
+ public void LElencoProponeSoloCioCheIlProgrammaSaCostruire()
+ {
+ SettingField campo = CampoStrategia(ConfigCon("trend-filter"));
+
+ Assert.All(campo.Options, static nome =>
+ Assert.True(StrategyFactory.IsKnown(nome), $"'{nome}' è nell'elenco ma non è costruibile"));
+ }
+
+ [Fact]
+ public void IlCampoStrategiaEModificabile()
+ {
+ // Bloccarlo perché la strategia è una sola è esattamente ciò che rendeva
+ // impossibile correggere un valore obsoleto senza aprire il JSON.
+ SettingField campo = CampoStrategia(ConfigCon("trend-filter"));
+
+ Assert.False(campo.IsReadOnly);
+ Assert.True(campo.IsEditable);
+ }
+
+ [Fact]
+ public void UnaStrategiaRimossaSiSegnalaDaSolaAllApertura()
+ {
+ SettingField campo = CampoStrategia(ConfigCon("adaptive-regime"));
+
+ Assert.True(campo.HasError, "il campo doveva segnalare l'errore senza essere toccato");
+ Assert.True(campo.IsObsolete);
+ Assert.Contains("adaptive-regime", campo.Error!, StringComparison.Ordinal);
+ Assert.Contains("trend-filter", campo.Error!, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void LElencoNonRipropoleUnaStrategiaRimossa()
+ {
+ SettingField campo = CampoStrategia(ConfigCon("adaptive-regime"));
+
+ Assert.DoesNotContain("adaptive-regime", campo.Options);
+ }
+
+ [Fact]
+ public void SceglierneUnaValidaRisolveEDiventaSalvabile()
+ {
+ SettingField campo = CampoStrategia(ConfigCon("adaptive-regime"));
+ Assert.True(campo.HasError);
+
+ campo.Value = StrategyFactory.Default;
+
+ Assert.False(campo.HasError);
+ Assert.False(campo.IsObsolete);
+ Assert.True(campo.IsDirty, "la scelta va salvata, quindi deve risultare modificata");
+ Assert.Equal(StrategyFactory.Default, campo.ToJson()!.GetValue());
+ }
+
+ [Fact]
+ public void LaConfigurazioneSpiegaComeUscirneInveceDiDireSoloCheEInvalida()
+ {
+ InvalidOperationException ex =
+ Assert.Throws(() => ConfigCon("adaptive-regime").Validate());
+
+ Assert.Contains("adaptive-regime", ex.Message, StringComparison.Ordinal);
+ Assert.Contains("Impostazioni", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void UnaConfigurazioneValidaNonSiLamenta()
+ {
+ SettingField campo = CampoStrategia(ConfigCon(StrategyFactory.Default));
+
+ Assert.False(campo.HasError);
+ Assert.False(campo.IsObsolete);
+ Assert.False(campo.IsDirty);
+ }
+}
+
+/// I campi con un insieme chiuso di valori non devono essere scrivibili.
+public class CampiAScelaTests
+{
+ private static IReadOnlyList Campi()
+ {
+ BotConfig c = new();
+ c.Symbols.Add(new SymbolConfig { Symbol = "BTC/USD", Strategy = "trend-filter", Enabled = true });
+ return [.. SettingsCatalogue.Build(c, "trend-filter").SelectMany(static g => g.Fields)];
+ }
+
+ [Theory]
+ [InlineData("symbols[0].strategy")]
+ [InlineData("engine.timeFrame")]
+ [InlineData("logging.level")]
+ [InlineData("engine.entryOrderType")]
+ public void SiCompilanoDaUnElenco(string percorso)
+ {
+ SettingField campo = Campi().First(f => f.Path == percorso);
+
+ Assert.True(campo.IsList, $"{percorso} deve essere un elenco");
+ Assert.NotEmpty(campo.Options);
+ }
+
+ [Fact]
+ public void AncheIBooleaniSonoUnElenco()
+ {
+ // "sì" e "no" scritti a mano sono due modi per sbagliare.
+ SettingField campo = Campi().First(static f => f.Path == "engine.dryRun");
+
+ Assert.True(campo.IsList);
+ Assert.Equal(["sì", "no"], campo.Options);
+ }
+
+ [Fact]
+ public void INumeriRestanoDaScrivere()
+ {
+ SettingField campo = Campi().First(static f => f.Path == "risk.stakePct");
+
+ Assert.True(campo.IsFreeText);
+ Assert.Empty(campo.Options);
+ }
+
+ [Fact]
+ public void OgniValoreInizialeDiUnElencoEFraLeOpzioni()
+ {
+ // Con la configurazione di fabbrica nessun campo a scelta deve partire in
+ // errore: se succede, catalogo e valori consegnati sono fuori sincrono.
+ foreach (SettingField campo in Campi().Where(static f => f.IsList && !f.IsReadOnly))
+ {
+ Assert.False(campo.IsObsolete,
+ $"{campo.Path} vale '{campo.Value}' che non è fra {string.Join(", ", campo.Options)}");
+ }
+ }
+}