diff --git a/Mimante/App.xaml.cs b/Mimante/App.xaml.cs index 71252a8..9609d66 100644 --- a/Mimante/App.xaml.cs +++ b/Mimante/App.xaml.cs @@ -23,32 +23,30 @@ namespace AutoBidder // Le impostazioni stanno nella radice fissa, quindi si possono leggere anche // prima di sapere dove finiranno gli altri dati. var settings = SettingsManager.Load(); - AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder, settings.DatabasePath); + AppPaths.Configure(settings.DatabaseFolder); AppPaths.EnsureFolders(); - // Le versioni precedenti scrivevano parte dei dati in Roaming e parte in Local, - // senza sottocartelle: senza questo passaggio storico e aste sparirebbero. - AppPaths.MigrateLegacyFiles(); - - // I registri su file partono subito: quello che succede durante l'avvio è - // proprio ciò che non si riesce a leggere a video. - TextLogService.SessionStarted(AppInfo.Version); - TextLogService.PurgeOldLogs(); - TextLogService.App("INFO", $"[PERCORSI] configurazione in {AppPaths.ConfigRoot}, dati in {AppPaths.DataFolder}"); - - // Il database si apre per primo: da qui in poi ogni archivio ci scrive. - Data.AuctionDatabase.OnLog += m => TextLogService.App("INFO", m); + // I due database si aprono per primi: da qui in poi ogni archivio e ogni + // registro ci scrive. Quello d'esercizio per primo, perché il registro + // applicativo sta lì dentro. + Data.SqliteDatabase.OnLog += m => TextLogService.App("INFO", m); try { + var op = Data.OperationalDatabase.Instance; + TextLogService.SessionStarted(AppInfo.Version); + TextLogService.App("INFO", $"[DATABASE] Esercizio: {op.Path} ({op.SizeBytes / 1024:N0} KB)"); + var db = Data.AuctionDatabase.Instance; - TextLogService.App("INFO", $"[DATABASE] {db.Path} (SQLite {Data.AuctionDatabase.LibraryVersion}, {db.SizeBytes / 1024:N0} KB)"); - db.Enqueue("INSERT INTO sessions(started_at, app_version) VALUES(?1, ?2)", Data.AuctionDatabase.Now(), AppInfo.Version); + TextLogService.App("INFO", $"[DATABASE] Osservazioni: {db.Path} (SQLite {Data.SqliteDatabase.LibraryVersion}, {db.SizeBytes / 1024:N0} KB)"); + db.Enqueue("INSERT INTO sessions(started_at, app_version) VALUES(?1, ?2)", Data.SqliteDatabase.Now(), AppInfo.Version); } catch (Exception ex) { TextLogService.App("ERROR", $"[DATABASE] Apertura non riuscita: {ex.Message}"); } + TextLogService.PurgeOldLogs(); + // Le righe di registro delle aste confluiscono nel database. Data.AuctionRecorder.CaptureAuctionLogs(); @@ -85,13 +83,13 @@ namespace AutoBidder // Quello che è ancora in coda va sul disco adesso: un registro che perde // proprio le ultime righe è inutile esattamente quando serve di più. TextLogService.SessionEnded(); - FileLogWriter.DisposeAll(); try { var db = Data.AuctionDatabase.Instance; - db.Enqueue("UPDATE sessions SET ended_at = ?1 WHERE session_id = (SELECT MAX(session_id) FROM sessions)", Data.AuctionDatabase.Now()); + db.Enqueue("UPDATE sessions SET ended_at = ?1 WHERE session_id = (SELECT MAX(session_id) FROM sessions)", Data.SqliteDatabase.Now()); Data.AuctionDatabase.CloseInstance(); + Data.OperationalDatabase.CloseInstance(); } catch { /* all'uscita non c'è più nessuno a cui dirlo */ } diff --git a/Mimante/Controls/AuctionMonitorControl.xaml b/Mimante/Controls/AuctionMonitorControl.xaml index ed86b24..07b63ae 100644 --- a/Mimante/Controls/AuctionMonitorControl.xaml +++ b/Mimante/Controls/AuctionMonitorControl.xaml @@ -66,17 +66,10 @@ - - - - - + + @@ -188,10 +181,6 @@ ToolTip="Rimuovi tutte le aste" Click="RemoveAllButton_Click"> - @@ -491,11 +480,6 @@ Click="CopyAuctionUrlButton_Click"> - diff --git a/Mimante/Controls/AuctionMonitorControl.xaml.cs b/Mimante/Controls/AuctionMonitorControl.xaml.cs index 4f82bc6..80ee037 100644 --- a/Mimante/Controls/AuctionMonitorControl.xaml.cs +++ b/Mimante/Controls/AuctionMonitorControl.xaml.cs @@ -67,18 +67,18 @@ namespace AutoBidder.Controls RaiseEvent(new RoutedEventArgs(StopClickedEvent, this)); } - private void KillSwitchToggle_Changed(object sender, RoutedEventArgs e) + private void LearningToggle_Changed(object sender, RoutedEventArgs e) { - RaiseEvent(new RoutedEventArgs(KillSwitchChangedEvent, this)); + RaiseEvent(new RoutedEventArgs(LearningToggledEvent, this)); } - public static readonly RoutedEvent KillSwitchChangedEvent = EventManager.RegisterRoutedEvent( - "KillSwitchChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); + public static readonly RoutedEvent LearningToggledEvent = EventManager.RegisterRoutedEvent( + "LearningToggled", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); - public event RoutedEventHandler KillSwitchChanged + public event RoutedEventHandler LearningToggled { - add { AddHandler(KillSwitchChangedEvent, value); } - remove { RemoveHandler(KillSwitchChangedEvent, value); } + add { AddHandler(LearningToggledEvent, value); } + remove { RemoveHandler(LearningToggledEvent, value); } } private void AddUrlButton_Click(object sender, RoutedEventArgs e) @@ -96,11 +96,6 @@ namespace AutoBidder.Controls RaiseEvent(new RoutedEventArgs(RemoveAllClickedEvent, this)); } - private void ExportButton_Click(object sender, RoutedEventArgs e) - { - RaiseEvent(new RoutedEventArgs(ExportClickedEvent, this)); - } - private void RemoveFinishedButton_Click(object sender, RoutedEventArgs e) { RaiseEvent(new RoutedEventArgs(RemoveFinishedClickedEvent, this)); @@ -337,10 +332,6 @@ namespace AutoBidder.Controls RaiseEvent(new RoutedEventArgs(OpenAuctionExternalClickedEvent, this)); } - private void ExportAuctionButton_Click(object sender, RoutedEventArgs e) - { - RaiseEvent(new RoutedEventArgs(ExportAuctionClickedEvent, this)); - } private void RefreshProductInfoButton_Click(object sender, RoutedEventArgs e) { @@ -410,9 +401,6 @@ namespace AutoBidder.Controls public static readonly RoutedEvent RemoveAllClickedEvent = EventManager.RegisterRoutedEvent( "RemoveAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); - public static readonly RoutedEvent ExportClickedEvent = EventManager.RegisterRoutedEvent( - "ExportClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); - public static readonly RoutedEvent AuctionSelectionChangedEvent = EventManager.RegisterRoutedEvent( "AuctionSelectionChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); @@ -449,8 +437,6 @@ namespace AutoBidder.Controls public static readonly RoutedEvent OpenAuctionExternalClickedEvent = EventManager.RegisterRoutedEvent( "OpenAuctionExternalClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); - public static readonly RoutedEvent ExportAuctionClickedEvent = EventManager.RegisterRoutedEvent( - "ExportAuctionClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); public static readonly RoutedEvent RefreshProductInfoClickedEvent = EventManager.RegisterRoutedEvent( "RefreshProductInfoClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); @@ -507,12 +493,6 @@ namespace AutoBidder.Controls remove { RemoveHandler(RemoveFinishedClickedEvent, value); } } - public event RoutedEventHandler ExportClicked - { - add { AddHandler(ExportClickedEvent, value); } - remove { RemoveHandler(ExportClickedEvent, value); } - } - public event RoutedEventHandler AuctionSelectionChanged { add { AddHandler(AuctionSelectionChangedEvent, value); } @@ -597,12 +577,6 @@ namespace AutoBidder.Controls remove { RemoveHandler(OpenAuctionExternalClickedEvent, value); } } - public event RoutedEventHandler ExportAuctionClicked - { - add { AddHandler(ExportAuctionClickedEvent, value); } - remove { RemoveHandler(ExportAuctionClickedEvent, value); } - } - public event RoutedEventHandler RefreshProductInfoClicked { add { AddHandler(RefreshProductInfoClickedEvent, value); } diff --git a/Mimante/Controls/ExportControl.xaml b/Mimante/Controls/ExportControl.xaml deleted file mode 100644 index 297bd18..0000000 --- a/Mimante/Controls/ExportControl.xaml +++ /dev/null @@ -1,248 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + @@ -144,7 +141,7 @@ - + @@ -209,7 +206,7 @@ + Help="Il rapporto dell'ultima valutazione sulle aste registrate in questo database. Le misure che contano: il sollevamento (quanto più spesso le puntate a probabilità alta erano davvero finali, rispetto al caso), la calibrazione per fasce (la probabilità prevista deve somigliare a quella osservata), e cosa il cancello avrebbe fatto sulle tue puntate vere."/> Refresh(); - private void OpenFolderButton_Click(object sender, RoutedEventArgs e) - { - try - { - var folder = Path.Combine(AppPaths.StatsFolder, "Apprendimento"); - Directory.CreateDirectory(folder); - Process.Start(new ProcessStartInfo { FileName = folder, UseShellExecute = true }); - } - catch (Exception ex) - { - MessageBox.Show($"Cartella non apribile: {ex.Message}", "Apprendimento", - MessageBoxButton.OK, MessageBoxImage.Warning); - } - } - private async void EvaluateButton_Click(object sender, RoutedEventArgs e) { if (_evaluation != null) @@ -317,12 +321,14 @@ namespace AutoBidder.Controls } _evaluation = new CancellationTokenSource(); - EvaluateButton.Content = "Annulla"; - EvaluationBox.Text = "Valutazione in corso: lettura dei dossier…"; + // Il pulsante è solo icona: mentre gira diventa una X, e il tooltip lo dice. + EvaluateGlyph.Text = "\uE711"; + EvaluateButton.ToolTip = "Annulla la valutazione in corso."; + EvaluationBox.Text = "Valutazione in corso: lettura delle aste…"; try { - // Al massimo tremila dossier, i più recenti: bastano a giudicare, e la + // Al massimo tremila aste, le più recenti: bastano a giudicare, e la // lettura di tutto l'archivio in sottofondo mentre le aste corrono non serve. var report = await LearningService.EvaluateAsync(3000, msg => Dispatcher.BeginInvoke(() => EvaluationBox.Text = "Valutazione in corso: " + msg), @@ -341,15 +347,16 @@ namespace AutoBidder.Controls finally { _evaluation = null; - EvaluateButton.Content = "Valuta ora"; + EvaluateGlyph.Text = "\uE9F5"; + EvaluateButton.ToolTip = "Valuta ora.\nAddestra sulle aste più vecchie del database e giudica sulle più recenti, mai viste. Poi la prova prequenziale: ogni asta prima prevista e poi appresa, come fa il motore. Qualche minuto in sottofondo; il rapporto compare nella scheda «Valutazione»."; } } private async void RetrainButton_Click(object sender, RoutedEventArgs e) { var answer = MessageBox.Show( - "Butto modello, profilo ed elenco dei dossier letti, e ricomincio a studiare tutto l'archivio.\n\n" + - "I dossier e lo storico non vengono toccati. Ci vorranno un paio di minuti in sottofondo.\n\nProcedo?", + "Butto modello, profilo, sfidante, bandit e contatore delle aste studiate, e ricomincio a studiare tutte le aste chiuse del database.\n\n" + + "Lo storico e i prodotti non vengono toccati. Ci vorranno un paio di minuti in sottofondo.\n\nProcedo?", "Ricomincia da capo", MessageBoxButton.YesNo, MessageBoxImage.Warning); if (answer != MessageBoxResult.Yes) return; diff --git a/Mimante/Controls/ProductsControl.xaml b/Mimante/Controls/ProductsControl.xaml index afcbc03..caae560 100644 --- a/Mimante/Controls/ProductsControl.xaml +++ b/Mimante/Controls/ProductsControl.xaml @@ -155,7 +155,7 @@ - + - - + - + - + - + + + diff --git a/Mimante/Controls/ProductsControl.xaml.cs b/Mimante/Controls/ProductsControl.xaml.cs index 8df7d8f..e493803 100644 --- a/Mimante/Controls/ProductsControl.xaml.cs +++ b/Mimante/Controls/ProductsControl.xaml.cs @@ -135,6 +135,15 @@ namespace AutoBidder.Controls private void ClearButton_Click(object sender, RoutedEventArgs e) => RaiseEvent(new RoutedEventArgs(ClearClickedEvent, this)); + private void ClearAllButton_Click(object sender, RoutedEventArgs e) + => RaiseEvent(new RoutedEventArgs(ClearAllClickedEvent, this)); + + private void UnwatchAllButton_Click(object sender, RoutedEventArgs e) + => RaiseEvent(new RoutedEventArgs(UnwatchAllClickedEvent, this)); + + private void RemoveUnwatchedButton_Click(object sender, RoutedEventArgs e) + => RaiseEvent(new RoutedEventArgs(RemoveUnwatchedClickedEvent, this)); + private void ApplySuggestedButton_Click(object sender, RoutedEventArgs e) => RaiseEvent(new RoutedEventArgs(ApplySuggestedClickedEvent, this)); @@ -158,6 +167,15 @@ namespace AutoBidder.Controls public static readonly RoutedEvent ClearClickedEvent = EventManager.RegisterRoutedEvent( "ClearClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl)); + public static readonly RoutedEvent ClearAllClickedEvent = EventManager.RegisterRoutedEvent( + "ClearAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl)); + + public static readonly RoutedEvent UnwatchAllClickedEvent = EventManager.RegisterRoutedEvent( + "UnwatchAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl)); + + public static readonly RoutedEvent RemoveUnwatchedClickedEvent = EventManager.RegisterRoutedEvent( + "RemoveUnwatchedClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl)); + public static readonly RoutedEvent ApplySuggestedClickedEvent = EventManager.RegisterRoutedEvent( "ApplySuggestedClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl)); @@ -200,6 +218,24 @@ namespace AutoBidder.Controls remove { RemoveHandler(ClearClickedEvent, value); } } + public event RoutedEventHandler ClearAllClicked + { + add { AddHandler(ClearAllClickedEvent, value); } + remove { RemoveHandler(ClearAllClickedEvent, value); } + } + + public event RoutedEventHandler UnwatchAllClicked + { + add { AddHandler(UnwatchAllClickedEvent, value); } + remove { RemoveHandler(UnwatchAllClickedEvent, value); } + } + + public event RoutedEventHandler RemoveUnwatchedClicked + { + add { AddHandler(RemoveUnwatchedClickedEvent, value); } + remove { RemoveHandler(RemoveUnwatchedClickedEvent, value); } + } + public event RoutedEventHandler ApplySuggestedClicked { add { AddHandler(ApplySuggestedClickedEvent, value); } diff --git a/Mimante/Controls/SettingsControl.xaml b/Mimante/Controls/SettingsControl.xaml index 5e4fcf4..9da8a5d 100644 --- a/Mimante/Controls/SettingsControl.xaml +++ b/Mimante/Controls/SettingsControl.xaml @@ -53,9 +53,9 @@ - + - + @@ -365,7 +365,7 @@ L'elenco dei prodotti è nella scheda Prodotti: lì ogni articolo ha la stellina + ToolTip="Aggiungi da solo soltanto le aste su cui nessuno ha ancora puntato, per poterle seguire dal primo istante all'ultimo. Un'asta presa a metà si può seguire, ma la sua storia è già scritta per buona parte: non si sa quanto è durata davvero, chi c'era prima, come è salito il prezzo. Le statistiche che ne escono sono monche e la registrazione racconta solo la seconda metà. Il prezzo da pagare è che si perdono le occasioni già in corso."/> @@ -380,59 +380,6 @@ L'elenco dei prodotti è nella scheda Prodotti: lì ogni articolo ha la stellina - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -588,17 +468,41 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m + + + + + + + + + + + - + + + + + + @@ -316,7 +325,9 @@ Background="{DynamicResource Brush.Surface}" BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1" CornerRadius="12"> + SelectionMode="Extended" SelectionUnit="FullRow" + Background="Transparent" Margin="4" + PreviewKeyDown="StatsDetailGrid_PreviewKeyDown"> @@ -355,17 +366,11 @@ ConnectSessionClicked="Settings_ConnectSessionClicked" DisconnectSessionClicked="Settings_DisconnectSessionClicked" OpenBrowserLoginClicked="Settings_OpenBrowserLoginClicked" - ComputeBidLeadAdviceClicked="Settings_ComputeBidLeadAdviceClicked" - ClearBidLeadStatsClicked="Settings_ClearBidLeadStatsClicked" - BrowseDataFolderClicked="Settings_BrowseDataFolderClicked" - BrowseStatsFolderClicked="Settings_BrowseStatsFolderClicked" - OpenDataFolderClicked="Settings_OpenDataFolderClicked" - OpenStatsFolderClicked="Settings_OpenStatsFolderClicked" - BrowseLogFolderClicked="Settings_BrowseLogFolderClicked" - OpenLogFolderClicked="Settings_OpenLogFolderClicked" - BrowseDatabaseFileClicked="Settings_BrowseDatabaseFileClicked" - OpenDatabaseFolderClicked="Settings_OpenDatabaseFolderClicked" - KillSwitchChanged="Settings_KillSwitchChanged"/> + BrowseDatabaseFolderClicked="Settings_BrowseDatabaseFolderClicked" + WipeAllDataClicked="Settings_WipeAllDataClicked" + ResetSettingsClicked="Settings_ResetSettingsClicked" + ExportAppLogClicked="Settings_ExportAppLogClicked" + OpenDatabaseFolderClicked="Settings_OpenDatabaseFolderClicked"/> \ No newline at end of file diff --git a/Mimante/MainWindow.xaml.cs b/Mimante/MainWindow.xaml.cs index 1b44550..f652cdc 100644 --- a/Mimante/MainWindow.xaml.cs +++ b/Mimante/MainWindow.xaml.cs @@ -102,7 +102,7 @@ namespace AutoBidder // Inizializza servizi _auctionMonitor = new AuctionMonitor(); Learning.AuctionsProvider = () => _auctionMonitor.GetAuctions(); - RefreshKillSwitchToggle(); + RefreshLearningToggle(); // ? NUOVO: Inizializza SessionService InitializeSessionService(); @@ -220,25 +220,35 @@ namespace AutoBidder /// /// Costruisce le schede prodotto la prima volta, ricavandole dallo storico già - /// raccolto. + /// raccolto, e le ricostruisce una volta sola quando cambia il modo di contare. /// /// Senza, chi ha già mesi di aste alle spalle aprirebbe un file vuoto e /// dovrebbe aspettare le aste future per vedere qualcosa — proprio i numeri che - /// servono adesso per decidere i limiti. Gira una sola volta e su un thread di - /// lavoro: rileggere l'archivio all'avvio non deve ritardare la finestra. + /// servono adesso per decidere i limiti. Gira su un thread di lavoro: rileggere + /// l'archivio all'avvio non deve ritardare la finestra. + /// + /// Il contrassegno nel database d'esercizio dice con quale «generazione» le + /// schede sono state contate. La 2 è quella in cui la chiusura di un'asta arriva + /// una volta sola: prima ogni asta conclusa veniva contata due volte, e le schede + /// costruite allora vanno rifatte dallo storico, che è sempre stato giusto. /// private void EnsureProductCardsBuilt() { + const string Marker = "products-generation"; + const string Generation = "2"; + try { - if (System.IO.File.Exists(AppPaths.ProductsFile)) return; + var op = Data.OperationalDatabase.Instance; + if (ProductStatsStore.GetAll().Count > 0 && op.GetDocument(Marker) == Generation) return; _ = System.Threading.Tasks.Task.Run(() => { var count = ProductStatsStore.RebuildFromHistory(); + op.SetDocument(Marker, Generation); Dispatcher.BeginInvoke(() => - Log($"[PRODOTTI] Schede prodotto costruite dallo storico ({count} aste)", LogLevel.Info)); + Log($"[PRODOTTI] Schede prodotto ricostruite dallo storico ({count} aste)", LogLevel.Info)); }); } catch (Exception ex) diff --git a/Mimante/Ml/LEGGIMI.md b/Mimante/Ml/LEGGIMI.md index 8c644ec..83d396d 100644 --- a/Mimante/Ml/LEGGIMI.md +++ b/Mimante/Ml/LEGGIMI.md @@ -40,10 +40,18 @@ obbligatoria; le aste in *Attiva* puntano davvero, dentro i paletti del gestore ## 1. Cosa si registra: il database -Un solo file SQLite (`%LocalAppData%\AutoBidder\Database\autobidder.sqlite`, cambiabile -dalle Impostazioni; mai in una cartella sincronizzata). Le scritture vanno in coda e un -thread dedicato le svuota a blocchi in transazioni: nulla sul percorso della puntata -aspetta il disco. Le letture usano una seconda connessione con giornale WAL. +Due file SQLite in `%LocalAppData%\AutoBidder\Database` (cartella cambiabile dalle +Impostazioni, con spostamento dei file e riavvio; mai una cartella sincronizzata): + +- `autobidder.sqlite` — le **osservazioni**: le tabelle qui sotto. +- `esercizio.sqlite` — l'**esercizio**: i documenti JSON dell'applicazione (aste nel + monitor, prodotti seguiti, statistiche per prodotto, promozioni riscattate, modelli + appresi con prefisso `ml/`) e i registri applicativo e del riscatto puntate. I file + JSON delle versioni precedenti vengono importati la prima volta e lasciati dove sono. + +Le scritture vanno in coda e un thread dedicato per database le svuota a blocchi in +transazioni: nulla sul percorso della puntata aspetta il disco. Le letture usano una +seconda connessione con giornale WAL. Il motore comune è `SqliteDatabase`. | tabella | cosa contiene | |---|---| @@ -85,9 +93,11 @@ il «Compralo Ora»), `Δ = 0,01 €`, `fee ≈ 1 €`, `c` il costo della punta **Copertura «Compralo Ora».** Chi perde può comprare al listino `L` recuperando le puntate spese come credito: la perdita finale, se si esercita, è `L − V` qualunque sia il numero di -puntate. Quindi `c_perdita = c` finché `spese + c ≤ L − V`, zero dopo. Per un buono al -nominale (`V = L`) la copertura è totale dal primo centesimo. È un'ipotesi forte, e per -questo è un'impostazione (`Copertura «Compralo Ora»`), accesa per scelta dell'utente. +puntate. Quindi `c_perdita = c` finché `spese + c ≤ L − V`, zero dopo. Con `V = L` la +copertura è totale dal primo centesimo e si punterebbe sempre, a qualunque probabilità: in +shadow è successo (BID con P dell'1%). Per questo la copertura è un'impostazione +(`Copertura «Compralo Ora»`) **e vale solo per i prodotti con «Valore reale €» scritto**: +con V lasciato al listino la puntata si conta persa, come senza copertura. **Null-model.** Profitto atteso per puntata = 0; numero critico di puntatori `V/c` (Hinnosaar). **Sopravvivenza empirica:** dalla tabella `resets`, P(vittoria) a un dato @@ -109,13 +119,19 @@ dell'istante di fuoco: QUANDO: anticipo adattivo ``` -1. **Rischio** — `RiskManager.Authorize`: kill-switch (file `KILL_SWITCH` in - `%LocalAppData%\AutoBidder`, anche dalla barra del monitor), HALT persistente, stop-loss - del giorno, drawdown dal picco, tetto di spesa del giorno, aste in gioco insieme. Lo - stop-loss e il drawdown mettono in HALT, che sopravvive al riavvio e si toglie a mano. - La contabilità è in `risk_ledger`: ogni puntata riuscita un'uscita, ogni vincita +1. **Rischio** — `RiskManager.Authorize`: HALT persistente, stop-loss del giorno, + drawdown dal picco, tetto di spesa del giorno, aste in gioco insieme. Lo stop-loss e + il drawdown mettono in HALT, che sopravvive al riavvio e si toglie a mano. La + contabilità è in `risk_ledger`: ogni puntata riuscita un'uscita, ogni vincita un'entrata al valore reale meno prezzo, fee e spedizione. 2. **Paletti** — fascia oraria sospesa (globale, per prodotto, per asta), tetti di puntate. + + **L'interruttore dell'apprendimento** (il robot nella barra del monitor, accanto ad + Avvia/Osserva/Ferma; `LearningEnabled`) taglia qui la pipeline: spento, i passi 3–5 non + girano e l'anticipo è quello fisso delle impostazioni. Si punta solo entro i limiti + dell'utente — prezzo minimo e massimo, puntate, budget, fascia oraria, rischio — come + un cecchino senza cervello. Le aste vengono comunque registrate e studiate: quando lo + si riaccende il modello non ha perso niente. 3. **Duello** — cinque risposte di fila con la firma dell'autopuntata (6 ± 2,5 s) = macchina armata dall'altra parte; si smette e si informa il regime. 4. **Valore atteso** — il modello dà P(senza risposta); `Theory` la trasforma in euro. @@ -217,8 +233,11 @@ si spegne il vivo. La posizione di default è che un vantaggio consistente sia i Scheda **Apprendimento**: stato e pesi del modello, ultime decisioni, profilo per prodotto, valutazione, autonomia sul momento (latenza e regime per asta), shadow (policy a confronto, -campione/sfidante, bandit), simulatore. Scheda **Impostazioni › Gestione del rischio**: -paletti, kill-switch, HALT e contabilità. Scheda **Prodotti**: colonna «Valore reale €». +campione/sfidante, bandit), simulatore. Barra del **monitor**: l'interruttore +dell'apprendimento. Scheda **Impostazioni › Gestione del rischio**: paletti, HALT e +contabilità; **Impostazioni › Database** e **Manutenzione**: cartella dei database, «Elimina +tutti i dati (tranne la login)», «Impostazioni di fabbrica». Scheda **Prodotti**: colonna +«Valore reale €». Nel registro di ogni asta: `[SHADOW] Avrei puntato…`, `[REGIME] Calmo → Sfogo…`, `[TIMING] … anticipo adattivo…`, `⛔ Strategia blocca: …`. @@ -238,8 +257,8 @@ Nel registro di ogni asta: `[SHADOW] Avrei puntato…`, `[REGIME] Calmo → Sfog | | stato | |---|---| -| M1 rischio, kill-switch, HALT, shadow per asta | fatto | -| M2 database SQLite, registrazione, sessioni, misure di rete | fatto (si parte da zero) | +| M1 rischio, HALT, interruttore dell'apprendimento, shadow per asta | fatto | +| M2 database SQLite (osservazioni + esercizio), registrazione, sessioni, misure di rete | fatto (si parte da zero) | | M3 teoria, simulatore con avversari tarati, rigiocata | fatto | | M4 modelli: logistico, rete neurale, profilo, hazard/Kaplan-Meier, Brier | fatto | | M5 policy: valore atteso + regime, bandit Thompson, agente Q conservativo | fatto (Q solo nel simulatore) | diff --git a/Mimante/Ml/LatencyModel.cs b/Mimante/Ml/LatencyModel.cs index 535d360..8453e1d 100644 --- a/Mimante/Ml/LatencyModel.cs +++ b/Mimante/Ml/LatencyModel.cs @@ -51,7 +51,7 @@ namespace AutoBidder.Ml private static int _bidsTotal; private static bool _loaded; - private static string FilePath => Path.Combine(AppPaths.StatsFolder, "Apprendimento", "latenza.json"); + private const string DocKey = "ml/latenza"; // ── Alimentazione ──────────────────────────────────────────────── @@ -163,8 +163,9 @@ namespace AutoBidder.Ml _loaded = true; try { - if (!File.Exists(FilePath)) return; - var s = JsonSerializer.Deserialize(File.ReadAllText(FilePath)); + var json = Data.OperationalDatabase.Instance.GetDocumentOrImport(DocKey, Path.Combine(AppPaths.LegacyLearningFolder, "latenza.json")); + if (json == null) return; + var s = JsonSerializer.Deserialize(json); if (s == null) return; _marginMs = Math.Clamp(s.MarginMs, 150, 2000); _lateBids = s.LateBids; @@ -190,8 +191,7 @@ namespace AutoBidder.Ml RoundTrips = _roundTrips.ToArray() }); } - Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); - File.WriteAllText(FilePath, json); + Data.OperationalDatabase.Instance.SetDocument(DocKey, json); } catch { /* la prossima puntata riproverà */ } } @@ -200,7 +200,7 @@ namespace AutoBidder.Ml public static void Forget() { ResetForTests(); - try { if (File.Exists(FilePath)) File.Delete(FilePath); } catch { } + try { Data.OperationalDatabase.Instance.DeleteDocument(DocKey); } catch { } } /// Solo per i test. diff --git a/Mimante/Ml/LearningService.cs b/Mimante/Ml/LearningService.cs index 6ff369d..f775e83 100644 --- a/Mimante/Ml/LearningService.cs +++ b/Mimante/Ml/LearningService.cs @@ -90,14 +90,21 @@ namespace AutoBidder.Ml // ── Percorsi ───────────────────────────────────────────────────── - private static string Folder => Path.Combine(AppPaths.StatsFolder, "Apprendimento"); - private static string ModelFile => Path.Combine(Folder, "modello-puntate.json"); - private static string ProfileFile => Path.Combine(Folder, "profilo-aste.json"); - private static string ChallengerFile => Path.Combine(Folder, "sfidante-rete.json"); - private static string BanditFile => Path.Combine(Folder, "bandit.json"); - private static string BrierFile => Path.Combine(Folder, "brier.txt"); - private static string CounterFile => Path.Combine(Folder, "aste-apprese.txt"); - private static string LegacyManifestFile => Path.Combine(Folder, "dossier-appresi.txt"); + // Tutto ciò che il modello sa vive nel database d'esercizio, come documenti con + // prefisso «ml/». I file della cartella Apprendimento delle versioni precedenti + // vengono importati la prima volta. + private const string DocModel = "ml/modello-puntate"; + private const string DocProfile = "ml/profilo-aste"; + private const string DocChallenger = "ml/sfidante-rete"; + private const string DocBandit = "ml/bandit"; + private const string DocBrier = "ml/brier"; + private const string DocCounter = "ml/aste-apprese"; + private const string DocEvaluation = "ml/valutazione"; + + private static OperationalDatabase Docs => OperationalDatabase.Instance; + + private static string? Doc(string key, string legacyFileName) => + Docs.GetDocumentOrImport(key, Path.Combine(AppPaths.LegacyLearningFolder, legacyFileName)); // ── Stato, per l'interfaccia e il registro ─────────────────────── @@ -178,12 +185,10 @@ namespace AutoBidder.Ml // ── Valutazione dall'applicazione ──────────────────────────────── - private static string EvaluationFile => Path.Combine(Folder, "valutazione.txt"); - /// L'ultimo rapporto di valutazione salvato, se c'è. public static string? LastEvaluation() { - try { return File.Exists(EvaluationFile) ? File.ReadAllText(EvaluationFile) : null; } + try { return Doc(DocEvaluation, "valutazione.txt"); } catch { return null; } } @@ -200,8 +205,7 @@ namespace AutoBidder.Ml try { - Directory.CreateDirectory(Folder); - lock (SaveSync) File.WriteAllText(EvaluationFile, $"Valutazione del {DateTime.Now:dd/MM/yyyy HH:mm}\n\n" + report.Text); + Docs.SetDocument(DocEvaluation, $"Valutazione del {DateTime.Now:dd/MM/yyyy HH:mm}\n\n" + report.Text); } catch { /* il rapporto e' comunque restituito */ } @@ -235,6 +239,7 @@ namespace AutoBidder.Ml /// public static void Forget() { + lock (Sync) _closedSeen.Clear(); lock (Sync) { _model = new OnlineLogit(BidFeatures.Size); @@ -249,11 +254,7 @@ namespace AutoBidder.Ml try { - lock (SaveSync) - { - foreach (var f in new[] { ModelFile, ProfileFile, CounterFile, LegacyManifestFile, EvaluationFile, ChallengerFile, BanditFile, BrierFile }) - if (File.Exists(f)) File.Delete(f); - } + lock (SaveSync) Docs.DeleteDocuments("ml/"); } catch (Exception ex) { @@ -282,17 +283,17 @@ namespace AutoBidder.Ml try { - if (File.Exists(ModelFile)) - _model = OnlineLogit.FromJson(File.ReadAllText(ModelFile), BidFeatures.Size); - if (File.Exists(ProfileFile)) - _profile = AuctionProfileStats.FromJson(File.ReadAllText(ProfileFile)); - if (File.Exists(ChallengerFile)) - _challenger = NeuralNet.FromJson(File.ReadAllText(ChallengerFile), new[] { BidFeatures.Size, 32, 16, 1 }); - if (File.Exists(BanditFile)) - _bandit = ThompsonBandit.FromJson(File.ReadAllText(BanditFile)); - if (File.Exists(BrierFile)) + if (Doc(DocModel, "modello-puntate.json") is { } model) + _model = OnlineLogit.FromJson(model, BidFeatures.Size); + if (Doc(DocProfile, "profilo-aste.json") is { } profile) + _profile = AuctionProfileStats.FromJson(profile); + if (Doc(DocChallenger, "sfidante-rete.json") is { } challenger) + _challenger = NeuralNet.FromJson(challenger, new[] { BidFeatures.Size, 32, 16, 1 }); + if (Doc(DocBandit, "bandit.json") is { } bandit) + _bandit = ThompsonBandit.FromJson(bandit); + if (Doc(DocBrier, "brier.txt") is { } brierText) { - var parts = File.ReadAllText(BrierFile).Split(';'); + var parts = brierText.Split(';'); if (parts.Length >= 3 && double.TryParse(parts[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var bl) && double.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var bn) && @@ -322,15 +323,26 @@ namespace AutoBidder.Ml /// private static int ReadCounter() { - foreach (var f in new[] { CounterFile, LegacyManifestFile }) + // Il contatore nuovo, oppure quello delle versioni precedenti, che stava in + // testa all'elenco dei dossier letti: non si azzera un modello che ha già + // studiato migliaia di aste. + var text = Doc(DocCounter, "aste-apprese.txt"); + if (text == null) { - if (!File.Exists(f)) continue; - foreach (var line in File.ReadLines(f)) + try { - var l = line.Trim(); - if (l.StartsWith("#appresi=", StringComparison.Ordinal) && int.TryParse(l["#appresi=".Length..], out var n)) return n; - if (int.TryParse(l, out var plain)) return plain; + var legacy = Path.Combine(AppPaths.LegacyLearningFolder, "dossier-appresi.txt"); + if (File.Exists(legacy)) text = File.ReadAllText(legacy); } + catch { } + } + if (string.IsNullOrEmpty(text)) return 0; + + foreach (var line in text.Split('\n')) + { + var l = line.Trim(); + if (l.StartsWith("#appresi=", StringComparison.Ordinal) && int.TryParse(l["#appresi=".Length..], out var n)) return n; + if (int.TryParse(l, out var plain)) return plain; } return 0; } @@ -356,13 +368,12 @@ namespace AutoBidder.Ml lock (SaveSync) { - Directory.CreateDirectory(Folder); - File.WriteAllText(ModelFile, model); - File.WriteAllText(ProfileFile, profile); - File.WriteAllText(ChallengerFile, challenger); - File.WriteAllText(BanditFile, bandit); - File.WriteAllText(BrierFile, brier); - File.WriteAllText(CounterFile, "#appresi=" + learned + Environment.NewLine); + Docs.SetDocument(DocModel, model); + Docs.SetDocument(DocProfile, profile); + Docs.SetDocument(DocChallenger, challenger); + Docs.SetDocument(DocBandit, bandit); + Docs.SetDocument(DocBrier, brier); + Docs.SetDocument(DocCounter, "#appresi=" + learned); } } catch (Exception ex) @@ -527,6 +538,19 @@ namespace AutoBidder.Ml // ── Chiusura di un'asta: si impara subito ──────────────────────── + private static readonly HashSet _closedSeen = new(StringComparer.Ordinal); + + /// Vero se l'asta ha già learned_at nel database: studiata in una sessione precedente. + private static bool AlreadyLearned(string auctionId) + { + try + { + Db.Flush(); + return Db.ScalarString("SELECT learned_at FROM auctions WHERE auction_id = ?1", auctionId) != null; + } + catch { return false; } + } + /// /// Da chiamare quando un'asta è chiusa e la sua registrazione è stata completata. /// Aggiorna il profilo con lo storico appena registrato e il modello con le @@ -534,7 +558,21 @@ namespace AutoBidder.Ml /// public static void OnAuctionClosed(CompletedAuctionRecord record) { - if (record == null) return; + if (record == null || string.IsNullOrEmpty(record.AuctionId)) return; + + // Una volta per asta. La chiusura arrivava due volte (fine e rimozione dal + // monitor) e, al riavvio, di nuovo per le aste concluse rimaste in elenco: + // il modello, il profilo e il bandit le contavano due volte. + lock (Sync) + { + if (!_closedSeen.Add(record.AuctionId)) return; + } + + if (AlreadyLearned(record.AuctionId)) + { + OnLog?.Invoke($"[APPRENDIMENTO] '{record.Name}': già appresa, non la ristudio"); + return; + } try { @@ -546,8 +584,8 @@ namespace AutoBidder.Ml // Le righe dell'asta sono in coda: prima devono essere sul disco. Db.Flush(); - var learned = !string.IsNullOrEmpty(record.AuctionId) && IngestAuction(record.AuctionId); - var decisioni = string.IsNullOrEmpty(record.AuctionId) ? 0 : LearnBanditFromAuction(record.AuctionId); + var learned = IngestAuction(record.AuctionId); + var decisioni = LearnBanditFromAuction(record.AuctionId); Save(); diff --git a/Mimante/Ml/Theory.cs b/Mimante/Ml/Theory.cs index 5e3c075..8083727 100644 --- a/Mimante/Ml/Theory.cs +++ b/Mimante/Ml/Theory.cs @@ -67,11 +67,13 @@ namespace AutoBidder.Ml /// già coperto. Quindi il costo marginale nel ramo di sconfitta è c finché /// spese + c ≤ L − V, zero dopo. /// - /// Per i buoni Bidoo Shop con V = L la copertura è totale dal primo - /// centesimo: ogni puntata ha valore atteso non negativo appena il margine è - /// positivo. È un'ipotesi forte — presume che si eserciti davvero il Compralo Ora - /// e che il buono valga davvero il suo nominale — e per questo è un'impostazione - /// dell'utente, non un dato. + /// Con V = L la copertura è totale dal primo centesimo: ogni puntata ha + /// valore atteso non negativo appena il margine è positivo, a qualunque + /// probabilità — cioè si punta sempre. È un'ipotesi forte — presume che si + /// eserciti davvero il Compralo Ora e che il bene valga davvero il listino — e per + /// questo la strategia la applica solo quando l'utente ha scritto il valore reale + /// del prodotto (BidStrategyService.HasExplicitValue): con V lasciato al + /// listino la puntata si conta persa, come senza copertura. /// public static double BidExpectedValue(in BidSituation s) { diff --git a/Mimante/Models/AuctionInfo.cs b/Mimante/Models/AuctionInfo.cs index d56da70..7ad95ca 100644 --- a/Mimante/Models/AuctionInfo.cs +++ b/Mimante/Models/AuctionInfo.cs @@ -644,6 +644,13 @@ namespace AutoBidder.Models [JsonIgnore] public int LastUsedOffsetMs { get; set; } = 0; + /// + /// La chiusura è già stata comunicata a storico, statistiche e apprendimento. + /// Si salva con l'asta: un'asta conclusa che resta in elenco fino al riavvio non + /// deve essere contata di nuovo. + /// + public bool CompletionReported { get; set; } + /// /// Indica se questa asta � stata seguita dall'inizio (per salvare storia completa) /// diff --git a/Mimante/Services/AuctionMonitor.cs b/Mimante/Services/AuctionMonitor.cs index 9cb3d43..f2fc14e 100644 --- a/Mimante/Services/AuctionMonitor.cs +++ b/Mimante/Services/AuctionMonitor.cs @@ -152,7 +152,7 @@ namespace AutoBidder.Services try { var won = finalState.Status == AuctionStatus.EndedWon || IsAuctionWonByUser(auction); - OnAuctionCompleted?.Invoke(auction, finalState, won); + RaiseCompleted(auction, finalState, won); } catch (Exception ex) { @@ -456,7 +456,7 @@ namespace AutoBidder.Services // Il dossier registra tutto ciò che passa di qui: è materiale che esiste // solo adesso. var pollSettings = Settings; - AuctionRecorder.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls); + AuctionRecorder.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.RecordPolls); if (state.RecentBidsHistory != null && state.RecentBidsHistory.Count > 0) { @@ -583,21 +583,6 @@ namespace AutoBidder.Services rttMs: result?.LatencyMs ?? 0, decisionId: auction.PendingDecisionId); - // L'anticipo effettivo dice se il valore impostato e' quello giusto: da solo - // nel log non serviva a nulla, sommato su molte puntate diventa una taratura. - if (Settings.BidLeadTrackingEnabled && result != null) - { - BidLeadStats.Record(new BidLeadStats.Sample - { - At = DateTime.Now, - AuctionId = auction.AuctionId, - ConfiguredMs = leadMs, - ActualMs = actualLeadMs, - PingMs = auction.AverageLatencyMs, - Success = result.Success, - TooLate = !result.Success && IsLateBidError(result.Error) - }); - } } /// Il server ha rifiutato perche' il tempo era gia' scaduto. @@ -686,7 +671,7 @@ namespace AutoBidder.Services try { - OnAuctionCompleted?.Invoke(auction, state, won); + RaiseCompleted(auction, state, won); } catch (Exception ex) { @@ -694,6 +679,25 @@ namespace AutoBidder.Services } } + private readonly object _completionGate = new(); + + /// + /// Comunica la chiusura di un'asta una volta sola. L'asta finisce (stato + /// terminale) e poco dopo viene tolta dall'elenco, e tutti e due i passaggi + /// arrivavano qui: storico, statistiche per prodotto, profilo e modello la + /// contavano due volte, e il bandit rivedeva gli stessi esiti. + /// + private void RaiseCompleted(AuctionInfo auction, AuctionState state, bool won) + { + lock (_completionGate) + { + if (auction.CompletionReported) return; + auction.CompletionReported = true; + } + + OnAuctionCompleted?.Invoke(auction, state, won); + } + /// /// Aggiorna il valore calcolato del prodotto /// diff --git a/Mimante/Services/BidStrategyService.cs b/Mimante/Services/BidStrategyService.cs index ddb2956..8862df2 100644 --- a/Mimante/Services/BidStrategyService.cs +++ b/Mimante/Services/BidStrategyService.cs @@ -47,6 +47,23 @@ namespace AutoBidder.Services return scheda?.MarketValue is > 0 ? scheda.MarketValue : auction.BuyNowPrice; } + /// + /// Vero se l'utente ha scritto il valore reale del prodotto nella scheda Prodotti. + /// + /// Conta per la copertura «Compralo Ora»: senza un valore scelto, V coincide + /// con il listino L, la perdita coperta L − V è zero e ogni puntata risulterebbe + /// gratis nel ramo di sconfitta — cioè si punterebbe sempre, a qualunque + /// probabilità. Nelle osservazioni in shadow è successo esattamente questo: BID + /// con P(senza risposta) dell'1%. La copertura vale solo quando l'utente ha + /// detto quanto vale davvero il bene. + /// + public static bool HasExplicitValue(AuctionInfo auction) + { + if (auction == null) return false; + var scheda = WatchedProductsStore.FindByName(auction.Name); + return scheda?.MarketValue is > 0; + } + public BidDecision ShouldPlaceBid( AuctionInfo auction, AuctionState state, @@ -92,6 +109,18 @@ namespace AutoBidder.Services } } + // ── Apprendimento spento: solo i limiti ──────────────────────── + // + // L'interruttore in barra. Spento, da qui in poi non si decide più niente: + // né duello, né valore atteso, né regime, né bandit. Si punta, entro i limiti + // già controllati sopra, come un cecchino senza cervello. + if (!settings.LearningEnabled) + { + decision.Policy = "solo-limiti"; + decision.Reason = "apprendimento spento: si punta entro i limiti (prezzo, puntate, budget, fascia oraria)"; + return decision; + } + // ── 3. Duello con l'autopuntata ──────────────────────────────── if (auction.AutoBidDuelDetected) { @@ -134,7 +163,7 @@ namespace AutoBidder.Services Shipping: auction.ShippingCost ?? 0, ListPrice: auction.BuyNowPrice, BidsSpentEuro: speso, - HedgeEnabled: settings.BuyNowHedgeEnabled); + HedgeEnabled: settings.BuyNowHedgeEnabled && HasExplicitValue(auction)); var valore = Theory.BidExpectedValue(situazione); diff --git a/Mimante/Services/FreeBidsPageParser.cs b/Mimante/Services/FreeBidsPageParser.cs index 471dfd8..3c1e737 100644 --- a/Mimante/Services/FreeBidsPageParser.cs +++ b/Mimante/Services/FreeBidsPageParser.cs @@ -47,11 +47,18 @@ namespace AutoBidder.Services if (html.Length < 200) return FreeBidsPageScan.Unrecognised($"risposta troppo corta ({html.Length} caratteri)"); - if (LooksLikeLogin(html, config)) + // Prima si guarda se è la pagina delle ricompense: anche da collegati la + // pagina porta un collegamento a /login.php (menu, modulo nascosto), e + // giudicarla «pagina di accesso» per quello dava una sessione scaduta che + // non c'era. Solo una pagina senza nessun segno delle ricompense, e con i + // segni dell'accesso, è davvero il modulo di login. + var markers = config.PageRecognition.PageMarkers; + var looksLikeRewards = markers.Count > 0 && markers.Any(m => Contains(html, m)); + + if (!looksLikeRewards && LooksLikeLogin(html, config)) return FreeBidsPageScan.Unrecognised("sessione scaduta: Bidoo ha risposto con la pagina di accesso"); - var markers = config.PageRecognition.PageMarkers; - if (markers.Count > 0 && !markers.Any(m => Contains(html, m))) + if (markers.Count > 0 && !looksLikeRewards) return FreeBidsPageScan.Unrecognised("pagina delle ricompense non riconosciuta (formato cambiato?)"); var patterns = PatternsFor(config); diff --git a/Mimante/Services/RiskManager.cs b/Mimante/Services/RiskManager.cs index 3282971..6eafb8b 100644 --- a/Mimante/Services/RiskManager.cs +++ b/Mimante/Services/RiskManager.cs @@ -15,10 +15,7 @@ namespace AutoBidder.Services /// Quattro vincoli, tutti in euro o in numero, tutti dell'utente. /// Il tetto di spesa giornaliero, lo stop-loss giornaliero (quando la perdita netta /// del giorno supera la soglia si entra in HALT), il drawdown massimo dal picco del - /// saldo cumulato, e il numero massimo di aste su cui puntare insieme. Sopra a tutti, - /// il kill-switch: un file sentinella in %LocalAppData%\AutoBidder che, - /// finché esiste, ferma ogni puntata dal vivo — lo si può creare anche da fuori, - /// senza aprire l'applicazione. + /// saldo cumulato, e il numero massimo di aste su cui puntare insieme. /// /// HALT è persistente. Si scrive nel database e sopravvive al riavvio: /// una regola di sicurezza che si azzera chiudendo e riaprendo il programma non è una @@ -47,45 +44,6 @@ namespace AutoBidder.Services private static string? _haltReason; private static bool _haltLoaded; - private static DateTime _killSwitchCheckedAt = DateTime.MinValue; - private static bool _killSwitchState; - - // ── Kill-switch ────────────────────────────────────────────────── - - /// Il file sentinella: finché esiste, nessuna puntata dal vivo. - public static string KillSwitchFile => Path.Combine(AppPaths.Root, "KILL_SWITCH"); - - /// Vero se il file c'è. Si rilegge al massimo una volta al secondo. - public static bool KillSwitchActive - { - get - { - lock (Sync) - { - if ((DateTime.UtcNow - _killSwitchCheckedAt).TotalSeconds >= 1) - { - _killSwitchState = File.Exists(KillSwitchFile); - _killSwitchCheckedAt = DateTime.UtcNow; - } - return _killSwitchState; - } - } - } - - public static void SetKillSwitch(bool on) - { - lock (Sync) - { - try - { - if (on) File.WriteAllText(KillSwitchFile, $"creato il {DateTime.Now:yyyy-MM-dd HH:mm:ss}\n"); - else if (File.Exists(KillSwitchFile)) File.Delete(KillSwitchFile); - } - catch { } - _killSwitchState = on; - _killSwitchCheckedAt = DateTime.UtcNow; - } - } // ── HALT ───────────────────────────────────────────────────────── @@ -206,16 +164,13 @@ namespace AutoBidder.Services // ── Autorizzazione ─────────────────────────────────────────────── /// - /// Può partire una puntata dal vivo su quest'asta? Controlla, nell'ordine: il - /// kill-switch, l'HALT, lo stop-loss del giorno, il drawdown, il tetto di spesa - /// del giorno, le aste in gioco insieme. Le prime due sono assolute; le altre - /// valgono solo se impostate (0 = nessun limite). + /// Può partire una puntata dal vivo su quest'asta? Controlla, nell'ordine: + /// l'HALT, lo stop-loss del giorno, il drawdown, il tetto di spesa del giorno, le + /// aste in gioco insieme. Il primo è assoluto; gli altri valgono solo se impostati + /// (0 = nessun limite). /// public static Verdict Authorize(AuctionInfo auction, AppSettings settings, IReadOnlyList? allAuctions = null) { - if (KillSwitchActive) - return new Verdict(false, "kill-switch attivo: nessuna puntata dal vivo finché esiste il file KILL_SWITCH"); - if (Halted) return new Verdict(false, $"HALT del gestore del rischio: {HaltReason}. Si toglie dalle Impostazioni"); @@ -271,7 +226,6 @@ namespace AutoBidder.Services _ledgerLoaded = false; _haltLoaded = false; _haltReason = null; - _killSwitchCheckedAt = DateTime.MinValue; } } } diff --git a/Mimante/Tests/AuctionRecorderTests.cs b/Mimante/Tests/AuctionRecorderTests.cs index 186e066..5d7db0d 100644 --- a/Mimante/Tests/AuctionRecorderTests.cs +++ b/Mimante/Tests/AuctionRecorderTests.cs @@ -21,8 +21,8 @@ public class AuctionRecorderTests private static AppSettings Settings(bool rawPolls = true) => new() { - WriteAuctionDossiers = true, - DossierIncludeRawPolls = rawPolls + RecordAuctions = true, + RecordPolls = rawPolls }; private static AuctionInfo NewAuction() => new() @@ -249,6 +249,6 @@ public class AuctionRecorderTests public void Con_la_registrazione_spenta_non_si_apre_nulla() { var auction = NewAuction(); - Assert.Null(AuctionRecorder.OpenFor(auction, new AppSettings { WriteAuctionDossiers = false })); + Assert.Null(AuctionRecorder.OpenFor(auction, new AppSettings { RecordAuctions = false })); } } diff --git a/Mimante/Tests/BidLeadStatsTests.cs b/Mimante/Tests/BidLeadStatsTests.cs deleted file mode 100644 index f268e1a..0000000 --- a/Mimante/Tests/BidLeadStatsTests.cs +++ /dev/null @@ -1,223 +0,0 @@ -using AutoBidder.Models; -using AutoBidder.Utilities; -using Xunit; - -namespace AutoBidder.Tests; - -/// -/// La taratura dell'anticipo tocca il parametro che decide se una puntata arriva o no. -/// Un consiglio troppo aggressivo si paga in aste perse, quindi la proprietà da -/// verificare è la prudenza: mai scendere sotto il margine di rete. -/// -public class BidLeadAdviceTests -{ - // Le prove usano lo store reale, quindi si parte sempre da pulito. - private static void Reset() => BidLeadStats.Clear(); - - private static void Add(int configured, double actual, double ping = 60, bool ok = true, bool late = false) - => BidLeadStats.Record(new BidLeadStats.Sample - { - AuctionId = "t", - ConfiguredMs = configured, - ActualMs = actual, - PingMs = ping, - Success = ok, - TooLate = late - }); - - [Fact] - public void Senza_dati_sufficienti_non_da_consigli() - { - Reset(); - for (var i = 0; i < 5; i++) Add(800, 700); - - var advice = BidLeadStats.Suggest(currentLeadMs: 800, minSamples: 15); - - Assert.False(advice.HasAdvice); - Assert.Equal(800, advice.SuggestedLeadMs); - } - - [Fact] - public void Con_margine_ampio_propone_di_scendere() - { - Reset(); - // Le puntate arrivano sempre ~1700 ms prima della scadenza, ping 60 ms: - // c'è spazio abbondante sopra il margine di sicurezza. - for (var i = 0; i < 20; i++) Add(1800, 1700 + i % 5, ping: 60); - - var advice = BidLeadStats.Suggest(1800, minSamples: 15); - - Assert.True(advice.HasAdvice); - Assert.True(advice.SuggestedLeadMs < 1800, "avrebbe dovuto proporre di scendere"); - Assert.True(advice.SuggestedLeadMs >= BidLeadStats.SafeFloorMs, - "non deve scendere sotto il pavimento ricavato dai dossier"); - } - - [Fact] - public void Non_scende_mai_sotto_il_pavimento_anche_con_ping_minimo() - { - Reset(); - // Ping bassissimo e puntate larghissime: la vecchia regola avrebbe proposto - // un anticipo di poche centinaia di ms, dove basta un picco di rete per - // arrivare a giochi chiusi (p99,9 del ping misurato: 444 ms). - for (var i = 0; i < 20; i++) Add(3000, 2900, ping: 10); - - var advice = BidLeadStats.Suggest(3000, minSamples: 15); - - Assert.True(advice.SuggestedLeadMs >= BidLeadStats.SafeFloorMs, - $"proposto {advice.SuggestedLeadMs}, sotto il pavimento di {BidLeadStats.SafeFloorMs} ms"); - } - - [Fact] - public void Senza_misure_avverte_comunque_se_lanticipo_e_fuori_fascia() - { - Reset(); - - // Troppo stretto: il rischio è la puntata che arriva tardi. - var stretto = BidLeadStats.Suggest(200, minSamples: 15); - Assert.True(stretto.HasAdvice); - Assert.Equal(BidLeadStats.SafeFloorMs, stretto.SuggestedLeadMs); - - // Troppo largo: il costo sono le puntate spese in cicli che si sarebbero - // risolti da soli. - var largo = BidLeadStats.Suggest(4000, minSamples: 15); - Assert.True(largo.HasAdvice); - Assert.Equal(BidLeadStats.SafeCeilingMs, largo.SuggestedLeadMs); - - // Dentro la fascia, senza misure non c'è nulla da dire. - var giusto = BidLeadStats.Suggest(1000, minSamples: 15); - Assert.False(giusto.HasAdvice); - } - - [Fact] - public void Non_scende_mai_sotto_il_margine_di_rete() - { - Reset(); - // Ping alto: il margine di sicurezza è grande e va rispettato. - for (var i = 0; i < 20; i++) Add(2000, 1900, ping: 400); - - var advice = BidLeadStats.Suggest(2000, minSamples: 15); - - if (advice.HasAdvice) - { - // safety = ping (400) + 150 = 550 - Assert.True(advice.SuggestedLeadMs >= 550, - $"proposto {advice.SuggestedLeadMs}, sotto il margine di sicurezza"); - } - } - - [Fact] - public void Con_puntate_tardive_propone_di_allargare() - { - Reset(); - for (var i = 0; i < 18; i++) Add(300, 120, ping: 90); - for (var i = 0; i < 3; i++) Add(300, -20, ping: 90, ok: false, late: true); - - var advice = BidLeadStats.Suggest(300, minSamples: 15); - - Assert.True(advice.HasAdvice); - Assert.True(advice.SuggestedLeadMs > 300, "con puntate tardive deve allargare, non stringere"); - } - - [Fact] - public void Se_l_anticipo_e_gia_stretto_non_propone_nulla() - { - Reset(); - // Anticipo 250 ms, ping 60: margine 210, quasi nessuno spazio. - for (var i = 0; i < 20; i++) Add(250, 240, ping: 60); - - var advice = BidLeadStats.Suggest(250, minSamples: 15); - - Assert.False(advice.HasAdvice); - } - - [Fact] - public void Il_riepilogo_conta_riuscite_e_tardive() - { - Reset(); - for (var i = 0; i < 10; i++) Add(800, 700); - Add(800, -5, ok: false, late: true); - - var s = BidLeadStats.Summarize(); - - Assert.Equal(11, s.Count); - Assert.Equal(10, s.Successes); - Assert.Equal(1, s.TooLate); - - // La media considera solo le puntate accettate: una rifiutata non è mai arrivata. - Assert.Equal(700, s.AverageActualMs, 1); - } -} - -/// -/// La scheda dettagliata è la materia prima per migliorare le strategie: i valori -/// derivati devono essere corretti, o le analisi partiranno da numeri sbagliati. -/// -public class AuctionDetailRecordTests -{ - [Fact] - public void La_quota_del_puntatore_principale_e_una_percentuale() - { - var bids = new Dictionary { ["tizio"] = 6, ["caio"] = 3, ["sempronio"] = 1 }; - - Assert.Equal(60.0, AuctionDetailRecord.ComputeTopShare(bids), 1); - } - - [Fact] - public void Senza_puntatori_la_quota_e_zero() - { - Assert.Equal(0, AuctionDetailRecord.ComputeTopShare(new Dictionary())); - } - - [Fact] - public void Il_costo_reale_somma_prezzo_puntate_e_spedizione() - { - var r = new AuctionDetailRecord { FinalPrice = 12.50, MyBids = 40, ShippingCost = 5.90 }; - - // 12,50 + 40 x 0,20 + 5,90 = 26,40 - Assert.Equal(26.40, r.TotalCostIfWon(0.20), 2); - } - - [Fact] - public void Il_rapporto_col_valore_prodotto_manca_se_il_valore_e_ignoto() - { - Assert.Null(new AuctionDetailRecord { FinalPrice = 10 }.FinalPriceRatio); - Assert.Equal(0.1, new AuctionDetailRecord { FinalPrice = 10, BuyNowPrice = 100 }.FinalPriceRatio!.Value, 3); - } -} - -/// La serie prezzi si raccoglie solo mentre l'asta vive: va registrata bene. -public class PriceSeriesTests -{ - [Fact] - public void Registra_solo_i_cambi_di_prezzo() - { - var a = new AuctionInfo(); - - a.TrackPrice(1.00); - a.TrackPrice(1.00); // identico: ignorato - a.TrackPrice(1.02); - - Assert.Equal(2, a.SnapshotPriceSeries().Count); - } - - [Fact] - public void Ignora_i_prezzi_non_validi() - { - var a = new AuctionInfo(); - a.TrackPrice(0); - a.TrackPrice(-1); - - Assert.Empty(a.SnapshotPriceSeries()); - } - - [Fact] - public void Non_cresce_oltre_il_tetto() - { - var a = new AuctionInfo(); - for (var i = 1; i <= 1500; i++) a.TrackPrice(i * 0.01); - - // Il diradamento tiene la serie sotto controllo senza perderne la forma. - Assert.True(a.SnapshotPriceSeries().Count <= 600); - } -} diff --git a/Mimante/Tests/ExportAndLogTests.cs b/Mimante/Tests/ExportAndLogTests.cs index 3eb473e..42a4ebd 100644 --- a/Mimante/Tests/ExportAndLogTests.cs +++ b/Mimante/Tests/ExportAndLogTests.cs @@ -13,7 +13,7 @@ namespace AutoBidder.Tests; /// un'asta sbagliata non si nota — si notano invece le conclusioni sbagliate che se ne /// traggono un mese dopo, quando non c'è più modo di capire da dove venivano. /// -public class AuctionExportFilterTests +public class AuctionDetailRecordTests { private static AuctionDetailRecord Record( string id = "1", @@ -67,145 +67,6 @@ public class AuctionExportFilterTests Assert.Equal(0.1, withValue.FinalPriceRatio!.Value, 3); } - [Fact] - public void I_filtri_predefiniti_lasciano_passare_tutto() - { - var filter = new AuctionExportFilter(); - - Assert.True(filter.IncludeWon); - Assert.True(filter.IncludeLost); - Assert.True(filter.IncludeClosed); - Assert.False(filter.OnlyComplete); - Assert.Equal(0, filter.MaxAuctions); - } - - [Fact] - public void Il_percorso_predefinito_sta_nella_cartella_esportazioni() - { - var path = AuctionExporter.DefaultPath(); - - Assert.StartsWith(AppPaths.ExportFolder, path); - Assert.EndsWith(".json", path); - } - - [Fact] - public void Senza_storico_lesportazione_lo_dice_invece_di_scrivere_un_file_vuoto() - { - var result = AuctionExporter.Export(new AuctionExportFilter - { - // Un periodo in cui non può esserci nulla. - From = new DateTime(1990, 1, 1), - To = new DateTime(1990, 1, 2) - }); - - Assert.False(result.Success); - Assert.Equal(0, result.AuctionCount); - Assert.Contains("nessuna asta", result.Message); - } -} - -/// -/// Il registro passa da una coda: chi scrive non aspetta il disco. La proprietà da -/// difendere è che le righe arrivino comunque nel file, e nell'ordine giusto — un registro -/// che perde o mescola le righe è peggio di nessun registro, perché lo si legge credendogli. -/// -public class FileLogWriterTests -{ - private static string TempFile() => - Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", $"log-{Guid.NewGuid():N}.log"); - - [Fact] - public void Quello_che_si_scrive_finisce_nel_file() - { - var path = TempFile(); - var writer = FileLogWriter.For(path); - - writer.Write("prima"); - writer.Write("seconda"); - writer.Flush(); - - var lines = File.ReadAllLines(path); - - Assert.Equal(new[] { "prima", "seconda" }, lines); - - writer.Dispose(); - } - - [Fact] - public void Lo_stesso_percorso_da_sempre_lo_stesso_scrittore() - { - // Due scrittori sullo stesso file mescolerebbero le righe fra loro. - var path = TempFile(); - - Assert.Same(FileLogWriter.For(path), FileLogWriter.For(path)); - - FileLogWriter.For(path).Dispose(); - } - - [Fact] - public void Le_righe_si_accodano_a_quelle_gia_scritte() - { - var path = TempFile(); - - var first = FileLogWriter.For(path); - first.Write("sessione 1"); - first.Flush(); - first.Dispose(); - - var second = FileLogWriter.For(path); - second.Write("sessione 2"); - second.Flush(); - second.Dispose(); - - Assert.Equal(new[] { "sessione 1", "sessione 2" }, File.ReadAllLines(path)); - } - - [Fact] - public void Un_percorso_impossibile_non_solleva() - { - // Il registro non deve mai poter interrompere un'asta. - var writer = FileLogWriter.For(Path.Combine("Z:\\", "inesistente", "x.log")); - - writer.Write("qualcosa"); - writer.Flush(); - writer.Dispose(); - } - - [Fact] - public void I_registri_scaduti_si_cancellano_e_quelli_recenti_no() - { - var folder = Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(folder); - - var old = Path.Combine(folder, "app-2020-01-01.log"); - var recent = Path.Combine(folder, "app-oggi.log"); - - File.WriteAllText(old, "vecchio"); - File.WriteAllText(recent, "recente"); - File.SetLastWriteTime(old, DateTime.Now.AddDays(-120)); - - var removed = FileLogWriter.PurgeOlderThan(folder, "app-*.log", days: 90); - - Assert.Equal(1, removed); - Assert.False(File.Exists(old)); - Assert.True(File.Exists(recent)); - } - - [Fact] - public void Con_zero_giorni_non_si_cancella_nulla() - { - // 0 = "tienili per sempre": interpretarlo come "cancellali tutti" distruggerebbe - // esattamente i dati che si voleva conservare. - var folder = Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(folder); - - var file = Path.Combine(folder, "app-vecchio.log"); - File.WriteAllText(file, "x"); - File.SetLastWriteTime(file, DateTime.Now.AddYears(-3)); - - Assert.Equal(0, FileLogWriter.PurgeOlderThan(folder, "app-*.log", days: 0)); - Assert.True(File.Exists(file)); - } } /// diff --git a/Mimante/Tests/HedgeRequiresValueTests.cs b/Mimante/Tests/HedgeRequiresValueTests.cs new file mode 100644 index 0000000..a4bc8da --- /dev/null +++ b/Mimante/Tests/HedgeRequiresValueTests.cs @@ -0,0 +1,59 @@ +using System; +using AutoBidder.Ml; +using AutoBidder.Models; +using AutoBidder.Services; +using AutoBidder.Utilities; +using Xunit; + +namespace AutoBidder.Tests; + +/// +/// La copertura «Compralo Ora» con V lasciato al listino rende gratis ogni puntata: la +/// strategia la concede solo ai prodotti con il valore reale scritto dall'utente. +/// +[Collection("Database")] +public class HedgeRequiresValueTests +{ + [Fact] + public void Con_V_uguale_al_listino_la_copertura_rende_gratis_ogni_puntata() + { + // È il caso degenere: P dell'uno per mille e valore atteso comunque positivo. + var s = new Theory.BidSituation(PWin: 0.001, Value: 11, CurrentPrice: 1.48, BidCost: 0.20, + TransactionFee: 1, Shipping: 0, ListPrice: 11, BidsSpentEuro: 0, HedgeEnabled: true); + Assert.Equal(0, Theory.LossBranchCost(s)); + Assert.True(Theory.BidExpectedValue(s) > 0); + + // Senza copertura la stessa puntata è in perdita: P deve superare c / (guadagno + c). + var senza = s with { HedgeEnabled = false }; + Assert.Equal(0.20, Theory.LossBranchCost(senza)); + Assert.True(Theory.BidExpectedValue(senza) < 0); + } + + [Fact] + public void La_copertura_vale_solo_con_il_valore_reale_scritto() + { + var nome = $"Buono di prova {Guid.NewGuid():N}"; + var catalogo = new CatalogAuction { AuctionId = "prova-copertura", Name = nome, BuyNowPrice = 11m, ProductId = 987654 }; + Assert.True(WatchedProductsStore.Add(catalogo)); + + try + { + var asta = new AuctionInfo { Name = nome, BuyNowPrice = 11 }; + + Assert.False(BidStrategyService.HasExplicitValue(asta)); + Assert.Equal(11, BidStrategyService.ValueOf(asta)); + + var scheda = WatchedProductsStore.FindByName(nome); + Assert.NotNull(scheda); + scheda!.MarketValue = 9.5; + WatchedProductsStore.Persist(); + + Assert.True(BidStrategyService.HasExplicitValue(asta)); + Assert.Equal(9.5, BidStrategyService.ValueOf(asta)); + } + finally + { + WatchedProductsStore.Remove(catalogo); + } + } +} diff --git a/Mimante/Tests/LearningDoubleCloseTests.cs b/Mimante/Tests/LearningDoubleCloseTests.cs new file mode 100644 index 0000000..90c7ded --- /dev/null +++ b/Mimante/Tests/LearningDoubleCloseTests.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutoBidder.Data; +using AutoBidder.Ml; +using AutoBidder.Models; +using Xunit; + +namespace AutoBidder.Tests; + +/// +/// La chiusura di un'asta arrivava due volte all'apprendimento (fine, poi rimozione dal +/// monitor) e di nuovo al riavvio: la stessa asta va studiata una volta sola. +/// +[Collection("Database")] +public class LearningDoubleCloseTests +{ + [Fact] + public void Una_asta_gia_appresa_non_viene_ristudiata() + { + var db = AuctionDatabase.Instance; + var id = "doppia-" + Guid.NewGuid().ToString("N"); + var now = AuctionDatabase.Now(); + db.ExecuteNow( + "INSERT INTO auctions(auction_id, product_key, name, added_at, first_seen_at, opened_at, status, outcome, learned_at) " + + "VALUES(?1, 'prova', 'Prova doppia', ?2, ?2, ?2, 'closed', 'Persa', ?2)", id, now); + + var apprese = LearningService.AuctionsLearned; + var profilo = LearningService.ProfileAuctions; + var messaggi = new List(); + void Raccogli(string m) { lock (messaggi) messaggi.Add(m); } + LearningService.OnLog += Raccogli; + + try + { + var record = new CompletedAuctionRecord { AuctionId = id, Name = "Prova doppia", ProductKey = "prova", EndedAt = DateTime.Now }; + LearningService.OnAuctionClosed(record); + LearningService.OnAuctionClosed(record); + } + finally + { + LearningService.OnLog -= Raccogli; + } + + Assert.Equal(apprese, LearningService.AuctionsLearned); + Assert.Equal(profilo, LearningService.ProfileAuctions); + + // La prima chiamata vede learned_at e lo dice; la seconda si ferma prima ancora + // di guardare il database. + Assert.Equal(1, messaggi.Count(m => m.Contains("già appresa"))); + } +} diff --git a/Mimante/Tests/OperationalDatabaseTests.cs b/Mimante/Tests/OperationalDatabaseTests.cs new file mode 100644 index 0000000..81750c1 --- /dev/null +++ b/Mimante/Tests/OperationalDatabaseTests.cs @@ -0,0 +1,110 @@ +using System; +using System.IO; +using System.Linq; +using AutoBidder.Data; +using AutoBidder.Utilities; +using Xunit; + +namespace AutoBidder.Tests; + +/// +/// Il database d'esercizio: documenti per chiave, importazione una tantum dei vecchi +/// file JSON, registri che si scrivono, si rileggono, si esportano e scadono. +/// +[Collection("Database")] +public class OperationalDatabaseTests +{ + private static OperationalDatabase Db => OperationalDatabase.Instance; + + [Fact] + public void Un_documento_si_scrive_e_si_rilegge() + { + var key = "test/" + Guid.NewGuid().ToString("N"); + Db.SetDocument(key, "{\"a\":1}"); + Assert.Equal("{\"a\":1}", Db.GetDocument(key)); + + Db.SetDocument(key, "{\"a\":2}"); + Assert.Equal("{\"a\":2}", Db.GetDocument(key)); + + Db.DeleteDocument(key); + Db.Flush(); + Assert.Null(Db.GetDocument(key)); + } + + [Fact] + public void Il_vecchio_file_si_importa_una_volta_sola() + { + var key = "test/import-" + Guid.NewGuid().ToString("N"); + var legacy = Path.Combine(Path.GetTempPath(), $"legacy-{Guid.NewGuid():N}.json"); + File.WriteAllText(legacy, "{\"vecchio\":true}"); + + try + { + Assert.Equal("{\"vecchio\":true}", Db.GetDocumentOrImport(key, legacy)); + + // Il file cambia, ma il documento è già nel database: comanda il database. + File.WriteAllText(legacy, "{\"vecchio\":false}"); + Assert.Equal("{\"vecchio\":true}", Db.GetDocumentOrImport(key, legacy)); + Assert.True(File.Exists(legacy)); // resta dov'è + } + finally { File.Delete(legacy); } + } + + [Fact] + public void Senza_documento_ne_file_si_ha_null() + { + Assert.Null(Db.GetDocumentOrImport("test/niente-" + Guid.NewGuid().ToString("N"), Path.Combine(Path.GetTempPath(), "non-esiste.json"))); + } + + [Fact] + public void Il_registro_si_scrive_si_rilegge_e_si_esporta() + { + var marker = "riga-" + Guid.NewGuid().ToString("N"); + Db.WriteLog(OperationalDatabase.AppLog, "INFO", marker); + Db.WriteLog(OperationalDatabase.AppLog, "WARN", marker + "-due"); + + var rows = Db.ReadLog(OperationalDatabase.AppLog, 10); + Assert.Contains(rows, r => r.Message == marker && r.Level == "INFO"); + Assert.Equal(marker + "-due", rows[0].Message); // dalla più recente + + var file = Path.Combine(Path.GetTempPath(), $"log-{Guid.NewGuid():N}.txt"); + try + { + var n = Db.ExportLog(OperationalDatabase.AppLog, file); + Assert.True(n >= 2); + var text = File.ReadAllText(file); + Assert.Contains(marker, text); + Assert.Contains("[WARN]", text); + } + finally { File.Delete(file); } + } + + [Fact] + public void Le_righe_vecchie_scadono() + { + var old = SqliteDatabase.Iso(DateTime.Now.AddDays(-200)); + Db.ExecuteNow($"INSERT INTO {OperationalDatabase.FreeBidsLog}(ts, level, message) VALUES(?1, 'INFO', 'antica')", old); + Db.WriteLog(OperationalDatabase.FreeBidsLog, "INFO", "recente"); + + var removed = Db.PurgeLogs(90); + + Assert.True(removed >= 1); + var rows = Db.ReadLog(OperationalDatabase.FreeBidsLog, 100); + Assert.DoesNotContain(rows, r => r.Message == "antica"); + Assert.Contains(rows, r => r.Message == "recente"); + } + + [Fact] + public void Le_aste_nel_monitor_vivono_nel_documento() + { + PersistenceManager.SaveAuctions(new[] + { + new Models.AuctionInfo { AuctionId = "doc-1", Name = "Uno" }, + new Models.AuctionInfo { AuctionId = "doc-2", Name = "Due" } + }); + + var back = PersistenceManager.LoadAuctions(); + Assert.Equal(2, back.Count); + Assert.Contains(back, a => a.AuctionId == "doc-2" && a.Name == "Due"); + } +} diff --git a/Mimante/Tests/RiskManagerTests.cs b/Mimante/Tests/RiskManagerTests.cs index c52d43e..0e1d31f 100644 --- a/Mimante/Tests/RiskManagerTests.cs +++ b/Mimante/Tests/RiskManagerTests.cs @@ -24,29 +24,11 @@ public class RiskManagerTests private static void Pulito() { - RiskManager.SetKillSwitch(false); AuctionDatabase.Instance.ExecuteNow("DELETE FROM risk_ledger"); AuctionDatabase.Instance.ExecuteNow("DELETE FROM meta WHERE key = 'risk_halt'"); RiskManager.ResetForTests(); } - [Fact] - public void Il_kill_switch_ferma_ogni_puntata() - { - Pulito(); - RiskManager.SetKillSwitch(true); - try - { - var v = RiskManager.Authorize(Auction("a"), new AppSettings()); - Assert.False(v.Allowed); - Assert.Contains("kill-switch", v.Reason); - Assert.True(File.Exists(RiskManager.KillSwitchFile)); - } - finally { RiskManager.SetKillSwitch(false); } - - Assert.True(RiskManager.Authorize(Auction("a"), new AppSettings()).Allowed); - } - [Fact] public void Lo_stop_loss_porta_in_halt_e_l_halt_resta() { diff --git a/Mimante/Tests/StatsWipeTests.cs b/Mimante/Tests/StatsWipeTests.cs index b1a51f5..c0ab93e 100644 --- a/Mimante/Tests/StatsWipeTests.cs +++ b/Mimante/Tests/StatsWipeTests.cs @@ -6,39 +6,35 @@ using Xunit; namespace AutoBidder.Tests; /// -/// L'azzeramento tocca solo le voci scelte, e riconosce gli archivi mensili delle -/// versioni precedenti dal nome senza confonderli con il resto della cartella. +/// L'azzeramento tocca solo le voci scelte: gli archivi delle versioni precedenti sono +/// la vecchia cartella Dati, e senza voci scelte non succede nulla. /// [Collection("Database")] public class StatsWipeTests { [Fact] - public void Toglie_gli_archivi_mensili_e_lascia_il_resto() + public void Toglie_la_vecchia_cartella_dei_dati_e_lascia_il_resto() { - var stats = AppPaths.StatsFolder; - Directory.CreateDirectory(stats); + var legacy = AppPaths.LegacyDataFolder; + Directory.CreateDirectory(Path.Combine(legacy, "Statistiche")); + var vecchio = Path.Combine(legacy, "Statistiche", "aste-2020-01.jsonl"); + File.WriteAllText(vecchio, "{}\n"); - var legacy = Path.Combine(stats, "aste-2020-01.jsonl"); - var legacyJson = Path.Combine(stats, "aste-2020-02.json"); - var altro = Path.Combine(stats, $"altro-{Guid.NewGuid():N}.json"); - - File.WriteAllText(legacy, "{}\n"); - File.WriteAllText(legacyJson, "[]"); + var altro = Path.Combine(AppPaths.ConfigRoot, $"altro-{Guid.NewGuid():N}.json"); File.WriteAllText(altro, "[]"); try { var prima = StatsWipe.Measure(); - Assert.True(prima.LegacyFiles >= 2); + Assert.True(prima.LegacyFiles >= 1); var report = StatsWipe.Run(new StatsWipe.Options { - History = false, ProductStats = false, BidLeadMeasures = false, - Exports = false, LegacyArchives = true, Learning = false + History = false, ProductStats = false, LegacyArchives = true, Learning = false }); - Assert.False(File.Exists(legacy)); - Assert.False(File.Exists(legacyJson)); + Assert.False(File.Exists(vecchio)); + Assert.False(Directory.Exists(legacy)); Assert.True(File.Exists(altro)); Assert.Null(report.BackupPath); Assert.Empty(report.Errors); @@ -46,32 +42,14 @@ public class StatsWipeTests } finally { - foreach (var f in new[] { legacy, legacyJson, altro }) if (File.Exists(f)) File.Delete(f); + if (File.Exists(altro)) File.Delete(altro); } } - [Fact] - public void Svuota_le_esportazioni_senza_toccare_altro() - { - Directory.CreateDirectory(AppPaths.ExportFolder); - var export = Path.Combine(AppPaths.ExportFolder, $"storico-{Guid.NewGuid():N}.csv"); - File.WriteAllText(export, "a;b\n"); - - var (files, bytes) = StatsWipe.ClearExports(); - - Assert.False(File.Exists(export)); - Assert.True(files >= 1); - Assert.True(bytes >= 4); - } - [Fact] public void Senza_voci_scelte_non_fa_nulla() { - var o = new StatsWipe.Options - { - History = false, ProductStats = false, BidLeadMeasures = false, - Exports = false, LegacyArchives = false, Learning = false - }; + var o = new StatsWipe.Options { History = false, ProductStats = false, LegacyArchives = false, Learning = false }; Assert.True(o.Nothing); var report = StatsWipe.Run(o); diff --git a/Mimante/Tests/TestEnvironment.cs b/Mimante/Tests/TestEnvironment.cs index 7bd6855..2c34293 100644 --- a/Mimante/Tests/TestEnvironment.cs +++ b/Mimante/Tests/TestEnvironment.cs @@ -24,10 +24,7 @@ internal static class TestEnvironment // leggerebbe e scriverebbe quelle vere di chi lancia la suite. AppPaths.OverrideConfigRootForTests(sandbox); - AppPaths.Configure( - dataFolder: Path.Combine(sandbox, "Dati"), - statsFolder: Path.Combine(sandbox, "Statistiche"), - databaseFile: Path.Combine(sandbox, "autobidder.sqlite")); + AppPaths.Configure(databaseFolder: Path.Combine(sandbox, "Database")); AppPaths.EnsureFolders(); } diff --git a/Mimante/Themes/Controls.xaml b/Mimante/Themes/Controls.xaml index 59319f9..b80f377 100644 --- a/Mimante/Themes/Controls.xaml +++ b/Mimante/Themes/Controls.xaml @@ -698,6 +698,40 @@ + + +