diff --git a/Mimante/App.xaml.cs b/Mimante/App.xaml.cs
index 36a83a7..71252a8 100644
--- a/Mimante/App.xaml.cs
+++ b/Mimante/App.xaml.cs
@@ -23,7 +23,7 @@ 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);
+ AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder, settings.DatabasePath);
AppPaths.EnsureFolders();
// Le versioni precedenti scrivevano parte dei dati in Roaming e parte in Local,
@@ -36,8 +36,21 @@ namespace AutoBidder
TextLogService.PurgeOldLogs();
TextLogService.App("INFO", $"[PERCORSI] configurazione in {AppPaths.ConfigRoot}, dati in {AppPaths.DataFolder}");
- // Le righe di registro delle aste confluiscono nei rispettivi dossier.
- AuctionDossier.CaptureAuctionLogs();
+ // Il database si apre per primo: da qui in poi ogni archivio ci scrive.
+ Data.AuctionDatabase.OnLog += m => TextLogService.App("INFO", m);
+ try
+ {
+ 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);
+ }
+ catch (Exception ex)
+ {
+ TextLogService.App("ERROR", $"[DATABASE] Apertura non riuscita: {ex.Message}");
+ }
+
+ // Le righe di registro delle aste confluiscono nel database.
+ Data.AuctionRecorder.CaptureAuctionLogs();
// Lo storico e' cresciuto per mesi con versioni diverse dell'applicazione:
// alcuni record hanno campi che allora non esistevano. Quelli deducibili si
@@ -74,6 +87,14 @@ namespace AutoBidder
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());
+ Data.AuctionDatabase.CloseInstance();
+ }
+ catch { /* all'uscita non c'è più nessuno a cui dirlo */ }
+
// L'icona di notifica sopravvive al processo se non la si rimuove:
// resterebbe nell'area di notifica come icona fantasma.
WindowsNotifier.Dispose();
diff --git a/Mimante/Controls/AuctionMonitorControl.xaml b/Mimante/Controls/AuctionMonitorControl.xaml
index 39df607..ed86b24 100644
--- a/Mimante/Controls/AuctionMonitorControl.xaml
+++ b/Mimante/Controls/AuctionMonitorControl.xaml
@@ -66,6 +66,19 @@
+
+
+
+
+
+
+
diff --git a/Mimante/Controls/AuctionMonitorControl.xaml.cs b/Mimante/Controls/AuctionMonitorControl.xaml.cs
index 8a73e5c..4f82bc6 100644
--- a/Mimante/Controls/AuctionMonitorControl.xaml.cs
+++ b/Mimante/Controls/AuctionMonitorControl.xaml.cs
@@ -67,6 +67,20 @@ namespace AutoBidder.Controls
RaiseEvent(new RoutedEventArgs(StopClickedEvent, this));
}
+ private void KillSwitchToggle_Changed(object sender, RoutedEventArgs e)
+ {
+ RaiseEvent(new RoutedEventArgs(KillSwitchChangedEvent, this));
+ }
+
+ public static readonly RoutedEvent KillSwitchChangedEvent = EventManager.RegisterRoutedEvent(
+ "KillSwitchChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
+
+ public event RoutedEventHandler KillSwitchChanged
+ {
+ add { AddHandler(KillSwitchChangedEvent, value); }
+ remove { RemoveHandler(KillSwitchChangedEvent, value); }
+ }
+
private void AddUrlButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(AddUrlClickedEvent, this));
diff --git a/Mimante/Controls/LearningControl.xaml b/Mimante/Controls/LearningControl.xaml
index 69ca0c7..35e8b9a 100644
--- a/Mimante/Controls/LearningControl.xaml
+++ b/Mimante/Controls/LearningControl.xaml
@@ -84,6 +84,12 @@
Content="Apri cartella" Margin="0,0,8,0"
ToolTip="La cartella con modello, profilo, elenco dei dossier letti e ultima valutazione"
Click="OpenFolderButton_Click"/>
+
+
@@ -279,6 +286,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Mimante/Controls/LearningControl.xaml.cs b/Mimante/Controls/LearningControl.xaml.cs
index ccf0fc4..61138e6 100644
--- a/Mimante/Controls/LearningControl.xaml.cs
+++ b/Mimante/Controls/LearningControl.xaml.cs
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
+using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
@@ -111,6 +112,7 @@ namespace AutoBidder.Controls
StatBootstrap.Text = LearningService.BootstrapRunning ? "in corso" : "fermo";
RefreshAutonomy(settings);
+ RefreshShadow(settings);
var decisioni = LearningService.RecentDecisions();
var fermate = decisioni.Count(d => d.Blocked);
@@ -213,6 +215,82 @@ namespace AutoBidder.Controls
: $"{righe.Count} aste seguite: {sfogo} in Sfogo, {righe.Count(r => r.Regime == "Sondaggio")} in Sondaggio, {righe.Count - sfogo - righe.Count(r => r.Regime == "Sondaggio")} Calme.";
}
+ private DateTime _shadowRefreshedAt = DateTime.MinValue;
+
+ /// Campione, Brier, bandit e il rapporto shadow (ogni mezzo minuto: interroga il database).
+ private void RefreshShadow(AppSettings settings)
+ {
+ var (bl, bn, samples) = LearningService.BrierScores;
+ StatChampion.Text = LearningService.ChallengerLeads ? "rete neurale" : "logistico";
+ StatBrier.Text = samples == 0 ? "—" : $"{bl:F5} / {bn:F5}";
+ StatBandit.Text = $"{LearningService.Bandit.Arms:N0} / {LearningService.Bandit.Observations:N0}";
+
+ if ((DateTime.Now - _shadowRefreshedAt).TotalSeconds < 30) return;
+ _shadowRefreshedAt = DateTime.Now;
+
+ try
+ {
+ var report = ShadowReport.Compute(Data.AuctionDatabase.Instance, settings, 30);
+ ShadowBox.Text = report.WithOutcome == 0
+ ? $"Decisioni registrate negli ultimi 30 giorni: {report.TotalDecisions:N0}, nessuna con esito ancora (arriva alla chiusura dell'asta)."
+ : report.Text;
+ }
+ catch (Exception ex)
+ {
+ ShadowBox.Text = $"Rapporto non disponibile: {ex.Message}";
+ }
+ }
+
+ private void ExportDecisionsButton_Click(object sender, RoutedEventArgs e)
+ {
+ var result = DecisionExporter.ExportCsv();
+ if (result.Success)
+ {
+ try { Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(result.Path)!, UseShellExecute = true }); } catch { }
+ MessageBox.Show($"{result.Message}\n{result.Path}", "Decisioni", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ else
+ {
+ MessageBox.Show(result.Message, "Decisioni", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+
+ private CancellationTokenSource? _simulation;
+
+ private async void SimulateButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_simulation != null) { _simulation.Cancel(); return; }
+
+ _simulation = new CancellationTokenSource();
+ var ct = _simulation.Token;
+ SimulateButton.Content = "Annulla";
+ SimulationBox.Text = "Simulazione in corso…";
+ var settings = SettingsManager.Load();
+
+ try
+ {
+ var text = await Task.Run(() => SimulationLab.Run(Data.AuctionDatabase.Instance, settings, 500,
+ msg => Dispatcher.BeginInvoke(() => SimulateHint.Text = msg), ct), ct);
+ SimulationBox.Text = text;
+ SimulateHint.Text = $"fatto alle {DateTime.Now:HH:mm}";
+ }
+ catch (OperationCanceledException)
+ {
+ SimulationBox.Text = "Simulazione annullata.";
+ SimulateHint.Text = "";
+ }
+ catch (Exception ex)
+ {
+ SimulationBox.Text = $"Simulazione non riuscita: {ex.Message}";
+ SimulateHint.Text = "";
+ }
+ finally
+ {
+ _simulation = null;
+ SimulateButton.Content = "Simula 500 aste per policy";
+ }
+ }
+
private void RefreshButton_Click(object sender, RoutedEventArgs e) => Refresh();
private void OpenFolderButton_Click(object sender, RoutedEventArgs e)
diff --git a/Mimante/Controls/ProductsControl.xaml b/Mimante/Controls/ProductsControl.xaml
index bc70fa5..afcbc03 100644
--- a/Mimante/Controls/ProductsControl.xaml
+++ b/Mimante/Controls/ProductsControl.xaml
@@ -298,6 +298,11 @@
CellStyle="{StaticResource EditableCell}"
HeaderTemplate="{StaticResource EditableHeader}"
ElementStyle="{StaticResource EditableText}"/>
+
-
@@ -562,6 +562,29 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
+
+
+
+
+
+
+
+
+
+
+
+
@@ -577,9 +600,9 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
+ Content="Registra ogni asta seguita nel database (puntate di ogni utente, reset, mie puntate, decisioni)"/>
@@ -671,6 +694,80 @@ Se il valore è maggiore di zero, il sistema non punta quando le puntate residue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
RaiseEvent(new RoutedEventArgs(OpenLogFolderClickedEvent, this));
+ private void BrowseDatabaseFileButton_Click(object sender, RoutedEventArgs e)
+ => RaiseEvent(new RoutedEventArgs(BrowseDatabaseFileClickedEvent, this));
+
+ private void OpenDatabaseFolderButton_Click(object sender, RoutedEventArgs e)
+ => RaiseEvent(new RoutedEventArgs(OpenDatabaseFolderClickedEvent, this));
+
+ public static readonly RoutedEvent BrowseDatabaseFileClickedEvent = EventManager.RegisterRoutedEvent(
+ "BrowseDatabaseFileClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
+
+ public static readonly RoutedEvent OpenDatabaseFolderClickedEvent = EventManager.RegisterRoutedEvent(
+ "OpenDatabaseFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
+
+ public event RoutedEventHandler BrowseDatabaseFileClicked
+ {
+ add { AddHandler(BrowseDatabaseFileClickedEvent, value); }
+ remove { RemoveHandler(BrowseDatabaseFileClickedEvent, value); }
+ }
+
+ public event RoutedEventHandler OpenDatabaseFolderClicked
+ {
+ add { AddHandler(OpenDatabaseFolderClickedEvent, value); }
+ remove { RemoveHandler(OpenDatabaseFolderClickedEvent, value); }
+ }
+
+ // ── Gestione del rischio: kill-switch e HALT si applicano subito ──
+
+ private bool _riskSyncing;
+
+ private void KillSwitchCheckBox_Changed(object sender, RoutedEventArgs e)
+ {
+ if (_riskSyncing) return;
+ Services.RiskManager.SetKillSwitch(KillSwitchCheckBox.IsChecked == true);
+ RefreshRiskStatus();
+ RaiseEvent(new RoutedEventArgs(KillSwitchChangedEvent, this));
+ }
+
+ private void ResumeHaltButton_Click(object sender, RoutedEventArgs e)
+ {
+ Services.RiskManager.Resume();
+ RefreshRiskStatus();
+ }
+
+ /// Riallinea kill-switch e HALT a quello che dicono il file e il database.
+ public void RefreshRiskStatus()
+ {
+ try
+ {
+ _riskSyncing = true;
+ KillSwitchCheckBox.IsChecked = Services.RiskManager.KillSwitchActive;
+
+ var halt = Services.RiskManager.HaltReason;
+ var s = Services.RiskManager.Snapshot();
+ var conto = $"Oggi: spesi {s.TodaySpent:F2} €, incassati {s.TodayGained:F2} € (netto {s.TodayNet:+0.00;-0.00} €). " +
+ $"Saldo cumulato {s.Cumulative:+0.00;-0.00} €, picco {s.Peak:F2} €, drawdown {s.Drawdown:F2} €.";
+
+ RiskStatusText.Text = string.IsNullOrEmpty(halt)
+ ? (Services.RiskManager.KillSwitchActive ? "Kill-switch ATTIVO: nessuna puntata dal vivo.\n" : "Nessun HALT in corso.\n") + conto
+ : $"HALT in corso: {halt}\n{conto}";
+
+ ResumeHaltButton.Visibility = string.IsNullOrEmpty(halt) ? Visibility.Collapsed : Visibility.Visible;
+ }
+ catch { }
+ finally { _riskSyncing = false; }
+ }
+
+ public static readonly RoutedEvent KillSwitchChangedEvent = EventManager.RegisterRoutedEvent(
+ "KillSwitchChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
+
+ public event RoutedEventHandler KillSwitchChanged
+ {
+ add { AddHandler(KillSwitchChangedEvent, value); }
+ remove { RemoveHandler(KillSwitchChangedEvent, value); }
+ }
+
/// Riepilogo delle misure sull'anticipo, mostrato nel riquadro informativo.
public void SetBidLeadSummary(string text) => BidLeadSummaryText.Text = text;
diff --git a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs
index 54da7d9..a2b491a 100644
--- a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs
+++ b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs
@@ -117,6 +117,18 @@ namespace AutoBidder
Settings.WriteDossiersCheckBox.IsChecked = settings.WriteAuctionDossiers;
Settings.RawPollsCheckBox.IsChecked = settings.DossierIncludeRawPolls;
Settings.LogRetentionTextBox.Text = settings.LogRetentionDays.ToString();
+ Settings.DatabaseFileTextBox.Text = AppPaths.DatabaseFile;
+
+ Settings.BankrollEnabledCheckBox.IsChecked = settings.BankrollManagerEnabled;
+ Settings.MaxBidsPerAuctionTextBox.Text = settings.MaxBidsPerAuction.ToString();
+ Settings.MaxBidsPerSessionTextBox.Text = settings.MaxBidsPerSession.ToString();
+ Settings.DailyBudgetTextBox.Text = settings.DailyBudgetEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
+ Settings.DailyStopLossTextBox.Text = settings.DailyStopLossEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
+ Settings.MaxDrawdownTextBox.Text = settings.MaxDrawdownEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
+ Settings.MaxConcurrentTextBox.Text = settings.MaxConcurrentActiveAuctions.ToString();
+ Settings.TransactionFeeTextBox.Text = settings.TransactionFeeEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
+ Settings.HedgeCheckBox.IsChecked = settings.BuyNowHedgeEnabled;
+ Settings.RefreshRiskStatus();
RefreshBidLeadSummary();
@@ -440,6 +452,21 @@ namespace AutoBidder
if (int.TryParse(Settings.LogRetentionTextBox.Text?.Trim(), out var retention) && retention >= 0)
settings.LogRetentionDays = retention;
+ var previousDb = AppPaths.DatabaseFile;
+ settings.DatabasePath = NormalizeFolderChoice(Settings.DatabaseFileTextBox.Text, AppPaths.DatabaseFile, settings.DatabasePath);
+ if (!string.Equals(previousDb, settings.DatabasePath.Length > 0 ? settings.DatabasePath : AppPaths.DatabaseFile, StringComparison.OrdinalIgnoreCase))
+ Log("[DATI] Percorso del database cambiato: vale dal prossimo avvio", LogLevel.Warning);
+
+ settings.BankrollManagerEnabled = Settings.BankrollEnabledCheckBox.IsChecked ?? true;
+ settings.MaxBidsPerAuction = ReadBounded(Settings.MaxBidsPerAuctionTextBox.Text, settings.MaxBidsPerAuction, 0, 100000, "puntate massime per asta", "");
+ settings.MaxBidsPerSession = ReadBounded(Settings.MaxBidsPerSessionTextBox.Text, settings.MaxBidsPerSession, 0, 1000000, "puntate massime per sessione", "");
+ settings.DailyBudgetEuro = ReadEuro(Settings.DailyBudgetTextBox.Text, settings.DailyBudgetEuro, "tetto di spesa del giorno");
+ settings.DailyStopLossEuro = ReadEuro(Settings.DailyStopLossTextBox.Text, settings.DailyStopLossEuro, "stop-loss del giorno");
+ settings.MaxDrawdownEuro = ReadEuro(Settings.MaxDrawdownTextBox.Text, settings.MaxDrawdownEuro, "drawdown massimo");
+ settings.MaxConcurrentActiveAuctions = ReadBounded(Settings.MaxConcurrentTextBox.Text, settings.MaxConcurrentActiveAuctions, 0, 1000, "aste in gioco insieme", "");
+ settings.TransactionFeeEuro = ReadEuro(Settings.TransactionFeeTextBox.Text, settings.TransactionFeeEuro, "fee di transazione");
+ settings.BuyNowHedgeEnabled = Settings.HedgeCheckBox.IsChecked ?? true;
+
Utilities.SettingsManager.Save(settings);
ApplyDataFolderSettings(settings, previousData, previousStats);
@@ -488,6 +515,17 @@ namespace AutoBidder
return current;
}
+ /// Un importo in euro, con virgola o punto; se non valido si tiene il valore attuale.
+ private double ReadEuro(string? text, double current, string label)
+ {
+ var t = (text ?? "").Trim().Replace(',', '.');
+ if (double.TryParse(t, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v) && v >= 0 && v < 1_000_000)
+ return Math.Round(v, 2);
+
+ Log($"[ERRORE] Valore {label} non valido: mantenuto {current:F2} €", LogLevel.Error);
+ return current;
+ }
+
///
/// Mostra lo stato del motore: aggancio all'orologio del server e traffico prodotto.
///
diff --git a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs
index de6661a..3015070 100644
--- a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs
+++ b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs
@@ -72,18 +72,16 @@ namespace AutoBidder
// mensile che la duplicava non c'è più (vedi AuctionDetailStore).
var detail = BuildDetail(auction, state, record);
- // Il dossier si chiude con il riepilogo: da lì in poi quel file è una
- // storia completa, ed è così che l'analisi sa di poterlo usare. Il percorso
- // si prende prima, perché Close lo toglie dal registro dei dossier aperti.
- var dossierPath = AuctionDossier.For(auction.AuctionId)?.Path;
- AuctionDossier.Close(auction, detail, state);
+ // La registrazione si chiude con il riepilogo: da lì in poi la riga è una
+ // storia completa, ed è così che l'analisi sa di poterla usare.
+ Data.AuctionRecorder.Close(auction, detail, state);
ProductStatsStore.RecordCompleted(record, detail);
// L'apprendimento impara subito da questa asta: il dossier è già su disco
// (Close lo svuota) e lo storico è già scritto. Fuori dal percorso di
// chiusura, che deve restare corto.
- _ = System.Threading.Tasks.Task.Run(() => Ml.LearningService.OnAuctionClosed(record, dossierPath));
+ _ = System.Threading.Tasks.Task.Run(() => Ml.LearningService.OnAuctionClosed(record));
NotifyOutcome(record, settings);
@@ -238,8 +236,7 @@ namespace AutoBidder
BidCostEuro = auction.BidCost,
ObservedFromStart = auction.ObservedFromStart,
- ObservedToEnd = auction.ObservedToEnd,
- DossierPath = AuctionDossier.For(auction.AuctionId)?.Path
+ ObservedToEnd = auction.ObservedToEnd
};
}
diff --git a/Mimante/Core/MainWindow.DataSettings.cs b/Mimante/Core/MainWindow.DataSettings.cs
index ea97a95..08fac0d 100644
--- a/Mimante/Core/MainWindow.DataSettings.cs
+++ b/Mimante/Core/MainWindow.DataSettings.cs
@@ -205,7 +205,7 @@ namespace AutoBidder
///
private void ApplyDataFolderSettings(AppSettings settings, string previousData, string previousStats)
{
- AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder);
+ AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder, settings.DatabasePath);
AppPaths.EnsureFolders();
// I campi tornano a mostrare i percorsi effettivi: chi ne ha svuotato uno deve
diff --git a/Mimante/Core/MainWindow.Risk.cs b/Mimante/Core/MainWindow.Risk.cs
new file mode 100644
index 0000000..3ee58bd
--- /dev/null
+++ b/Mimante/Core/MainWindow.Risk.cs
@@ -0,0 +1,63 @@
+using System;
+using System.IO;
+using System.Windows;
+using AutoBidder.Services;
+using AutoBidder.Utilities;
+
+namespace AutoBidder
+{
+ ///
+ /// Kill-switch dalla barra del monitor e percorso del database dalle impostazioni.
+ ///
+ public partial class MainWindow
+ {
+ private void AuctionMonitor_KillSwitchChanged(object sender, RoutedEventArgs e)
+ {
+ var on = AuctionMonitor.KillSwitchToggle.IsChecked == true;
+ RiskManager.SetKillSwitch(on);
+
+ Log(on
+ ? "[RISCHIO] Kill-switch ATTIVO: nessuna puntata dal vivo finché resta acceso (file KILL_SWITCH)"
+ : "[RISCHIO] Kill-switch tolto: le puntate dal vivo possono ripartire",
+ on ? LogLevel.Warning : LogLevel.Success);
+
+ try { Settings.RefreshRiskStatus(); } catch { }
+ }
+
+ /// Il kill-switch cambiato dalle Impostazioni: l'interruttore in barra deve seguirlo.
+ private void Settings_KillSwitchChanged(object sender, RoutedEventArgs e) => RefreshKillSwitchToggle();
+
+ /// Allinea l'interruttore in barra allo stato vero del file sentinella.
+ private void RefreshKillSwitchToggle()
+ {
+ try { AuctionMonitor.KillSwitchToggle.IsChecked = RiskManager.KillSwitchActive; } catch { }
+ }
+
+ private void Settings_BrowseDatabaseFileClicked(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var current = AppPaths.DatabaseFile;
+ var dialog = new Microsoft.Win32.SaveFileDialog
+ {
+ Title = "Scegli dove tenere il database",
+ FileName = Path.GetFileName(current),
+ InitialDirectory = Path.GetDirectoryName(current),
+ Filter = "Database SQLite (*.sqlite)|*.sqlite|Tutti i file|*.*",
+ OverwritePrompt = false,
+ CheckPathExists = true
+ };
+
+ if (dialog.ShowDialog(this) == true)
+ Settings.DatabaseFileTextBox.Text = dialog.FileName;
+ }
+ catch (Exception ex)
+ {
+ Log($"[DATI] Scelta del database non riuscita: {ex.Message}", LogLevel.Error);
+ }
+ }
+
+ private void Settings_OpenDatabaseFolderClicked(object sender, RoutedEventArgs e)
+ => OpenFolder(Path.GetDirectoryName(AppPaths.DatabaseFile) ?? AppPaths.Root);
+ }
+}
diff --git a/Mimante/Data/AuctionDatabase.cs b/Mimante/Data/AuctionDatabase.cs
new file mode 100644
index 0000000..62b638d
--- /dev/null
+++ b/Mimante/Data/AuctionDatabase.cs
@@ -0,0 +1,731 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using AutoBidder.Utilities;
+
+namespace AutoBidder.Data
+{
+ ///
+ /// L'archivio dell'applicazione: un solo file SQLite con tutto ciò che si osserva e
+ /// si decide — aste, puntate, interrogazioni, reset, nostre puntate, decisioni del
+ /// motore, misure di rete, puntatori, sessioni.
+ ///
+ /// Perché un database. Prima c'erano un file JSON per asta (i dossier),
+ /// un file JSON con lo storico compatto, uno con le statistiche per prodotto, uno con
+ /// le misure dell'anticipo: quattro copie parziali degli stessi fatti, da tenere
+ /// allineate, e nessuna interrogabile. Qui ogni fatto sta in una riga sola, con un
+ /// indice, e una domanda come «quante puntate manuali fra le 10 e le 13 sui buoni da
+ /// 10 € sono rimaste senza risposta?» è una riga di SQL invece di una lettura di otto
+ /// gigabyte.
+ ///
+ /// Come scrive. Mai sul thread che segue le aste. Ogni scrittura finisce
+ /// in una coda e un thread dedicato la svuota a blocchi, in una transazione ogni
+ /// qualche centinaio di righe: SQLite fa migliaia di inserimenti al secondo così, e
+ /// nessuno sul percorso della puntata aspetta il disco. Chi deve leggere ciò che ha
+ /// appena scritto chiama .
+ ///
+ /// Come legge. Su una seconda connessione, sotto un lucchetto. Con il
+ /// giornale in modalità WAL le letture non bloccano la scrittura e viceversa.
+ ///
+ /// Dove sta. In %LocalAppData%\AutoBidder\Database: non in
+ /// Documenti, che può essere sincronizzata da OneDrive — un file SQLite sotto
+ /// sincronizzazione è il modo classico di corromperlo.
+ ///
+ public sealed class AuctionDatabase : IDisposable
+ {
+ public const int SchemaVersion = 1;
+
+ private static readonly object InstanceSync = new();
+ private static AuctionDatabase? _instance;
+
+ /// Righe di registro (errori di scrittura, migrazioni): chi si aggancia le scrive dove vuole.
+ public static event Action? OnLog;
+
+ /// L'archivio dell'applicazione, aperto al primo uso nel percorso di .
+ public static AuctionDatabase Instance
+ {
+ get
+ {
+ lock (InstanceSync)
+ {
+ if (_instance == null || _instance._disposed)
+ _instance = Open(AppPaths.DatabaseFile);
+ return _instance;
+ }
+ }
+ }
+
+ /// Chiude l'archivio dell'applicazione svuotando la coda. Da chiamare all'uscita.
+ public static void CloseInstance()
+ {
+ lock (InstanceSync)
+ {
+ _instance?.Dispose();
+ _instance = null;
+ }
+ }
+
+ private readonly SqliteConnection _writer;
+ private readonly SqliteConnection _reader;
+ private readonly object _readSync = new();
+
+ // ── Coda di scrittura ────────────────────────────────────────────
+
+ private abstract class Pending
+ {
+ public long Seq;
+ }
+
+ private sealed class Statement : Pending
+ {
+ public string Sql = "";
+ public object?[] Args = Array.Empty
\ No newline at end of file
diff --git a/Mimante/MainWindow.xaml.cs b/Mimante/MainWindow.xaml.cs
index dcfcafe..1b44550 100644
--- a/Mimante/MainWindow.xaml.cs
+++ b/Mimante/MainWindow.xaml.cs
@@ -102,6 +102,7 @@ namespace AutoBidder
// Inizializza servizi
_auctionMonitor = new AuctionMonitor();
Learning.AuctionsProvider = () => _auctionMonitor.GetAuctions();
+ RefreshKillSwitchToggle();
// ? NUOVO: Inizializza SessionService
InitializeSessionService();
diff --git a/Mimante/Ml/DossierTrainingReader.cs b/Mimante/Ml/DossierTrainingReader.cs
index 335b8b7..e8ce34f 100644
--- a/Mimante/Ml/DossierTrainingReader.cs
+++ b/Mimante/Ml/DossierTrainingReader.cs
@@ -2,17 +2,19 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
+using System.Linq;
using System.Text.Json;
+using AutoBidder.Data;
namespace AutoBidder.Ml
{
///
- /// Legge da un dossier solo ciò che serve ad addestrare: intestazione, puntate,
- /// riepilogo. Le interrogazioni — il 90% delle righe e quasi tutti i byte — si saltano
- /// con un confronto di testo prima ancora di interpretare il JSON.
+ /// Porta un'asta conclusa nella forma che serve ad addestrare: la sequenza delle
+ /// puntate in ordine di tempo dichiarato dal server, chi ha vinto, a quanto.
///
- /// È il motivo per cui il primo addestramento su 8,8 GB di dossier è fattibile in
- /// sottofondo: per ogni file si interpreta l'uno per cento delle righe.
+ /// La fonte normale è il database (). Resta anche il
+ /// lettore dei vecchi dossier JSON Lines (): serve ai test scritti
+ /// a mano e a un'eventuale importazione dell'archivio precedente.
///
public static class DossierTrainingReader
{
@@ -29,23 +31,69 @@ namespace AutoBidder.Ml
public double FinalPrice { get; init; }
public bool HasSummary { get; init; }
- /// Il nostro nome utente, dall'intestazione (dossier recenti) o da fuori.
+ /// Il nostro nome utente, dall'asta o da fuori.
public string Me { get; init; } = "";
///
/// Utilizzabile per l'addestramento: chiusa, con almeno qualche puntata, e con
- /// l'ultima puntata registrata che sia davvero del vincitore. Se non lo è, il
- /// dossier ha un buco proprio in fondo, e l'etichetta sarebbe sbagliata.
+ /// l'ultima puntata registrata che sia davvero del vincitore. Se non lo è, la
+ /// registrazione ha un buco proprio in fondo, e l'etichetta sarebbe sbagliata.
///
public bool IsUsable =>
HasSummary && Bids.Count >= 3 && !string.IsNullOrEmpty(Winner) &&
string.Equals(Bids[^1].User, Winner, StringComparison.OrdinalIgnoreCase);
}
- ///
- /// Il nostro nome utente, per i dossier scritti prima che l'intestazione lo portasse.
- /// Le puntate di quell'utente valgono come nostre anche se il file non le marca.
- ///
+ // ── Dal database ─────────────────────────────────────────────────
+
+ /// L'asta indicata, dal database. Null se non esiste.
+ public static Session? FromDatabase(AuctionDatabase db, string auctionId, string? me = null)
+ {
+ var a = db.QueryOne(
+ "SELECT auction_id, product_key, buy_now_price, added_at, winner_user, final_price, outcome, me " +
+ "FROM auctions WHERE auction_id = ?1", auctionId);
+ if (a == null) return null;
+
+ var chiSonoIo = a.StrOrNull("me") ?? me ?? "";
+
+ var bids = db.Query(
+ "SELECT username, price_after, bid_type, is_mine, server_ts, seen_t FROM bids " +
+ "WHERE auction_id = ?1 ORDER BY COALESCE(server_ts, 0), price_after", auctionId)
+ .Select(r => new Bid(
+ T: r.Dbl("seen_t"),
+ User: r.Str("username"),
+ UnixSeconds: r.LongOrNull("server_ts") ?? 0,
+ BidType: r.Str("bid_type"),
+ Mine: r.Bool("is_mine"),
+ Price: r.Dbl("price_after")))
+ .ToList();
+
+ return Finish(new Session
+ {
+ AuctionId = a.Str("auction_id"),
+ ProductKey = a.Str("product_key"),
+ BuyNowPrice = a.DblOrNull("buy_now_price"),
+ AddedAtLocal = (a.Date("added_at") ?? DateTime.MinValue) == DateTime.MinValue
+ ? DateTime.MinValue
+ : a.Date("added_at")!.Value.ToLocalTime(),
+ Bids = bids,
+ Winner = a.Str("winner_user"),
+ FinalPrice = a.Dbl("final_price"),
+ HasSummary = a.StrOrNull("outcome") != null,
+ Me = chiSonoIo
+ });
+ }
+
+ /// Gli id delle aste concluse, dalla più vecchia alla più recente.
+ public static List ClosedAuctionIds(AuctionDatabase db, int max = 0)
+ {
+ var ids = db.Query("SELECT auction_id FROM auctions WHERE outcome IS NOT NULL ORDER BY end_time")
+ .Select(r => r.Str("auction_id")).ToList();
+ return max > 0 && ids.Count > max ? ids.Skip(ids.Count - max).ToList() : ids;
+ }
+
+ // ── Dai vecchi dossier JSON Lines ────────────────────────────────
+
public static Session? ReadFile(string path, string? me = null)
{
try { return Read(File.ReadLines(path), me); }
@@ -65,7 +113,6 @@ namespace AutoBidder.Ml
foreach (var raw in lines)
{
- // Scarto veloce: la stragrande maggioranza delle righe è un poll.
if (raw.Length < 20 || raw.Contains("\"type\":\"poll\"", StringComparison.Ordinal)) continue;
if (raw.Contains("\"type\":\"log\"", StringComparison.Ordinal)) continue;
if (raw.Contains("\"type\":\"reset\"", StringComparison.Ordinal)) continue;
@@ -111,27 +158,7 @@ namespace AutoBidder.Ml
catch (JsonException) { /* riga rotta: si va avanti */ }
}
- // In ordine di tempo dichiarato dal server, poi di prezzo: è l'ordine vero
- // delle puntate, non quello in cui le abbiamo sapute.
- bids.Sort((a, b) =>
- {
- var c = a.UnixSeconds.CompareTo(b.UnixSeconds);
- return c != 0 ? c : a.Price.CompareTo(b.Price);
- });
-
- // Le puntate nostre: quelle marcate, oppure quelle del nostro nome utente. I
- // dossier scritti finora non marcavano niente — la bandiera veniva copiata da
- // un campo che nessuno impostava — quindi il nome è l'unica via per il passato.
- if (chiSonoIo.Length > 0)
- {
- for (var i = 0; i < bids.Count; i++)
- {
- if (!bids[i].Mine && string.Equals(bids[i].User, chiSonoIo, StringComparison.OrdinalIgnoreCase))
- bids[i] = bids[i] with { Mine = true };
- }
- }
-
- return new Session
+ return Finish(new Session
{
AuctionId = auctionId,
ProductKey = productKey,
@@ -142,11 +169,32 @@ namespace AutoBidder.Ml
FinalPrice = finalPrice,
HasSummary = hasSummary,
Me = chiSonoIo
- };
+ });
+ }
+
+ /// Ordina le puntate nel tempo del server e marca le nostre dal nome utente.
+ private static Session Finish(Session s)
+ {
+ s.Bids.Sort((a, b) =>
+ {
+ var c = a.UnixSeconds.CompareTo(b.UnixSeconds);
+ return c != 0 ? c : a.Price.CompareTo(b.Price);
+ });
+
+ if (s.Me.Length > 0)
+ {
+ for (var i = 0; i < s.Bids.Count; i++)
+ {
+ if (!s.Bids[i].Mine && string.Equals(s.Bids[i].User, s.Me, StringComparison.OrdinalIgnoreCase))
+ s.Bids[i] = s.Bids[i] with { Mine = true };
+ }
+ }
+
+ return s;
}
///
- /// Gli esempi di addestramento di un dossier: uno per puntata, con etichetta
+ /// Gli esempi di addestramento di un'asta: uno per puntata, con etichetta
/// «è rimasta senza risposta» — vera solo per l'ultima. La prima puntata non ha un
/// ciclo da misurare e si salta.
///
@@ -185,8 +233,7 @@ namespace AutoBidder.Ml
}
// Le finestre guardano le puntate PRIMA di questa: sono quelle che si sarebbero
- // viste nell'istante di decidere. Includere questa stessa puntata sarebbe dare al
- // modello un pezzo di futuro.
+ // viste nell'istante di decidere.
private static double AutoShare(List bids, int i, int window)
{
diff --git a/Mimante/Ml/LEGGIMI.md b/Mimante/Ml/LEGGIMI.md
index d25e2ee..8c644ec 100644
--- a/Mimante/Ml/LEGGIMI.md
+++ b/Mimante/Ml/LEGGIMI.md
@@ -1,154 +1,248 @@
-# Come decide il motore
+# AutoBidder — il sistema di decisione e apprendimento
-Questo documento descrive l'algoritmo di puntata di AutoBidder dopo la rimozione delle
-euristiche a soglia fissa. Vale come specifica: il codice in `Services/BidStrategyService.cs`,
-`Engine/AuctionRunner.cs` e in questa cartella la implementa, e i test in `Tests/` la
-verificano.
+Questo documento descrive come AutoBidder osserva, impara e decide. Vale come specifica:
+il codice in `Data/`, `Ml/`, `Services/BidStrategyService.cs`, `Services/RiskManager.cs` e
+`Engine/AuctionRunner.cs` la implementa, i test in `Tests/` la verificano. Nessuna libreria
+esterna: SQLite arriva da `winsqlite3.dll` di Windows, rete neurale, bandit, simulatore e
+agente di rinforzo sono C# puro.
-## Principio
+---
-**Tutto ciò che si può imparare dai dati non si scrive a mano.** L'utente fissa i
-paletti — quanto è disposto a spendere, quando non vuole puntare, entro quali limiti
-l'anticipo può muoversi — e dentro quei paletti il sistema decide da solo, imparando
-sia dallo storico (seimila dossier) sia dalla sessione in corso (il ping di adesso, le
-risposte degli avversari di adesso).
+## 0. Avvertenze, prima di tutto
-Le vecchie euristiche — calore dell'asta, ritiro morbido dopo le collisioni, avversari
-aggressivi, anti-bot, puntata probabilistica, velocità del prezzo, esaurimento
-dell'avversario — sono state tolte perché rigiocate sui dossier non hanno mai fermato
-una puntata sbagliata senza fermarne anche di giuste, e i loro numeri magici andavano
-tarati a mano per ogni prodotto e ora del giorno.
+**Termini e condizioni di Bidoo.** I termini (it.bidoo.com/terms.php) vietano «l'uso di
+software di monitoraggio dell'offerta» e la creazione di account multipli, e consentono
+un solo account per persona. Questo programma è software di monitoraggio e puntata: il suo
+uso può comportare il blocco o la chiusura dell'account e la perdita delle puntate
+acquistate. Non implementa e non implementerà la creazione di account multipli.
-## La pipeline: quattro passi, sempre nello stesso ordine
+**Quadro legale.** In Italia le aste al centesimo sono in una zona grigia: non
+classificate come gioco d'azzardo (art. 721 c.p. richiede aleatorietà pressoché totale;
+qui c'è un elemento di abilità), ma trattate dall'AGCM come pratica commerciale ai sensi
+del Codice del Consumo. Nel 2015 (provvedimenti PS8243 e PS9030) l'AGCM ha sanzionato due
+operatori del settore per un milione di euro complessivo. Non risulta alcuna azione contro
+Bidoo, ma ciò non è una garanzia.
+
+**Realtà economica.** Augenblick (2016, *Review of Economic Studies* 83(1)), su 166.000
+aste: il margine medio del banco supera il 50% del valore del bene; il 75% dei puntatori
+abbandona prima di 50 puntate, l'86% prima di 100. Platt, Price e Tappen (2013) e Hinnosaar
+(2016): in ogni equilibrio che non finisce subito, il profitto atteso di ogni puntata è
+**zero**. Un vantaggio, se esiste, viene solo dall'irrazionalità altrui (costo affondato,
+gusto per il rischio), non dalla struttura del gioco.
+
+Il sistema è quindi **scettico per costruzione**: ogni stima di profittabilità si confronta
+con il null-model (ROI per puntata = 0), e l'esito atteso onesto è astenersi dalla grande
+maggioranza delle aste. Lo stato *Osserva* è la modalità shadow: registra ogni decisione
+che avrebbe preso senza puntare. L'utente ha scelto di non avere una shadow globale
+obbligatoria; le aste in *Attiva* puntano davvero, dentro i paletti del gestore del rischio.
+
+---
+
+## 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.
+
+| tabella | cosa contiene |
+|---|---|
+| `items` | il prodotto: chiave, titolo, «Compralo Ora», **valore reale V** scelto dall'utente, spedizione |
+| `auctions` | una riga per asta, aggiornata dall'apertura alla chiusura: esito, prezzo finale, vincitore, puntate mie e del vincitore, copertura dell'osservazione, rete, motore, serie dei prezzi, puntate per utente, configurazione |
+| `bids` | ogni puntata di ogni utente: prezzo dopo, tipo (Auto/Manuale), ora del server, secondi residui quando è arrivata, mia/altrui |
+| `polls` | le interrogazioni, coalescenti (quando cambia qualcosa, e almeno una al secondo): prezzo, timer, ultimo puntatore, ping, scarto dell'orologio |
+| `resets` | ogni azzeramento del timer con la **profondità del ciclo** (fin dove era sceso) e la sua durata |
+| `my_bids` | le nostre puntate: anticipo voluto ed effettivo, ping, andata e ritorno, esito, id della decisione |
+| `bot_decisions` | ogni decisione del motore, in shadow e dal vivo: azione, P(vittoria), EV, policy, regime, **reason_detail**, e l'**esito** scritto alla chiusura |
+| `network_metrics` | una riga ogni dieci secondi: mediana e p95 del ping, jitter, scarto dell'orologio |
+| `bidders` | chi punta: aste, puntate, quota di automatiche, vittorie |
+| `sessions`, `risk_ledger`, `meta` | sessioni dell'applicazione; uscite ed entrate in euro per il gestore del rischio; stato HALT |
+
+Lo storico compatto (`CompletedAuctionsStore`), le schede dettagliate (`AuctionDetailStore`),
+l'apprendimento, la rigiocata e le esportazioni leggono tutti da qui. I vecchi dossier
+JSON Lines restano leggibili (`DossierReader.Read`, `DossierTrainingReader.Read`) per
+un'eventuale importazione, ma non vengono più scritti.
+
+**Esiti delle decisioni.** Alla chiusura dell'asta ogni decisione riceve un esito: se il
+prezzo non è più salito dopo quell'istante, una puntata lì sarebbe stata l'ultima
+(`won`/`would_win`; `missed` se non si è puntato), altrimenti sarebbe stata coperta
+(`answered`/`would_be_answered`; `right_skip`). È quello che permette di giudicare le policy
+sugli stessi istanti (§5).
+
+---
+
+## 2. Il modello matematico — `Ml/Theory.cs`
+
+**Valore atteso di una puntata** (§3.1 del documento di progetto, nella forma corretta:
+la puntata si paga in entrambi i rami):
```
- ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
- │ 1. PALETTI │──▶│ 2. DUELLO │──▶│ 3. VALORE │──▶│ 4. REGIME │──▶ punta?
- │ (utente) │ │ (sessione) │ │ ATTESO │ │ (sessione) │
- └──────────────┘ └──────────────┘ │ (storico+ora)│ └──────────────┘
- └──────────────┘
- │
- ┌──────────▼──────────┐
- │ QUANDO: anticipo │
- │ adattivo (sessione) │
- └─────────────────────┘
+EV = P · (V − (p + Δ) − fee − spedizione − c) − (1 − P) · c_perdita
```
-### 1. Paletti — `BidStrategyService`, `BiddingHours`
+con `V` il valore reale del bene (colonna «Valore reale €» nella scheda Prodotti; se vuota,
+il «Compralo Ora»), `Δ = 0,01 €`, `fee ≈ 1 €`, `c` il costo della puntata.
-Regole dell'utente che non si discutono e non imparano niente:
+**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.
-- tetti di puntate per asta e per sessione, budget giornaliero (`BankrollManager*`);
-- fascia oraria sospesa (`QuietHours*`): fra le 0 e le 9 la stessa asta costa quasi il
- doppio che fra le 10 e le 13 (6060 aste concluse). L'asta resta *Attiva* e riprende da
- sola alla fine della fascia; si può scavalcare per prodotto e per singola asta.
+**Null-model.** Profitto atteso per puntata = 0; numero critico di puntatori `V/c`
+(Hinnosaar). **Sopravvivenza empirica:** dalla tabella `resets`, P(vittoria) a un dato
+anticipo = quota dei cicli scesi almeno fin lì che hanno chiuso l'asta; curva di
+Kaplan-Meier della profondità dei cicli. **Arrivi di Poisson:** `P(nessuna puntata in τ) =
+exp(−λτ)` con λ dagli intervalli recenti.
-### 2. Duello con l'autopuntata — `AutoBidDuel`
+---
-L'autopuntata ufficiale di Bidoo scatta a 2 s dalla fine e risponde alla nostra puntata
-in 6 ± 2,5 s, sempre. Cinque risposte di fila con quella firma = autopuntata armata. Nelle
-prove reali il motore ci ha lasciato 191 puntate su tre aste per zero vittorie. Il
-riconoscimento è un segnale della sessione in corso: informa il regime (passo 4) e, se
-l'utente lo vuole (`AutoBidDuelWithdrawEnabled`, predefinito acceso), ferma le puntate.
+## 3. La pipeline di decisione — `Services/BidStrategyService.cs`
-### 3. Valore atteso — `LearningService`, `OnlineLogit`, `BidFeatures`
-
-Un modello logistico in linea, aggiornato a ogni asta chiusa, stima la probabilità `p`
-che una puntata fatta *adesso* resti senza risposta. Le variabili sono a fasce e leggibili:
-prezzo in percentuale del valore, ora e giorno, prodotto (32 cassetti con hash stabile),
-densità delle puntate negli ultimi cicli, profondità del ciclo, se l'ultimo puntatore
-risponde a macchina.
-
-Il valore atteso in euro di puntare è
+Cinque passi, sempre nello stesso ordine, una volta per ciclo del timer, ~60 ms prima
+dell'istante di fuoco:
```
-EV = p × (Valore − Prezzo − 0,01) − CostoPuntata
+ RISCHIO ──▶ PALETTI ──▶ DUELLO ──▶ VALORE ATTESO ──▶ REGIME ──▶ punta?
+ (solo dal (utente) (sessione) (storico + (sessione)
+ vivo) sessione) │
+ QUANDO: anticipo adattivo
```
-Sotto zero, in media, puntare perde soldi. La calibrazione si corregge in linea con il
-rapporto osservato/previsto (finestra 20 000, limitata a 0,25–4): il mercato deriva, e
-un modello congelato sbaglierebbe di 1,5–1,8×. Il modello parla sempre; decide solo
-dopo `LearningMinAuctions` aste apprese (predefinito 300).
+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
+ un'entrata al valore reale meno prezzo, fee e spedizione.
+2. **Paletti** — fascia oraria sospesa (globale, per prodotto, per asta), tetti di puntate.
+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.
+ Il modello parla sempre, decide dopo `LearningMinAuctions` aste apprese.
+5. **Regime** — `CompetitionRegime`: Calmo / Sfogo / Sondaggio con isteresi e pazienza
+ che raddoppia a ogni sondaggio coperto. Uscire è facile, rientrare è difficile.
-Valutazione prequenziale sui dossier reali: sollevamento 10,8× sul decile alto,
-calibrazione entro il 10–30%; 45 delle 116 puntate vere dell'utente sarebbero state
-fermate senza perdere nessuna vittoria.
+**Quando** — `LatencyModel`: anticipo = margine + p99 della latenza di sessione, fra
+`LeadMinMs` e `LeadMaxMs`; una puntata tardiva alza il margine subito, venti in tempo lo
+abbassano piano. Un anticipo scritto a mano su un'asta vince sempre.
-### 4. Regime di concorrenza — `CompetitionRegime`
+**Shadow.** In *Osserva* il cecchino arriva allo stesso istante e chiama la stessa pipeline
+(saltando il gestore del rischio, che riguarda solo i soldi veri), registra la decisione e
+non punta. Ogni decisione porta anche il parere del bandit (§4.3) sullo stesso istante.
-Il valore atteso è un numero per istante; il regime lo trasforma in una condotta per
-asta. Tre stati:
+---
-| Stato | Cosa fa | Ci si entra quando |
-|---|---|---|
-| **Calmo** | punta se EV ≥ 0 | stato iniziale; un sondaggio riuscito |
-| **Sfogo** | non punta: lascia che gli altri si battano | 3 EV negativi di fila; autopuntata riconosciuta; sondaggio coperto subito |
-| **Sondaggio** | una sola puntata di prova | in Sfogo, `Pazienza` cicli buoni di fila |
+## 4. Cosa impara, e come — `Ml/`
-**Isteresi nel verso giusto.** Uscire è facile (3 negativi), rientrare è difficile
-(`Pazienza` positivi di fila, che parte da 3). Sbagliare a rientrare costa una puntata;
-sbagliare a uscire costa al massimo un'attesa.
+### 4.1 P(senza risposta): campione e sfidante
-**La pazienza raddoppia.** Se la puntata di prova viene coperta entro 9 s, il regime torna
-in Sfogo e la pazienza raddoppia (fino a 24). Così il sistema "capisce da solo" che quell'asta
-oggi non si lascia prendere, senza deciderlo una volta per tutte: se la risposta arriva
-lenta (una persona, non una macchina) si torna Calmo e la pazienza si dimezza.
+Due modelli imparano dagli stessi esempi — ogni puntata di ogni asta chiusa, etichettata
+«è rimasta l'ultima?» — con le stesse variabili a fasce (`BidFeatures`: durata del ciclo,
+quota di autopuntate, puntatori distinti, prezzo sul valore, ora, giorno, ora di apertura,
+tipo di puntata, profondità, prodotto in 32 cassetti):
-È questo il passo che risponde a «quando c'è troppa concorrenza li lascio sfogare, e il
-sistema deve capire da solo quando può tornare a puntare».
+- **campione**: regressione logistica online (`OnlineLogit`, SGD, L2, calibrazione in linea);
+- **sfidante**: rete neurale 70→32→16→1 (`NeuralNet`, ReLU, Adam, L2, stessa calibrazione).
-### Quando puntare: l'anticipo adattivo — `LatencyModel`
+A ogni esempio, *prima* di imparare, entrambi prevedono; il quadrato dell'errore va in una
+media mobile (20.000 esempi): è il **punteggio di Brier prequenziale** di ciascuno. Risponde
+al motore chi ce l'ha più basso; la rete non comanda prima di 5.000 esempi. La scheda
+Apprendimento mostra chi risponde e i due punteggi.
-L'anticipo giusto dipende dalla latenza di *questa* rete in *questo* momento. Un numero
-fisso è sbagliato quasi sempre. La regola:
+Perché non le nostre vittorie come etichetta: sono troppo poche (l'utente non ne ha ancora).
+Il comportamento avversario non dipende da chi ha puntato: questo rende utilizzabili i
+milioni di puntate altrui.
-```
-anticipo = clamp( margine + p99(latenza) , LeadMinMs , LeadMaxMs )
-```
+### 4.2 Profilo per prodotto e ora
-- la **coda** (p99 delle ultime 300 andate-e-ritorno delle puntate, o dei ping delle
- interrogazioni finché non ci sono puntate), non la media: una puntata su cento in
- ritardo costa un'asta intera;
-- il **margine** (parte da 300 ms) sale di 150 ms a ogni puntata tardiva e scende di
- 25 ms ogni 20 puntate in tempo. Asimmetrico apposta;
-- i **paletti** `LeadMinMs` (300) e `LeadMaxMs` (1500) li fissa l'utente; un anticipo
- scritto a mano su una singola asta vince sempre (`BidLeadIsManual`).
+`AuctionProfileStats`: puntate del vincitore e prezzo di chiusura in frazione del valore, per
+prodotto × fascia oraria × feriale/festivo, con restringimento gerarchico dove i dati
+scarseggiano. Alimentato asta per asta.
-Lo stato si salva fra le sessioni (`Statistiche/Apprendimento/latenza.json`).
+### 4.3 Bandit contestuale (Thompson)
-## Cosa impara, e da dove
+`ThompsonBandit`: per ogni contesto (ciclo, prezzo/valore, fascia, puntatori, autopuntate)
+una Beta(α, β) su P(senza risposta). Decide estraendo p dalla Beta e applicando la regola
+del valore atteso: esplora dove ha visto poco, si stringe con i dati. Impara dagli **esiti
+delle decisioni** — informazione completa, anche dove non si è puntato. È la seconda policy,
+sempre in shadow, confrontata con la prima sugli stessi istanti.
-| Componente | Dati storici | Sessione in corso |
-|---|---|---|
-| Modello logistico | ogni puntata di ogni dossier | l'asta appena chiusa |
-| Profilo per prodotto/ora | riepiloghi dei dossier | l'asta appena chiusa |
-| Regime | — | risposte avversarie di quest'asta |
-| Anticipo adattivo | margine salvato | ping e puntate di adesso |
-| Duello | — | ritmo delle risposte di quest'asta |
+### 4.4 Regime e latenza
-## Dove si vede
+Sessione in corso, non storico: `CompetitionRegime` per asta, `LatencyModel` per la rete.
+Vedi §3.
-Scheda **Apprendimento**: stato del modello, pesi, ultime decisioni, profilo per
-prodotto, valutazione, e la sezione *Autonomia sul momento* con il modello di latenza e
-il regime di ogni asta seguita.
+### 4.5 Simulatore e agente di rinforzo
-Nel registro di ogni asta: `[REGIME] Calmo → Sfogo: …`, `[TIMING] … anticipo adattivo
-portato a N ms`, `⛔ Strategia blocca: valore atteso negativo: P(senza risposta) … = −0,012 €`.
+`PennyAuctionEnv`: aste sintetiche con quattro profili di avversario (aggressivo, cecchino,
+autopuntata a 2 s, principiante), ognuno con credito e **persistenza crescente con lo speso**
+(costo affondato, Augenblick). Taratura dal database: puntatori per asta, istogramma della
+profondità dei cicli, quota di autopuntate, chiusura mediana in frazione del valore.
-## Come si prova
+`QLearningAgent`: funzione Q su rete neurale, replay delle esperienze, ε-greedy decrescente,
+**penalità conservativa** sulle azioni poco viste (nello spirito di CQL). Si addestra solo nel
+simulatore: non si esplora con denaro vero.
-- `Tests/AutonomyTests.cs` — regime (isteresi, raddoppio, tetto) e latenza (paletti,
- asimmetria);
-- `Tests/MlTests.cs`, `Tests/MlModelBacktest.cs` — modello e valutazione prequenziale;
-- `Tests/AutoBidDuelTests.cs`, `Tests/BiddingHoursTests.cs` — duello e fascia oraria;
-- `Tests/BacktestTests.cs`, `Tests/RealDossierBacktest.cs` — rigiocata sui dossier con
- la stessa `BidStrategyService` del motore.
+`SimulationLab` mette a confronto su aste identiche (stessi semi): non punta mai, punta
+sempre, valore atteso con P empirica, bandit che impara giocando, agente Q. Il simulatore
+non è Bidoo: i numeri assoluti non contano, il confronto sì. È l'unico posto dove si vede
+come reagirebbero gli altri a una nostra puntata.
-## Archiviazione
+---
-Ogni asta seguita ha **un solo** file: il dossier JSON Lines in `Registri/Aste/`
-(intestazione, eventi coalescenti, riepilogo in coda). Le schede dettagliate si leggono
-da lì (`AuctionDetailStore` legge testa e coda del file, con cache); l'archivio mensile
-`aste-AAAA-MM.jsonl` che le duplicava non esiste più. Lo storico compatto delle aste
-concluse resta in `Statistiche/completed-auctions.json`, con copia di sicurezza a ogni
-pulizia.
+## 5. Come si giudica — `ShadowReport`, `LearningEvaluator`, rigiocata
+
+- **Shadow** (`ShadowReport`): sugli istanti di decisione con esito noto, per ogni policy:
+ puntate, vincenti, spesi, incassati al valore reale, ROI per puntata con estremo inferiore
+ dell'intervallo al 95%. Il null-model è battuto solo se l'estremo inferiore è sopra zero,
+ su abbastanza puntate.
+- **Prequenziale** (`LearningEvaluator`, «Valuta ora»): addestra sulle aste più vecchie,
+ giudica sulle più recenti, poi ogni asta prima prevista e poi appresa; sollevamento,
+ calibrazione per fasce, punteggio di Brier, cosa avrebbe fatto il cancello sulle nostre
+ puntate vere.
+- **Rigiocata** (`BacktestReport`): cosa avrebbe fatto il motore su ogni asta registrata,
+ anticipo per anticipo; misura i costi, non l'esito.
+- **Esportazione**: «Esporta decisioni (CSV)» — punto e virgola, colonna `reason_detail`.
+
+**Criteri di promozione e di stop** (dal documento di progetto): una policy passa dal
+shadow al vivo solo con ROI per puntata > 0 ed estremo inferiore > 0 su almeno quattro
+settimane, calibrazione buona, drawdown simulato entro il limite. Se dopo un periodo di
+vita reale il ROI cumulato non batte il null-model, il sistema resta strumento analitico e
+si spegne il vivo. La posizione di default è che un vantaggio consistente sia improbabile.
+
+---
+
+## 6. Dove si vede
+
+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 €».
+
+Nel registro di ogni asta: `[SHADOW] Avrei puntato…`, `[REGIME] Calmo → Sfogo…`, `[TIMING]
+… anticipo adattivo…`, `⛔ Strategia blocca: …`.
+
+---
+
+## 7. Cosa non c'è, e perché
+
+- **Feed DOM (Playwright)**: gli endpoint HTTP di Bidoo (`data.php`, `get_auction_updates`)
+ sono verificati e usati da mesi; un browser sarebbe più lento e più fragile.
+- **Dataset Swoopo (2008-09)**: sito e anni diversi; il database si riempie da solo.
+- **Modelli sequenziali (LSTM/Transformer)**: rimandati; le variabili a fasce con la rete
+ MLP coprono le interazioni che contano, e i dati per una sequenza profonda non ci sono.
+- **Account multipli**: mai.
+
+## 8. Milestone
+
+| | stato |
+|---|---|
+| M1 rischio, kill-switch, HALT, shadow per asta | fatto |
+| M2 database SQLite, 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) |
+| M6 valutazione shadow ≥ 4 settimane, confronto fra policy | strumenti pronti; servono le settimane |
+| M7 esecuzione e latenza | fatto (anticipo adattivo) |
+| M8 monitoraggio e riaddestramento | scheda pronta; riaddestramento a ogni asta chiusa; deriva: da fare |
diff --git a/Mimante/Ml/LearningEvaluator.cs b/Mimante/Ml/LearningEvaluator.cs
index 06eb017..bb22621 100644
--- a/Mimante/Ml/LearningEvaluator.cs
+++ b/Mimante/Ml/LearningEvaluator.cs
@@ -1,36 +1,35 @@
using System;
using System.Collections.Generic;
-using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
+using AutoBidder.Data;
namespace AutoBidder.Ml
{
///
- /// Valuta il modello sui dossier veri, con una divisione onesta nel tempo: si impara
- /// dai più vecchi e si giudica sui più recenti, mai visti. Poi, sui recenti, la
- /// valutazione prequenziale: ogni asta prima prevista e poi appresa, in ordine di
- /// tempo, che è esattamente la sequenza del motore in produzione.
+ /// Valuta il modello sulle aste vere del database, con una divisione onesta nel tempo:
+ /// si impara dalle più vecchie e si giudica sulle più recenti, mai viste. Poi, sulle
+ /// recenti, la valutazione prequenziale: ogni asta prima prevista e poi appresa,
+ /// in ordine di tempo, che è esattamente la sequenza del motore in produzione.
///
/// Sta in una classe a sé perché la usano in due: il test da riga di comando
- /// (target Apprendimento) e la scheda nell'applicazione. Una sola
- /// implementazione, così i numeri che vede l'utente sono gli stessi che vede chi
- /// sviluppa.
+ /// (target Apprendimento) e la scheda nell'applicazione.
///
/// Cosa misura. Quanto il modello separa le puntate rimaste senza risposta
/// (sollevamento nella cima della classifica), se la probabilità è nella scala giusta
- /// (calibrazione per fasce), e sulle nostre puntate vere cosa avrebbe fatto il cancello
- /// del valore atteso: quante fermate, e quante di quelle erano invece vincenti.
+ /// (calibrazione per fasce, punteggio di Brier), e sulle nostre puntate vere cosa
+ /// avrebbe fatto il cancello del valore atteso.
///
public static class LearningEvaluator
{
public sealed class Report
{
public string Text { get; init; } = "";
- public int Files { get; init; }
+ public int Auctions { get; init; }
public int Usable { get; init; }
public double Lift5 { get; init; }
+ public double Brier { get; init; }
public double CalibrationFactor { get; init; }
public int MyBids { get; init; }
public int MyBlocked { get; init; }
@@ -40,28 +39,34 @@ namespace AutoBidder.Ml
private sealed record Row(double P, bool Unanswered, bool Mine, double Ev);
- public static Report Run(string folder, int maxFiles, string? me, Action? progress = null, CancellationToken ct = default)
+ public static Report Run(AuctionDatabase db, int maxAuctions, string? me, Action? progress = null, CancellationToken ct = default)
{
- var files = Directory.GetFiles(folder, "*.jsonl").OrderBy(Path.GetFileName, StringComparer.Ordinal).ToList();
- if (maxFiles > 0 && files.Count > maxFiles) files = files.Skip(files.Count - maxFiles).ToList();
+ db.Flush();
+ var ids = DossierTrainingReader.ClosedAuctionIds(db, maxAuctions);
var sessions = new List();
var letti = 0;
- foreach (var f in files)
+ foreach (var id in ids)
{
ct.ThrowIfCancellationRequested();
- var s = DossierTrainingReader.ReadFile(f, me);
+ var s = DossierTrainingReader.FromDatabase(db, id, me);
if (s != null && s.IsUsable) sessions.Add(s);
- if (++letti % 200 == 0) progress?.Invoke($"letti {letti:N0} dossier su {files.Count:N0}…");
+ if (++letti % 200 == 0) progress?.Invoke($"lette {letti:N0} aste su {ids.Count:N0}…");
}
+ return Evaluate(sessions, ids.Count, progress, ct);
+ }
+
+ /// La valutazione vera e propria, su sessioni già in memoria (anche per i test).
+ public static Report Evaluate(List sessions, int totalAuctions, Action? progress = null, CancellationToken ct = default)
+ {
var report = new StringBuilder();
- report.AppendLine($"Dossier letti: {files.Count:N0}, utilizzabili: {sessions.Count:N0}");
+ report.AppendLine($"Aste chiuse: {totalAuctions:N0}, utilizzabili: {sessions.Count:N0}");
if (sessions.Count < 20)
{
- report.AppendLine("Troppo pochi dossier utilizzabili per una valutazione.");
- return new Report { Text = report.ToString(), Files = files.Count, Usable = sessions.Count };
+ report.AppendLine("Troppo poche aste utilizzabili per una valutazione.");
+ return new Report { Text = report.ToString(), Auctions = totalAuctions, Usable = sessions.Count };
}
progress?.Invoke("addestramento e confronto delle configurazioni…");
@@ -70,7 +75,6 @@ namespace AutoBidder.Ml
var train = sessions.Take(split).ToList();
var test = sessions.Skip(split).ToList();
- // Le variabili si costruiscono una volta sola: leggere i file è la parte lenta.
var trainX = train.SelectMany(s => DossierTrainingReader.Examples(s))
.Select(e => (X: BidFeatures.Build(e.Context), Y: e.Unanswered)).ToList();
@@ -90,10 +94,6 @@ namespace AutoBidder.Ml
report.AppendLine();
// ── Confronto fra configurazioni (modello congelato) ─────────────
- //
- // Serve a scegliere; non è la misura finale. Un modello congelato sui vecchi e
- // applicato ai nuovi sconta la deriva del mercato, che nell'uso online non
- // subisce: la misura che conta è la prequenziale qui sotto.
const double costo = 0.20;
var configs = new List<(string Nome, double Lr, double L2)>();
foreach (var lr in new[] { 0.01, 0.003 })
@@ -129,7 +129,7 @@ namespace AutoBidder.Ml
if (best == null)
{
report.AppendLine("Nessuna configurazione separa abbastanza (sollevamento al 5% > 2x).");
- return new Report { Text = report.ToString(), Files = files.Count, Usable = sessions.Count };
+ return new Report { Text = report.ToString(), Auctions = totalAuctions, Usable = sessions.Count };
}
report.AppendLine($"Scelta: {bestName}");
@@ -157,6 +157,14 @@ namespace AutoBidder.Ml
report.AppendLine($"P media sulle puntate senza risposta : {meanPos:P3}");
report.AppendLine($"P media sulle altre : {meanNeg:P3}");
report.AppendLine($"rapporto : {meanPos / Math.Max(meanNeg, 1e-9):N2}x");
+
+ // Il punteggio di Brier: media del quadrato dell'errore fra probabilità e
+ // esito. È la misura onesta di una probabilità: premia chi è calibrato E
+ // discrimina. Si confronta con quello del tasso di base, che è il minimo
+ // ottenibile senza guardare nulla.
+ var brier = rows.Average(r => Math.Pow(r.P - (r.Unanswered ? 1 : 0), 2));
+ var brierBase = rows.Average(r => Math.Pow(baseRate - (r.Unanswered ? 1 : 0), 2));
+ report.AppendLine($"punteggio di Brier : {brier:F6} (tasso di base: {brierBase:F6}, miglioramento {(1 - brier / Math.Max(1e-12, brierBase)):P1})");
report.AppendLine();
var sorted = rows.OrderByDescending(r => r.P).ToList();
@@ -204,9 +212,10 @@ namespace AutoBidder.Ml
return new Report
{
Text = report.ToString(),
- Files = files.Count,
+ Auctions = totalAuctions,
Usable = sessions.Count,
Lift5 = liftTop5,
+ Brier = brier,
CalibrationFactor = model.CalibrationFactor,
MyBids = mie.Count,
MyBlocked = fermate.Count,
diff --git a/Mimante/Ml/LearningService.cs b/Mimante/Ml/LearningService.cs
index e0c14fd..6ff369d 100644
--- a/Mimante/Ml/LearningService.cs
+++ b/Mimante/Ml/LearningService.cs
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
+using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Utilities;
@@ -19,49 +20,84 @@ namespace AutoBidder.Ml
/// risponde a «quanto costa di solito vincere questo prodotto a quest'ora?»: medie per
/// prodotto, fascia oraria e tipo di giorno, con restringimento dove i dati scarseggiano.
///
- /// Da dove impara. Dai dossier. Ogni puntata di ogni asta chiusa è un
+ /// Da dove impara. Dal database. Ogni puntata di ogni asta chiusa è un
/// esempio: rimasta senza risposta oppure no. Non dalle nostre vittorie, che sono
- /// diciannove in duemila aste: un modello addestrato su quelle non sarebbe un modello.
- /// Il comportamento avversario non dipende da chi ha puntato, e questo rende
- /// utilizzabili i milioni di puntate altrui.
+ /// troppo poche: il comportamento avversario non dipende da chi ha puntato, e questo
+ /// rende utilizzabili le puntate altrui.
///
- /// Il primo addestramento è incrementale e in sottofondo: i dossier sono
- /// migliaia e pesano gigabyte, e si leggono un po' per avvio, dal più recente, con un
- /// tempo massimo per sessione. Quello che è già stato letto non si rilegge: un elenco
- /// dei file appresi lo tiene a mente. Il profilo delle aste invece parte subito dallo
- /// storico compatto, che è piccolo e ha le puntate del vincitore già verificate.
+ /// Quando impara. Alla chiusura di ogni asta, subito. All'avvio, in
+ /// sottofondo e con un tempo massimo, ripassa le aste chiuse che non risultano ancora
+ /// apprese (colonna learned_at): capita dopo un riaddestramento da capo o se
+ /// l'applicazione era stata chiusa nel mezzo.
///
/// Cosa NON può dire. Il modello impara come finiscono le aste senza di
/// noi. La nostra puntata cambia il gioco: chi stava per smettere può rispondere.
- /// Nessun dato storico dice questo. Il modello dà la stima migliore possibile di
- /// quando gli altri sono esausti, ed è quanto si può chiedere ai dati.
+ /// Nessun dato storico dice questo.
///
public static class LearningService
{
private static readonly object Sync = new();
// I salvataggi hanno un lucchetto tutto loro: all'avvio più aste concluse nel
- // frattempo vengono chiuse insieme, ognuna su un proprio thread, e senza questo
- // scrivevano lo stesso file nello stesso istante — "file in uso da un altro
- // processo", che in realtà eravamo noi.
+ // frattempo vengono chiuse insieme, ognuna su un proprio thread.
private static readonly object SaveSync = new();
private static OnlineLogit _model = new(BidFeatures.Size);
+ private static NeuralNet _challenger = NewChallenger();
+ private static ThompsonBandit _bandit = new();
private static AuctionProfileStats _profile = new();
- private static readonly HashSet _ingested = new(StringComparer.OrdinalIgnoreCase);
private static int _auctionsLearned;
private static bool _loaded;
private static volatile bool _bootstrapRunning;
+ // ── Campione e sfidante ──────────────────────────────────────────
+ //
+ // Il modello logistico (campione) e la rete neurale (sfidante) imparano dagli
+ // stessi esempi. A ogni esempio, PRIMA di imparare, entrambi prevedono: il
+ // quadrato dell'errore va in una media mobile, che è il punteggio di Brier
+ // prequenziale di ciascuno. Chi ce l'ha più basso risponde al motore. La rete
+ // non comanda mai prima di qualche migliaio di esempi: all'inizio direbbe cose
+ // a caso.
+ private static double _brierLogit = double.NaN;
+ private static double _brierNet = double.NaN;
+ private static long _brierSamples;
+ private const double BrierWindow = 20_000;
+ private const long ChallengerMinSamples = 5_000;
+
+ private static NeuralNet NewChallenger() =>
+ new(new[] { BidFeatures.Size, 32, 16, 1 }, seed: 42) { LearningRate = 0.001, L2 = 1e-5 };
+
+ /// La rete neurale ha un Brier prequenziale più basso del logistico: risponde lei.
+ public static bool ChallengerLeads
+ {
+ get { lock (Sync) return _brierSamples >= ChallengerMinSamples && !double.IsNaN(_brierNet) && _brierNet < _brierLogit; }
+ }
+
+ public static (double Logit, double Net, long Samples) BrierScores
+ {
+ get { lock (Sync) return (_brierLogit, _brierNet, _brierSamples); }
+ }
+
+ public static long ChallengerUpdates { get { lock (Sync) return _challenger.Updates; } }
+
+ /// Il bandit Thompson: la seconda policy, giudicata in shadow sugli stessi istanti.
+ public static ThompsonBandit Bandit => _bandit;
+
/// Righe di registro sull'apprendimento: chi si aggancia le scrive dove vuole.
public static event Action? OnLog;
+ private static AuctionDatabase Db => AuctionDatabase.Instance;
+
// ── 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 ManifestFile => Path.Combine(Folder, "dossier-appresi.txt");
+ 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");
// ── Stato, per l'interfaccia e il registro ───────────────────────
@@ -71,6 +107,19 @@ namespace AutoBidder.Ml
public static long ProfileAuctions { get { lock (Sync) return _profile.AuctionsObserved; } }
public static bool BootstrapRunning => _bootstrapRunning;
+ /// Versione del modello, per marcare le decisioni: chi risponde e quanti aggiornamenti ha.
+ public static string ModelVersion
+ {
+ get
+ {
+ lock (Sync)
+ {
+ var netLeads = _brierSamples >= ChallengerMinSamples && !double.IsNaN(_brierNet) && _brierNet < _brierLogit;
+ return netLeads ? $"mlp-{BidFeatures.Size}-{_challenger.Updates}" : $"logit-{BidFeatures.Size}-{_model.Updates}";
+ }
+ }
+ }
+
///
/// Abbastanza aste apprese perché il cancello sul valore atteso possa fermare una
/// puntata. Prima di questa soglia il modello parla, ma non decide.
@@ -97,11 +146,6 @@ namespace AutoBidder.Ml
}
// ── Ultime decisioni, per la scheda ──────────────────────────────
- //
- // Il motore chiede al modello a ogni giro utile, e la risposta finisce nel
- // registro dell'asta. Qui se ne tiene una coda corta per vederle tutte insieme:
- // e' l'unico modo di capire a colpo d'occhio se il cancello sta fermando troppo o
- // troppo poco.
/// Una risposta data al motore.
public readonly record struct Decision(
@@ -114,8 +158,6 @@ namespace AutoBidder.Ml
{
lock (_decisions)
{
- // Lo stesso giro dell'asta produce molte risposte uguali di fila: si tiene
- // solo il cambiamento, altrimenti la coda si riempie di una sola asta.
if (_decisions.Count > 0)
{
var last = _decisions.Last();
@@ -146,15 +188,15 @@ namespace AutoBidder.Ml
}
///
- /// Valuta il modello sui dossier di questa macchina, in sottofondo, e salva il
- /// rapporto. È la stessa valutazione del target da riga di comando.
+ /// Valuta il modello sulle aste del database, in sottofondo, e salva il rapporto.
+ /// È la stessa valutazione del target da riga di comando.
///
- public static Task EvaluateAsync(int maxFiles, Action? progress, System.Threading.CancellationToken ct)
+ public static Task EvaluateAsync(int maxAuctions, Action? progress, System.Threading.CancellationToken ct)
{
return Task.Run(() =>
{
- var me = AuctionDossier.CurrentUsername;
- var report = LearningEvaluator.Run(AppPaths.AuctionLogFolder, maxFiles, string.IsNullOrEmpty(me) ? null : me, progress, ct);
+ var me = AuctionRecorder.CurrentUsername;
+ var report = LearningEvaluator.Run(Db, maxAuctions, string.IsNullOrEmpty(me) ? null : me, progress, ct);
try
{
@@ -170,15 +212,15 @@ namespace AutoBidder.Ml
// ── Ricominciare da capo ─────────────────────────────────────────
///
- /// Butta modello, profilo ed elenco dei dossier letti, e ricomincia a studiare
- /// dall'archivio. Serve quando si cambiano le variabili o si sospetta che il
- /// modello abbia imparato da dati sbagliati. Non tocca i dossier.
+ /// Butta modello e profilo, smarca tutte le aste come non apprese e ricomincia a
+ /// studiarle dal database. Serve quando si cambiano le variabili o si sospetta che
+ /// il modello abbia imparato da dati sbagliati.
///
public static Task RetrainFromScratchAsync(AppSettings settings)
{
Forget();
- OnLog?.Invoke("[APPRENDIMENTO] Ricomincio da capo: profilo dallo storico, poi tutti i dossier.");
+ OnLog?.Invoke("[APPRENDIMENTO] Ricomincio da capo: profilo dallo storico, poi tutte le aste chiuse.");
BootstrapProfileFromHistory();
// Un riaddestramento voluto ha diritto a tutto il tempo che serve.
@@ -187,18 +229,20 @@ namespace AutoBidder.Ml
}
///
- /// Dimentica tutto: modello, profilo, elenco dei dossier studiati, decisioni e
- /// valutazione — in memoria e su disco. Senza, il modello ancora vivo riscriverebbe
- /// il file al primo salvataggio. Al prossimo avvio si ristudiano i dossier da capo.
+ /// Dimentica tutto: modello, profilo, contatore, decisioni e valutazione — in
+ /// memoria e su disco — e smarca le aste come non apprese. Senza, il modello
+ /// ancora vivo riscriverebbe il file al primo salvataggio.
///
public static void Forget()
{
lock (Sync)
{
_model = new OnlineLogit(BidFeatures.Size);
+ _challenger = NewChallenger();
+ _bandit = new ThompsonBandit();
_profile = new AuctionProfileStats();
- _ingested.Clear();
_auctionsLearned = 0;
+ _brierLogit = double.NaN; _brierNet = double.NaN; _brierSamples = 0;
}
lock (_decisions) _decisions.Clear();
@@ -207,7 +251,7 @@ namespace AutoBidder.Ml
{
lock (SaveSync)
{
- foreach (var f in new[] { ModelFile, ProfileFile, ManifestFile, EvaluationFile })
+ foreach (var f in new[] { ModelFile, ProfileFile, CounterFile, LegacyManifestFile, EvaluationFile, ChallengerFile, BanditFile, BrierFile })
if (File.Exists(f)) File.Delete(f);
}
}
@@ -215,6 +259,9 @@ namespace AutoBidder.Ml
{
OnLog?.Invoke($"[APPRENDIMENTO] Archivio non cancellato del tutto: {ex.Message}");
}
+
+ try { Db.ExecuteNow("UPDATE auctions SET learned_at = NULL"); }
+ catch (Exception ex) { OnLog?.Invoke($"[APPRENDIMENTO] Aste non smarcate: {ex.Message}"); }
}
// ── Avvio ────────────────────────────────────────────────────────
@@ -239,45 +286,72 @@ namespace AutoBidder.Ml
_model = OnlineLogit.FromJson(File.ReadAllText(ModelFile), BidFeatures.Size);
if (File.Exists(ProfileFile))
_profile = AuctionProfileStats.FromJson(File.ReadAllText(ProfileFile));
- if (File.Exists(ManifestFile))
+ 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))
{
- foreach (var line in File.ReadLines(ManifestFile))
- {
- var l = line.Trim();
- if (l.Length == 0) continue;
- if (l.StartsWith("#", StringComparison.Ordinal))
- {
- if (l.StartsWith("#appresi=", StringComparison.Ordinal) &&
- int.TryParse(l["#appresi=".Length..], out var n)) _auctionsLearned = n;
- continue;
- }
- _ingested.Add(l);
- }
+ var parts = File.ReadAllText(BrierFile).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) &&
+ long.TryParse(parts[2], out var bs))
+ { _brierLogit = bl; _brierNet = bn; _brierSamples = bs; }
}
+
+ _auctionsLearned = ReadCounter();
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Archivio illeggibile, si riparte da zero: {ex.Message}");
_model = new OnlineLogit(BidFeatures.Size);
_profile = new AuctionProfileStats();
- _ingested.Clear();
_auctionsLearned = 0;
}
}
OnLog?.Invoke($"[APPRENDIMENTO] Modello: {ModelUpdates:N0} aggiornamenti da {AuctionsLearned:N0} aste; " +
- $"profilo: {ProfileAuctions:N0} aste; dossier già letti: {_ingested.Count:N0}");
+ $"profilo: {ProfileAuctions:N0} aste");
+ }
+
+ ///
+ /// Il contatore delle aste apprese. Il file nuovo porta solo il numero; quello
+ /// delle versioni precedenti lo portava in testa all'elenco dei dossier letti, e
+ /// si legge ancora per non azzerare un modello che ha già studiato migliaia di aste.
+ ///
+ private static int ReadCounter()
+ {
+ foreach (var f in new[] { CounterFile, LegacyManifestFile })
+ {
+ if (!File.Exists(f)) continue;
+ foreach (var line in File.ReadLines(f))
+ {
+ 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;
}
private static void Save()
{
try
{
- string model, profile;
+ string model, profile, challenger, bandit, brier;
+ int learned;
lock (Sync)
{
model = _model.ToJson();
profile = _profile.ToJson();
+ challenger = _challenger.ToJson();
+ bandit = _bandit.ToJson();
+ brier = string.Join(';',
+ _brierLogit.ToString("R", System.Globalization.CultureInfo.InvariantCulture),
+ _brierNet.ToString("R", System.Globalization.CultureInfo.InvariantCulture),
+ _brierSamples);
+ learned = _auctionsLearned;
}
lock (SaveSync)
@@ -285,6 +359,10 @@ namespace AutoBidder.Ml
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);
}
}
catch (Exception ex)
@@ -293,34 +371,9 @@ namespace AutoBidder.Ml
}
}
- private static void SaveManifest()
- {
- try
- {
- string[] lines;
- lock (Sync)
- {
- lines = new[] { "#appresi=" + _auctionsLearned }
- .Concat(_ingested.OrderBy(x => x, StringComparer.Ordinal))
- .ToArray();
- }
-
- lock (SaveSync)
- {
- Directory.CreateDirectory(Folder);
- File.WriteAllLines(ManifestFile, lines);
- }
- }
- catch (Exception ex)
- {
- OnLog?.Invoke($"[APPRENDIMENTO] Elenco dei dossier letti non salvato: {ex.Message}");
- }
- }
-
///
/// Il profilo parte dallo storico compatto: piccolo, immediato, e con le puntate
- /// del vincitore già passate dal controllo di coerenza. Solo se il profilo è vuoto,
- /// perché lo storico è la stessa fonte che poi lo alimenta asta per asta.
+ /// del vincitore già passate dal controllo di coerenza. Solo se il profilo è vuoto.
///
private static void BootstrapProfileFromHistory()
{
@@ -329,6 +382,8 @@ namespace AutoBidder.Ml
try
{
var records = CompletedAuctionsStore.LoadAll();
+ if (records.Count == 0) return;
+
var fresh = new AuctionProfileStats();
foreach (var r in records)
@@ -347,8 +402,7 @@ namespace AutoBidder.Ml
}
///
- /// Legge i dossier non ancora appresi, dal più recente, finché c'è tempo. Il tempo è
- /// per avvio: la prima volta ne servono diversi, poi resta solo l'asta appena chiusa.
+ /// Studia le aste chiuse non ancora apprese, dalla più recente, finché c'è tempo.
///
private static async Task BootstrapAsync(AppSettings settings)
{
@@ -359,55 +413,53 @@ namespace AutoBidder.Ml
{
await Task.Delay(3000).ConfigureAwait(false); // prima l'avvio, poi lo studio
- var folder = AppPaths.AuctionLogFolder;
- if (!Directory.Exists(folder)) return;
-
- string[] pending;
- lock (Sync)
+ List pending;
+ try
{
- pending = Directory.GetFiles(folder, "*.jsonl")
- .Where(f => !_ingested.Contains(Path.GetFileName(f)))
- .OrderByDescending(Path.GetFileName, StringComparer.Ordinal)
- .ToArray();
+ Db.Flush();
+ pending = Db.Query("SELECT auction_id FROM auctions WHERE outcome IS NOT NULL AND learned_at IS NULL ORDER BY end_time DESC")
+ .Select(r => r.Str("auction_id")).ToList();
+ }
+ catch (Exception ex)
+ {
+ OnLog?.Invoke($"[APPRENDIMENTO] Elenco delle aste da studiare non disponibile: {ex.Message}");
+ return;
}
- if (pending.Length == 0) return;
+ if (pending.Count == 0) return;
var budget = TimeSpan.FromSeconds(Math.Max(10, settings.LearningBootstrapSecondsPerStart));
var sw = Stopwatch.StartNew();
var done = 0;
var usable = 0;
- OnLog?.Invoke($"[APPRENDIMENTO] {pending.Length:N0} dossier da leggere, al massimo {budget.TotalSeconds:N0} s per questo avvio");
+ OnLog?.Invoke($"[APPRENDIMENTO] {pending.Count:N0} aste da studiare, al massimo {budget.TotalSeconds:N0} s per questo avvio");
- foreach (var file in pending)
+ foreach (var id in pending)
{
if (sw.Elapsed > budget) break;
- if (IngestFile(file)) usable++;
+ if (IngestAuction(id)) usable++;
done++;
if (done % 100 == 0)
{
Save();
- SaveManifest();
- OnLog?.Invoke($"[APPRENDIMENTO] {done:N0} dossier letti, {usable:N0} utilizzabili, {ModelUpdates:N0} aggiornamenti");
+ OnLog?.Invoke($"[APPRENDIMENTO] {done:N0} aste studiate, {usable:N0} utilizzabili, {ModelUpdates:N0} aggiornamenti");
}
- // Un respiro ogni file: il motore delle aste ha la precedenza.
- await Task.Yield();
+ await Task.Yield(); // il motore delle aste ha la precedenza
}
Save();
- SaveManifest();
- var remaining = pending.Length - done;
- OnLog?.Invoke($"[APPRENDIMENTO] Letti {done:N0} dossier ({usable:N0} utilizzabili) in {sw.Elapsed.TotalSeconds:N0} s; " +
+ var remaining = pending.Count - done;
+ OnLog?.Invoke($"[APPRENDIMENTO] Studiate {done:N0} aste ({usable:N0} utilizzabili) in {sw.Elapsed.TotalSeconds:N0} s; " +
(remaining > 0 ? $"ne restano {remaining:N0} per i prossimi avvii." : "archivio completo."));
}
catch (Exception ex)
{
- OnLog?.Invoke($"[APPRENDIMENTO] Studio dei dossier interrotto: {ex.Message}");
+ OnLog?.Invoke($"[APPRENDIMENTO] Studio delle aste interrotto: {ex.Message}");
}
finally
{
@@ -416,36 +468,71 @@ namespace AutoBidder.Ml
}
///
- /// Impara da un dossier. Restituisce true se era utilizzabile. Un file letto si
- /// segna come letto anche se non era utilizzabile: rileggerlo non lo renderebbe tale.
+ /// Impara da un'asta del database e la marca come appresa. Restituisce true se era
+ /// utilizzabile. Un'asta studiata si marca anche se non era utilizzabile:
+ /// ristudiarla non la renderebbe tale.
///
- private static bool IngestFile(string path)
+ private static bool IngestAuction(string auctionId)
{
- var name = Path.GetFileName(path);
- var session = DossierTrainingReader.ReadFile(path);
+ var session = DossierTrainingReader.FromDatabase(Db, auctionId, AuctionRecorder.CurrentUsername);
+ var usable = false;
lock (Sync)
{
- _ingested.Add(name);
- if (session == null || !session.IsUsable) return false;
+ if (session != null && session.IsUsable)
+ {
+ foreach (var (ctx, unanswered, _) in DossierTrainingReader.Examples(session))
+ {
+ var x = BidFeatures.Build(ctx);
- foreach (var (ctx, unanswered, _) in DossierTrainingReader.Examples(session))
- _model.Update(BidFeatures.Build(ctx), unanswered);
+ // Prima si prevede, poi si impara: è la valutazione prequenziale
+ // fatta dal vivo, e decide chi dei due risponde al motore.
+ var y = unanswered ? 1.0 : 0.0;
+ var eL = _model.PredictCalibrated(x) - y;
+ var eN = _challenger.PredictCalibrated(x) - y;
+ var k = 1.0 / Math.Min(BrierWindow, _brierSamples + 1);
+ _brierLogit = double.IsNaN(_brierLogit) ? eL * eL : _brierLogit + k * (eL * eL - _brierLogit);
+ _brierNet = double.IsNaN(_brierNet) ? eN * eN : _brierNet + k * (eN * eN - _brierNet);
+ _brierSamples++;
- _auctionsLearned++;
+ _model.Update(x, unanswered);
+ _challenger.Update(x, unanswered);
+ }
+
+ _auctionsLearned++;
+ usable = true;
+ }
}
- return true;
+ Db.Enqueue("UPDATE auctions SET learned_at = ?2 WHERE auction_id = ?1", auctionId, AuctionDatabase.Now());
+ return usable;
+ }
+
+ ///
+ /// Il bandit impara dalle decisioni di quest'asta, ora che hanno un esito: per
+ /// ogni istante di decisione si sa se una puntata lì sarebbe stata l'ultima.
+ ///
+ private static int LearnBanditFromAuction(string auctionId)
+ {
+ var n = 0;
+ foreach (var r in Db.Query("SELECT context_key, outcome FROM bot_decisions WHERE auction_id = ?1 AND context_key IS NOT NULL AND outcome IS NOT NULL", auctionId))
+ {
+ var outcome = r.Str("outcome");
+ var unanswered = outcome is "won" or "would_win" or "missed";
+ _bandit.Observe(r.Str("context_key"), unanswered);
+ n++;
+ }
+ return n;
}
// ── Chiusura di un'asta: si impara subito ────────────────────────
///
- /// Da chiamare quando un'asta è chiusa e il suo dossier è stato scritto. Aggiorna
- /// il profilo con lo storico appena registrato e il modello con le puntate del
- /// dossier, poi salva: la prossima decisione le vede già.
+ /// 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
+ /// puntate dell'asta, poi salva: la prossima decisione le vede già.
///
- public static void OnAuctionClosed(CompletedAuctionRecord record, string? dossierPath)
+ public static void OnAuctionClosed(CompletedAuctionRecord record)
{
if (record == null) return;
@@ -457,19 +544,19 @@ namespace AutoBidder.Ml
AuctionIntegrity.TrustedWinnerBids(record), Ratio(record.FinalPrice, record.BuyNowPrice));
}
- var learned = false;
- if (!string.IsNullOrWhiteSpace(dossierPath) && File.Exists(dossierPath))
- {
- // Il dossier è appena stato svuotato su disco da Close: si può leggere.
- learned = IngestFile(dossierPath);
- }
+ // 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);
Save();
- SaveManifest();
+
+ if (decisioni > 0)
+ OnLog?.Invoke($"[APPRENDIMENTO] '{record.Name}': {decisioni} decisioni con esito, bandit aggiornato ({_bandit.Observations:N0} osservazioni)");
OnLog?.Invoke(learned
? $"[APPRENDIMENTO] '{record.Name}': appresa ({ModelUpdates:N0} aggiornamenti, {AuctionsLearned:N0} aste)"
- : $"[APPRENDIMENTO] '{record.Name}': profilo aggiornato, dossier non utilizzabile per il modello");
+ : $"[APPRENDIMENTO] '{record.Name}': profilo aggiornato, puntate non utilizzabili per il modello");
}
catch (Exception ex)
{
@@ -484,6 +571,27 @@ namespace AutoBidder.Ml
/// c'è abbastanza contesto (nessuna puntata recente vista).
///
public static double? PredictUnanswered(AuctionInfo auction, AuctionState state, DateTime nowLocal, string? myUsername)
+ {
+ var ctx = BuildContext(auction, state, nowLocal, myUsername);
+ return ctx is { } c ? PredictUnanswered(c) : null;
+ }
+
+ /// La probabilità per un contesto già costruito: risponde il campione del momento.
+ public static double PredictUnanswered(in BidContext ctx)
+ {
+ var x = BidFeatures.Build(ctx);
+ lock (Sync)
+ {
+ var netLeads = _brierSamples >= ChallengerMinSamples && !double.IsNaN(_brierNet) && _brierNet < _brierLogit;
+ return netLeads ? _challenger.PredictCalibrated(x) : _model.PredictCalibrated(x);
+ }
+ }
+
+ ///
+ /// Lo stato dell'asta nell'istante della decisione, ridotto alle variabili del
+ /// modello. Null quando non c'è abbastanza contesto (nessuna puntata recente).
+ ///
+ public static BidContext? BuildContext(AuctionInfo auction, AuctionState state, DateTime nowLocal, string? myUsername)
{
if (auction == null || state == null) return null;
@@ -495,7 +603,6 @@ namespace AutoBidder.Ml
? state.ServerUnixSeconds - last.Timestamp
: -1;
- // Quota di autopuntate: solo sui tipi dichiarati dal server.
int declared = 0, auto = 0;
foreach (var b in recent.Take(10))
{
@@ -510,10 +617,10 @@ namespace AutoBidder.Ml
for (var i = 0; i < recent.Count; i++)
{
if (recent[i].IsManual) { sinceManual = i; break; }
- if (!recent[i].IsAuto) break; // tipo ignoto: non si conta
+ if (!recent[i].IsAuto) break;
}
- var ctx = new BidContext(
+ return new BidContext(
CycleSeconds: gap,
AutoShareRecent: autoShare,
DistinctBiddersRecent: Math.Max(1, distinct),
@@ -527,9 +634,6 @@ namespace AutoBidder.Ml
SameBidderAsTwoAgo: recent.Count >= 2 && !string.IsNullOrEmpty(myUsername) &&
string.Equals(recent[1].Username, myUsername, StringComparison.OrdinalIgnoreCase),
ProductKey: ProductKeyHelper.GenerateProductKey(auction.Name));
-
- var x = BidFeatures.Build(ctx);
- lock (Sync) return _model.PredictCalibrated(x);
}
/// Cosa aspettarsi da un'asta di questo prodotto se chiudesse a quest'ora.
@@ -547,9 +651,11 @@ namespace AutoBidder.Ml
lock (Sync)
{
_model = new OnlineLogit(BidFeatures.Size);
+ _challenger = NewChallenger();
+ _bandit = new ThompsonBandit();
_profile = new AuctionProfileStats();
- _ingested.Clear();
_auctionsLearned = 0;
+ _brierLogit = double.NaN; _brierNet = double.NaN; _brierSamples = 0;
_loaded = true;
}
}
diff --git a/Mimante/Ml/NeuralNet.cs b/Mimante/Ml/NeuralNet.cs
new file mode 100644
index 0000000..8a6b610
--- /dev/null
+++ b/Mimante/Ml/NeuralNet.cs
@@ -0,0 +1,274 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+
+namespace AutoBidder.Ml
+{
+ ///
+ /// Una rete neurale densa (MLP) in C# puro: strati completamente connessi, ReLU
+ /// nascosti, uscita lineare, ottimizzatore Adam, regolarizzazione L2. Niente librerie.
+ ///
+ /// A cosa serve qui. Due usi. Come sfidante del modello logistico
+ /// per P(senza risposta): stesse variabili, stessi esempi, ma può imparare le
+ /// interazioni fra fasce che un modello lineare non vede (un ciclo lungo vale cose
+ /// diverse alle 12 e a mezzanotte). E come funzione Q dell'agente di rinforzo nel
+ /// simulatore. La valutazione prequenziale decide chi comanda: il modello che ha il
+ /// punteggio di Brier più basso sulle aste recenti.
+ ///
+ /// Perché piccola. Le variabili sono una settantina di indicatori a
+ /// fasce; una rete 70→32→16→1 ha circa tremila pesi e si addestra online, un esempio
+ /// alla volta, in microsecondi. Più grande non imparerebbe di più da questi dati:
+ /// li memorizzerebbe.
+ ///
+ /// La calibrazione in linea è la stessa del modello logistico: il rapporto
+ /// osservato/previsto sugli ultimi esempi corregge la scala, perché il mercato
+ /// deriva e una rete addestrata su ieri sovrastima oggi.
+ ///
+ public sealed class NeuralNet
+ {
+ private readonly int[] _sizes;
+ private readonly double[][] _w; // per strato: [out * in]
+ private readonly double[][] _b; // per strato: [out]
+ private readonly double[][] _mw, _vw, _mb, _vb; // momenti di Adam
+ private long _t;
+
+ // Attivazioni dell'ultimo passaggio in avanti, per la retropropagazione.
+ private readonly double[][] _a; // _a[0] = ingresso, _a[L] = uscita (lineare)
+ private readonly double[][] _z; // pre-attivazioni
+
+ public double LearningRate { get; set; } = 0.001;
+ public double L2 { get; set; } = 1e-5;
+ public double Beta1 { get; set; } = 0.9;
+ public double Beta2 { get; set; } = 0.999;
+
+ public long Updates { get; private set; }
+
+ // ── Calibrazione in linea (come OnlineLogit) ─────────────────────
+ private double _observedEma;
+ private double _predictedEma;
+ private const double CalibrationWindow = 20_000;
+
+ /// Fattore che riporta la scala delle probabilità a quella osservata di recente.
+ public double CalibrationFactor =>
+ _predictedEma > 1e-9 && Updates > 200 ? Math.Clamp(_observedEma / _predictedEma, 0.25, 4.0) : 1.0;
+
+ public int InputSize => _sizes[0];
+ public int OutputSize => _sizes[^1];
+ public IReadOnlyList Sizes => _sizes;
+
+ public NeuralNet(int[] layerSizes, int seed = 42)
+ {
+ if (layerSizes == null || layerSizes.Length < 2) throw new ArgumentException("servono almeno ingresso e uscita");
+ _sizes = (int[])layerSizes.Clone();
+
+ var layers = _sizes.Length - 1;
+ _w = new double[layers][]; _b = new double[layers][];
+ _mw = new double[layers][]; _vw = new double[layers][];
+ _mb = new double[layers][]; _vb = new double[layers][];
+ _a = new double[_sizes.Length][];
+ _z = new double[_sizes.Length][];
+
+ var rng = new Random(seed);
+ for (var l = 0; l < layers; l++)
+ {
+ int nin = _sizes[l], nout = _sizes[l + 1];
+ _w[l] = new double[nout * nin];
+ _b[l] = new double[nout];
+ _mw[l] = new double[nout * nin]; _vw[l] = new double[nout * nin];
+ _mb[l] = new double[nout]; _vb[l] = new double[nout];
+
+ // Inizializzazione di He: adatta alla ReLU, tiene la varianza stabile in profondità.
+ var scale = Math.Sqrt(2.0 / nin);
+ for (var i = 0; i < _w[l].Length; i++) _w[l][i] = Gaussian(rng) * scale;
+ }
+
+ for (var l = 0; l < _sizes.Length; l++) { _a[l] = new double[_sizes[l]]; _z[l] = new double[_sizes[l]]; }
+ }
+
+ private static double Gaussian(Random rng)
+ {
+ var u1 = 1.0 - rng.NextDouble();
+ var u2 = rng.NextDouble();
+ return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
+ }
+
+ // ── In avanti ────────────────────────────────────────────────────
+
+ /// Uscita lineare della rete (senza sigmoide).
+ public double[] Forward(double[] x)
+ {
+ if (x.Length != _sizes[0]) throw new ArgumentException($"ingresso di {x.Length} valori, attesi {_sizes[0]}");
+ Array.Copy(x, _a[0], x.Length);
+
+ var layers = _sizes.Length - 1;
+ for (var l = 0; l < layers; l++)
+ {
+ int nin = _sizes[l], nout = _sizes[l + 1];
+ var w = _w[l]; var b = _b[l]; var ain = _a[l]; var z = _z[l + 1]; var aout = _a[l + 1];
+
+ for (var o = 0; o < nout; o++)
+ {
+ var sum = b[o];
+ var row = o * nin;
+ for (var i = 0; i < nin; i++) sum += w[row + i] * ain[i];
+ z[o] = sum;
+ aout[o] = l == layers - 1 ? sum : Math.Max(0, sum); // ReLU nascosti, uscita lineare
+ }
+ }
+
+ return (double[])_a[layers].Clone();
+ }
+
+ public static double Sigmoid(double z) => z >= 0 ? 1.0 / (1.0 + Math.Exp(-z)) : Math.Exp(z) / (1.0 + Math.Exp(z));
+
+ /// Probabilità grezza (rete a una uscita, perdita logistica).
+ public double Predict(double[] x) => Sigmoid(Forward(x)[0]);
+
+ /// Probabilità con la scala corretta dalla calibrazione in linea.
+ public double PredictCalibrated(double[] x) => Math.Clamp(Predict(x) * CalibrationFactor, 1e-6, 1 - 1e-6);
+
+ // ── Addestramento ────────────────────────────────────────────────
+
+ ///
+ /// Un passo online con perdita logistica su un'uscita sola: è il caso
+ /// «P(senza risposta)». Il gradiente sull'uscita lineare è semplicemente p − y.
+ ///
+ public void Update(double[] x, bool y)
+ {
+ var p = Predict(x);
+ var target = y ? 1.0 : 0.0;
+
+ // Calibrazione: media mobile di osservato e previsto.
+ var k = Math.Min(1.0, 1.0 / Math.Min(CalibrationWindow, Math.Max(1, Updates + 1)));
+ _observedEma += k * (target - _observedEma);
+ _predictedEma += k * (p - _predictedEma);
+
+ Backward(new[] { p - target });
+ }
+
+ /// Un passo con perdita quadratica su tutte le uscite (regressione, funzione Q).
+ public double UpdateRegression(double[] x, double[] target)
+ {
+ var y = Forward(x);
+ var grad = new double[y.Length];
+ var loss = 0.0;
+ for (var i = 0; i < y.Length; i++)
+ {
+ grad[i] = y[i] - target[i];
+ loss += 0.5 * grad[i] * grad[i];
+ }
+ Backward(grad);
+ return loss;
+ }
+
+ ///
+ /// Come ma aggiorna una sola uscita (l'azione
+ /// scelta): è ciò che serve al Q-learning, dove il bersaglio esiste solo per
+ /// l'azione presa.
+ ///
+ public double UpdateOutput(double[] x, int output, double target)
+ {
+ var y = Forward(x);
+ var grad = new double[y.Length];
+ grad[output] = y[output] - target;
+ Backward(grad);
+ return 0.5 * grad[output] * grad[output];
+ }
+
+ /// Retropropagazione dal gradiente sull'uscita lineare, con passo di Adam.
+ private void Backward(double[] gradOut)
+ {
+ var layers = _sizes.Length - 1;
+ var delta = (double[])gradOut.Clone();
+ _t++;
+ Updates++;
+
+ var lrT = LearningRate * Math.Sqrt(1 - Math.Pow(Beta2, _t)) / (1 - Math.Pow(Beta1, _t));
+
+ for (var l = layers - 1; l >= 0; l--)
+ {
+ int nin = _sizes[l], nout = _sizes[l + 1];
+ var w = _w[l]; var b = _b[l]; var ain = _a[l];
+ var prevDelta = l > 0 ? new double[nin] : null;
+
+ for (var o = 0; o < nout; o++)
+ {
+ var d = delta[o];
+ var row = o * nin;
+
+ // gradiente rispetto all'ingresso di questo strato (per lo strato sotto)
+ if (prevDelta != null)
+ for (var i = 0; i < nin; i++) prevDelta[i] += w[row + i] * d;
+
+ // Adam sui pesi
+ for (var i = 0; i < nin; i++)
+ {
+ var g = d * ain[i] + L2 * w[row + i];
+ var idx = row + i;
+ _mw[l][idx] = Beta1 * _mw[l][idx] + (1 - Beta1) * g;
+ _vw[l][idx] = Beta2 * _vw[l][idx] + (1 - Beta2) * g * g;
+ w[idx] -= lrT * _mw[l][idx] / (Math.Sqrt(_vw[l][idx]) + 1e-8);
+ }
+
+ _mb[l][o] = Beta1 * _mb[l][o] + (1 - Beta1) * d;
+ _vb[l][o] = Beta2 * _vb[l][o] + (1 - Beta2) * d * d;
+ b[o] -= lrT * _mb[l][o] / (Math.Sqrt(_vb[l][o]) + 1e-8);
+ }
+
+ if (prevDelta != null)
+ {
+ // derivata della ReLU: passa solo dove la pre-attivazione era positiva
+ for (var i = 0; i < nin; i++) if (_z[l][i] <= 0) prevDelta[i] = 0;
+ delta = prevDelta;
+ }
+ }
+ }
+
+ // ── Persistenza ──────────────────────────────────────────────────
+
+ private sealed class Snapshot
+ {
+ public int[] Sizes { get; set; } = Array.Empty();
+ public double[][] W { get; set; } = Array.Empty();
+ public double[][] B { get; set; } = Array.Empty();
+ public long Updates { get; set; }
+ public double ObservedEma { get; set; }
+ public double PredictedEma { get; set; }
+ }
+
+ public string ToJson() => JsonSerializer.Serialize(new Snapshot
+ {
+ Sizes = _sizes, W = _w, B = _b, Updates = Updates, ObservedEma = _observedEma, PredictedEma = _predictedEma
+ });
+
+ ///
+ /// Ricarica i pesi. Passo, L2 e momenti di Adam NON si rileggono: i primi sono
+ /// parametri del codice, i momenti ripartono da zero (un breve riscaldamento).
+ /// Se la forma salvata non coincide con quella richiesta, si riparte da capo.
+ ///
+ public static NeuralNet FromJson(string json, int[] expectedSizes, int seed = 42)
+ {
+ var fresh = new NeuralNet(expectedSizes, seed);
+ try
+ {
+ var s = JsonSerializer.Deserialize(json);
+ if (s == null || !s.Sizes.SequenceEqual(expectedSizes)) return fresh;
+ for (var l = 0; l < fresh._w.Length; l++)
+ {
+ if (s.W[l].Length != fresh._w[l].Length || s.B[l].Length != fresh._b[l].Length) return new NeuralNet(expectedSizes, seed);
+ Array.Copy(s.W[l], fresh._w[l], s.W[l].Length);
+ Array.Copy(s.B[l], fresh._b[l], s.B[l].Length);
+ }
+ fresh.Updates = s.Updates;
+ fresh._observedEma = s.ObservedEma;
+ fresh._predictedEma = s.PredictedEma;
+ return fresh;
+ }
+ catch
+ {
+ return fresh;
+ }
+ }
+ }
+}
diff --git a/Mimante/Ml/PennyAuctionEnv.cs b/Mimante/Ml/PennyAuctionEnv.cs
new file mode 100644
index 0000000..eceddb7
--- /dev/null
+++ b/Mimante/Ml/PennyAuctionEnv.cs
@@ -0,0 +1,303 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutoBidder.Data;
+
+namespace AutoBidder.Ml
+{
+ ///
+ /// Il simulatore di un'asta al centesimo, con avversari sintetici tarati sui dati
+ /// osservati. È il banco di prova sicuro: qui una policy si può lasciar giocare
+ /// migliaia di aste senza spendere un centesimo, e si può misurare quello che la
+ /// rigiocata dei dati veri non può dire — come reagiscono gli altri a una nostra
+ /// puntata.
+ ///
+ /// Gli avversari. Quattro profili, come nel documento di progetto:
+ /// aggressivo punta presto e insiste; cecchino punta negli ultimi
+ /// secondi; autopuntata spara a 2 secondi esatti finché ha credito;
+ /// principiante punta a caso e molla presto. Ognuno ha un credito di puntate e
+ /// una persistenza che cresce con quanto ha già speso: è l'effetto del costo
+ /// affondato documentato da Augenblick, ed è ciò che rende le aste lunghe.
+ ///
+ /// La taratura. Da : quanti
+ /// puntatori distinti per asta, a che profondità del ciclo arrivano le puntate
+ /// (tabella resets), la quota di autopuntate, a che frazione del valore chiude
+ /// l'asta. Il simulatore non pretende di essere Bidoo: pretende di sbagliare nello
+ /// stesso verso su tutte le policy, così che il confronto fra loro sia onesto.
+ ///
+ /// Il ciclo. Ogni passo è un ciclo del timer. Si chiede all'agente cosa
+ /// fa (niente, oppure punta all'anticipo dato); gli avversari attivi estraggono
+ /// ciascuno il momento in cui punterebbero; vince chi arriva prima. Se nessuno punta
+ /// e non puntiamo noi, l'asta finisce e vince chi era in testa.
+ ///
+ public sealed class PennyAuctionEnv
+ {
+ public enum OpponentType { Aggressive, Sniper, AutoBid, Beginner }
+
+ /// Parametri appresi dai dati, con predefiniti sensati per quando i dati mancano.
+ public sealed class Calibration
+ {
+ public double Value { get; set; } = 11;
+ public double BidCost { get; set; } = 0.20;
+ public double TransactionFee { get; set; } = 1.0;
+ public double Shipping { get; set; } = 0;
+ public double TimerSeconds { get; set; } = 10;
+
+ /// Puntatori distinti per asta: media e dispersione.
+ public double MeanBidders { get; set; } = 4;
+ public double AutoShare { get; set; } = 0.35;
+
+ /// Istogramma della profondità dei cicli (secondi residui quando arriva la puntata), da resets.min_timer.
+ public double[] CycleDepthHistogram { get; set; } = { 0.05, 0.10, 0.30, 0.25, 0.15, 0.08, 0.04, 0.02, 0.005, 0.005 };
+
+ /// Prezzo finale in frazione del valore: mediana osservata, usata per tarare la persistenza.
+ public double MedianFinalPriceRatio { get; set; } = 0.06;
+
+ public int Auctions { get; set; }
+
+ public static Calibration FromDatabase(AuctionDatabase db, string? productKey = null)
+ {
+ var c = new Calibration();
+ try
+ {
+ db.Flush();
+ var where = productKey == null ? "" : " AND a.product_key = ?1";
+ var args = productKey == null ? Array.Empty() : new object?[] { productKey };
+
+ var a = db.QueryOne(
+ "SELECT COUNT(*) AS n, AVG(distinct_bidders) AS bidders, AVG(buy_now_price) AS v, AVG(bid_fee) AS fee, AVG(shipping) AS ship " +
+ "FROM auctions a WHERE outcome IS NOT NULL AND distinct_bidders > 0" + where, args);
+ if (a != null && a.Long("n") > 0)
+ {
+ c.Auctions = (int)a.Long("n");
+ c.MeanBidders = Math.Max(1, a.Dbl("bidders"));
+ if (a.DblOrNull("v") is > 0) c.Value = a.Dbl("v");
+ if (a.DblOrNull("fee") is > 0) c.BidCost = a.Dbl("fee");
+ if (a.DblOrNull("ship") is > 0) c.Shipping = a.Dbl("ship");
+ }
+
+ var ratios = db.Query(
+ "SELECT final_price / buy_now_price AS r FROM auctions a WHERE outcome IS NOT NULL AND buy_now_price > 0 AND final_price > 0" + where + " ORDER BY r", args)
+ .Select(r => r.Dbl("r")).ToList();
+ if (ratios.Count >= 5) c.MedianFinalPriceRatio = ratios[ratios.Count / 2];
+
+ var types = db.QueryOne(
+ "SELECT SUM(CASE WHEN b.bid_type = 'Auto' THEN 1 ELSE 0 END) AS auto, SUM(CASE WHEN b.bid_type IN ('Auto', 'Manuale') THEN 1 ELSE 0 END) AS known " +
+ "FROM bids b JOIN auctions a ON a.auction_id = b.auction_id WHERE a.outcome IS NOT NULL" + where, args);
+ if (types != null && types.Long("known") > 50) c.AutoShare = (double)types.Long("auto") / types.Long("known");
+
+ var depths = db.Query(
+ "SELECT r.min_timer AS d FROM resets r JOIN auctions a ON a.auction_id = r.auction_id WHERE r.min_timer IS NOT NULL AND a.outcome IS NOT NULL" + where, args)
+ .Select(r => r.Dbl("d")).ToList();
+ if (depths.Count >= 100)
+ {
+ var hist = new double[10];
+ foreach (var d in depths) hist[Math.Clamp((int)Math.Floor(d), 0, 9)]++;
+ var total = hist.Sum();
+ for (var i = 0; i < hist.Length; i++) hist[i] = Math.Max(0.002, hist[i] / total);
+ c.CycleDepthHistogram = hist;
+ }
+ }
+ catch { /* si resta sui predefiniti */ }
+ return c;
+ }
+ }
+
+ public sealed class Opponent
+ {
+ public OpponentType Type;
+ public int Budget; // puntate che è disposto a spendere
+ public int Spent;
+ public bool Active = true;
+
+ /// Probabilità di rispondere a un ciclo, crescente con lo speso (costo affondato).
+ public double Persistence(double baseP) =>
+ Math.Min(0.98, baseP + 0.04 * Math.Min(Spent, 15));
+ }
+
+ /// Lo stato osservabile dall'agente a ogni ciclo.
+ public readonly record struct Observation(
+ double Price,
+ double PriceRatio,
+ int ActiveOpponents,
+ int TotalBids,
+ double LastGapSeconds,
+ bool AutoBidPresent,
+ int MyBids,
+ double MyBidsEuro,
+ bool ImLeading);
+
+ public readonly record struct StepResult(Observation Next, double Reward, bool Done, bool Won);
+
+ private readonly Calibration _cal;
+ private readonly Random _rng;
+ private readonly List _opponents = new();
+
+ private double _price;
+ private int _totalBids;
+ private int _myBids;
+ private bool _imLeading;
+ private double _lastGap;
+ private bool _done;
+
+ public PennyAuctionEnv(Calibration calibration, int seed = 1)
+ {
+ _cal = calibration;
+ _rng = new Random(seed);
+ }
+
+ public Calibration Cal => _cal;
+
+ /// Comincia un'asta nuova con avversari estratti dalla taratura.
+ public Observation Reset()
+ {
+ _opponents.Clear();
+ _price = 0.01;
+ _totalBids = 1;
+ _myBids = 0;
+ _imLeading = false;
+ _lastGap = _cal.TimerSeconds;
+ _done = false;
+
+ var n = Math.Max(1, (int)Math.Round(Gaussian() * Math.Sqrt(_cal.MeanBidders) + _cal.MeanBidders));
+ for (var i = 0; i < n; i++)
+ {
+ var r = _rng.NextDouble();
+ var type = r < _cal.AutoShare ? OpponentType.AutoBid
+ : r < _cal.AutoShare + 0.25 ? OpponentType.Sniper
+ : r < _cal.AutoShare + 0.45 ? OpponentType.Aggressive
+ : OpponentType.Beginner;
+
+ // Il credito complessivo degli avversari deve produrre chiusure vicine a
+ // quelle osservate: il prezzo finale in centesimi è il numero di puntate.
+ var expectedTotal = Math.Max(5, _cal.MedianFinalPriceRatio * _cal.Value * 100);
+ var budget = type switch
+ {
+ OpponentType.AutoBid => (int)(expectedTotal * (0.3 + 0.6 * _rng.NextDouble())),
+ OpponentType.Aggressive => (int)(expectedTotal * (0.2 + 0.5 * _rng.NextDouble())),
+ OpponentType.Sniper => (int)(expectedTotal * (0.1 + 0.3 * _rng.NextDouble())),
+ _ => (int)(expectedTotal * (0.02 + 0.15 * _rng.NextDouble()))
+ };
+
+ _opponents.Add(new Opponent { Type = type, Budget = Math.Max(1, budget) });
+ }
+
+ return Observe();
+ }
+
+ private Observation Observe() => new(
+ Price: _price,
+ PriceRatio: _cal.Value > 0 ? _price / _cal.Value : 0,
+ ActiveOpponents: _opponents.Count(o => o.Active),
+ TotalBids: _totalBids,
+ LastGapSeconds: _lastGap,
+ AutoBidPresent: _opponents.Any(o => o.Active && o.Type == OpponentType.AutoBid),
+ MyBids: _myBids,
+ MyBidsEuro: _myBids * _cal.BidCost,
+ ImLeading: _imLeading);
+
+ ///
+ /// Un ciclo. = null per non puntare, altrimenti
+ /// l'anticipo (secondi residui) a cui la nostra puntata partirebbe.
+ ///
+ public StepResult Step(double? bidAtLeadSeconds)
+ {
+ if (_done) return new StepResult(Observe(), 0, true, _imLeading);
+
+ // Ogni avversario attivo decide se e quando punterebbe in questo ciclo.
+ var bestOpponentTime = double.NegativeInfinity; // secondi residui: più alto = prima
+ Opponent? bidder = null;
+
+ foreach (var o in _opponents)
+ {
+ if (!o.Active) continue;
+ if (o.Spent >= o.Budget) { o.Active = false; continue; }
+
+ var baseP = o.Type switch
+ {
+ OpponentType.AutoBid => 0.97,
+ OpponentType.Aggressive => 0.75,
+ OpponentType.Sniper => 0.55,
+ _ => 0.35
+ };
+
+ if (_rng.NextDouble() > o.Persistence(baseP))
+ {
+ // Non risponde a questo ciclo: i principianti mollano per sempre.
+ if (o.Type == OpponentType.Beginner && _rng.NextDouble() < 0.5) o.Active = false;
+ continue;
+ }
+
+ var when = o.Type switch
+ {
+ OpponentType.AutoBid => 2.0,
+ OpponentType.Sniper => 0.5 + 2.5 * _rng.NextDouble(),
+ OpponentType.Aggressive => Math.Min(_cal.TimerSeconds, 5 + 5 * _rng.NextDouble()),
+ _ => SampleDepth()
+ };
+
+ if (when > bestOpponentTime) { bestOpponentTime = when; bidder = o; }
+ }
+
+ var reward = 0.0;
+ var weBid = bidAtLeadSeconds is { } lead && !_imLeading;
+ var ourTime = weBid ? bidAtLeadSeconds!.Value : double.NegativeInfinity;
+
+ if (bidder != null && bestOpponentTime >= ourTime)
+ {
+ // Un avversario arriva prima di noi (o noi non puntiamo).
+ bidder.Spent++;
+ _price += Theory.PriceIncrement;
+ _totalBids++;
+ _imLeading = false;
+ _lastGap = _cal.TimerSeconds - bestOpponentTime;
+ return new StepResult(Observe(), 0, false, false);
+ }
+
+ if (weBid)
+ {
+ // La nostra puntata resetta il timer; si paga subito.
+ _myBids++;
+ _price += Theory.PriceIncrement;
+ _totalBids++;
+ _imLeading = true;
+ _lastGap = _cal.TimerSeconds - ourTime;
+ reward -= _cal.BidCost;
+ return new StepResult(Observe(), reward, false, false);
+ }
+
+ // Nessuno ha puntato: l'asta finisce.
+ _done = true;
+ if (_imLeading)
+ {
+ reward += _cal.Value - _price - _cal.TransactionFee - _cal.Shipping;
+ return new StepResult(Observe(), reward, true, true);
+ }
+ return new StepResult(Observe(), 0, true, false);
+ }
+
+ private double SampleDepth()
+ {
+ var h = _cal.CycleDepthHistogram;
+ var u = _rng.NextDouble() * h.Sum();
+ for (var i = 0; i < h.Length; i++)
+ {
+ u -= h[i];
+ if (u <= 0) return Math.Min(_cal.TimerSeconds, i + _rng.NextDouble());
+ }
+ return 0.5;
+ }
+
+ private double Gaussian()
+ {
+ var u1 = 1.0 - _rng.NextDouble();
+ var u2 = _rng.NextDouble();
+ return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
+ }
+
+ /// Il prezzo corrente, per chi vuole valutare la fine.
+ public double Price => _price;
+ public bool ImLeading => _imLeading;
+ public int MyBids => _myBids;
+ }
+}
diff --git a/Mimante/Ml/QLearningAgent.cs b/Mimante/Ml/QLearningAgent.cs
new file mode 100644
index 0000000..be20a63
--- /dev/null
+++ b/Mimante/Ml/QLearningAgent.cs
@@ -0,0 +1,214 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace AutoBidder.Ml
+{
+ ///
+ /// Apprendimento per rinforzo nel simulatore: un agente Q-learning con funzione Q su
+ /// rete neurale (), replay delle esperienze, esplorazione
+ /// ε-greedy decrescente, e una penalità conservativa sulle azioni non viste.
+ ///
+ /// Il problema di decisione (§3.5 del documento): stato = prezzo sul
+ /// valore, avversari attivi, ritmo delle puntate, presenza dell'autopuntata, quanto
+ /// abbiamo speso, se siamo in testa; azioni = NON PUNTARE, PUNTA; ricompensa = −c a
+ /// ogni puntata, +(V − prezzo − fee − spedizione) se si vince, 0 se si esce.
+ /// Sconto γ vicino a 1: la ricompensa che conta è quella finale.
+ ///
+ /// Perché nel simulatore e non dal vivo. Non si esplora con denaro
+ /// vero. La penalità conservativa (nello spirito di CQL, Kumar et al. 2020) abbassa
+ /// le Q delle azioni che il replay ha visto poco: un agente ottimista su ciò che non
+ /// conosce è esattamente ciò che si vuole evitare quando poi lo si guarda in
+ /// shadow.
+ ///
+ /// L'agente è una policy come le altre: si confronta con la regola del
+ /// valore atteso e con il bandit su aste simulate identiche (stessi semi), e la
+ /// scheda mostra ROI per puntata, tasso di vittoria, costo per vittoria.
+ ///
+ public sealed class QLearningAgent
+ {
+ public const int StateSize = 9;
+ public const int Actions = 2; // 0 = NON PUNTARE, 1 = PUNTA
+
+ private readonly NeuralNet _q;
+ private readonly Random _rng;
+ private readonly List<(double[] S, int A, double R, double[]? S2)> _replay = new();
+ private const int ReplayCap = 20_000;
+
+ public double Gamma { get; set; } = 0.98;
+ public double Epsilon { get; private set; } = 0.30;
+ public double EpsilonMin { get; set; } = 0.02;
+ public double EpsilonDecay { get; set; } = 0.999;
+
+ /// Quanto si penalizzano le Q delle azioni poco viste (0 = Q-learning puro).
+ public double ConservativeWeight { get; set; } = 0.02;
+
+ public long Episodes { get; private set; }
+ private readonly long[] _actionCounts = new long[Actions];
+
+ public QLearningAgent(int seed = 7)
+ {
+ _q = new NeuralNet(new[] { StateSize, 32, 16, Actions }, seed) { LearningRate = 0.0007, L2 = 1e-6 };
+ _rng = new Random(seed);
+ }
+
+ public static double[] Encode(in PennyAuctionEnv.Observation o) => new[]
+ {
+ Math.Min(1, o.PriceRatio * 5), // prezzo sul valore, scalato
+ Math.Min(1, o.ActiveOpponents / 8.0),
+ Math.Min(1, o.TotalBids / 500.0),
+ Math.Min(1, o.LastGapSeconds / 10.0),
+ o.AutoBidPresent ? 1 : 0,
+ Math.Min(1, o.MyBids / 50.0),
+ Math.Min(1, o.MyBidsEuro / 10.0),
+ o.ImLeading ? 1 : 0,
+ 1.0 // bias
+ };
+
+ /// L'azione secondo la policy appresa (senza esplorazione).
+ public int Act(in PennyAuctionEnv.Observation o)
+ {
+ var q = _q.Forward(Encode(o));
+ return q[1] > q[0] ? 1 : 0;
+ }
+
+ public double[] QValues(in PennyAuctionEnv.Observation o) => _q.Forward(Encode(o));
+
+ /// Un episodio di addestramento (un'asta simulata). Restituisce la ricompensa totale.
+ public double TrainEpisode(PennyAuctionEnv env, double leadSeconds)
+ {
+ var obs = env.Reset();
+ var total = 0.0;
+ var steps = 0;
+
+ while (steps++ < 2000)
+ {
+ var s = Encode(obs);
+ int a;
+ if (_rng.NextDouble() < Epsilon) a = _rng.Next(Actions);
+ else { var q = _q.Forward(s); a = q[1] > q[0] ? 1 : 0; }
+
+ var r = env.Step(a == 1 ? leadSeconds : null);
+ total += r.Reward;
+ _actionCounts[a]++;
+
+ var s2 = r.Done ? null : Encode(r.Next);
+ Remember(s, a, r.Reward, s2);
+ Learn(4);
+
+ if (r.Done) break;
+ obs = r.Next;
+ }
+
+ Episodes++;
+ Epsilon = Math.Max(EpsilonMin, Epsilon * EpsilonDecay);
+ return total;
+ }
+
+ private void Remember(double[] s, int a, double r, double[]? s2)
+ {
+ if (_replay.Count >= ReplayCap) _replay.RemoveAt(_rng.Next(_replay.Count / 2));
+ _replay.Add((s, a, r, s2));
+ }
+
+ private void Learn(int batch)
+ {
+ if (_replay.Count < 64) return;
+
+ var total = Math.Max(1, _actionCounts.Sum());
+ for (var k = 0; k < batch; k++)
+ {
+ var (s, a, r, s2) = _replay[_rng.Next(_replay.Count)];
+
+ var target = r;
+ if (s2 != null)
+ {
+ var qNext = _q.Forward(s2);
+ target += Gamma * Math.Max(qNext[0], qNext[1]);
+ }
+
+ // Penalità conservativa: un'azione vista di rado ha un bersaglio più basso,
+ // in proporzione a quanto è rara. Evita Q ottimiste su ciò che non si conosce.
+ var rarity = 1.0 - (double)_actionCounts[a] / total;
+ target -= ConservativeWeight * rarity;
+
+ _q.UpdateOutput(s, a, target);
+ }
+ }
+
+ // ── Confronto fra policy nel simulatore ──────────────────────────
+
+ public sealed class PolicyResult
+ {
+ public string Name { get; init; } = "";
+ public int Auctions { get; init; }
+ public int Wins { get; init; }
+ public int Bids { get; init; }
+ public double Spent { get; init; }
+ public double Gained { get; init; }
+ public double MaxDrawdown { get; init; }
+
+ public double Net => Gained - Spent;
+ public double RoiPerBid => Bids > 0 ? Net / Bids : 0;
+ public double WinRate => Auctions > 0 ? (double)Wins / Auctions : 0;
+ public double CostPerWin => Wins > 0 ? Spent / Wins : double.NaN;
+ }
+
+ /// Una policy da valutare: dallo stato all'anticipo (null = non puntare).
+ public delegate double? Policy(in PennyAuctionEnv.Observation o);
+
+ ///
+ /// Gioca aste simulate con una policy e ne misura i
+ /// risultati. Gli stessi semi per ogni policy: le differenze sono della policy,
+ /// non del caso.
+ ///
+ public static PolicyResult Evaluate(string name, PennyAuctionEnv.Calibration cal, Policy policy, int auctions, int seed = 1000)
+ {
+ int wins = 0, bids = 0;
+ double spent = 0, gained = 0, balance = 0, peak = 0, drawdown = 0;
+
+ for (var i = 0; i < auctions; i++)
+ {
+ var env = new PennyAuctionEnv(cal, seed + i);
+ var obs = env.Reset();
+ var steps = 0;
+
+ while (steps++ < 2000)
+ {
+ var lead = policy(obs);
+ var r = env.Step(lead);
+
+ if (r.Reward < 0) { spent += -r.Reward; bids++; }
+ else if (r.Reward > 0) gained += r.Reward;
+
+ balance += r.Reward;
+ if (balance > peak) peak = balance;
+ if (peak - balance > drawdown) drawdown = peak - balance;
+
+ if (r.Done) { if (r.Won) wins++; break; }
+ obs = r.Next;
+ }
+ }
+
+ return new PolicyResult { Name = name, Auctions = auctions, Wins = wins, Bids = bids, Spent = spent, Gained = gained, MaxDrawdown = drawdown };
+ }
+
+ public static string Format(IEnumerable results)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine(" policy aste vinte puntate spesi € incassati € netto € ROI/puntata costo/vittoria drawdown €");
+ sb.AppendLine(" ---------------------------------------------------------------------------------------------------------------------");
+ foreach (var r in results)
+ {
+ sb.AppendLine(string.Format(System.Globalization.CultureInfo.GetCultureInfo("it-IT"),
+ " {0,-22} {1,6} {2,7} {3,9} {4,9:F2} {5,13:F2} {6,10:F2} {7,13:F3} {8,16} {9,12:F2}",
+ r.Name, r.Auctions, r.Wins, r.Bids, r.Spent, r.Gained, r.Net, r.RoiPerBid,
+ double.IsNaN(r.CostPerWin) ? "—" : r.CostPerWin.ToString("F2"), r.MaxDrawdown));
+ }
+ return sb.ToString();
+ }
+
+ public string ToJson() => _q.ToJson();
+ }
+}
diff --git a/Mimante/Ml/ShadowReport.cs b/Mimante/Ml/ShadowReport.cs
new file mode 100644
index 0000000..a3dd781
--- /dev/null
+++ b/Mimante/Ml/ShadowReport.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using AutoBidder.Data;
+using AutoBidder.Utilities;
+
+namespace AutoBidder.Ml
+{
+ ///
+ /// Cosa avrebbero reso le decisioni registrate, policy per policy, con l'esito noto.
+ ///
+ /// Ogni decisione ha un esito alla chiusura dell'asta: se il prezzo non è più
+ /// salito dopo quell'istante, una puntata lì sarebbe stata l'ultima. Con questo si
+ /// valuta ogni policy sugli stessi istanti, dal vivo e in shadow: quante volte
+ /// avrebbe puntato, quante di quelle sarebbero state vincenti, quanto avrebbe speso e
+ /// incassato al valore reale del bene. È la valutazione off-policy più onesta
+ /// che i dati permettono — con l'avvertenza che una puntata nostra può cambiare il
+ /// comportamento altrui, e questo nessun dato registrato lo dice.
+ ///
+ /// Il confronto è sempre con il null-model: profitto atteso per puntata zero.
+ /// Un ROI per puntata positivo su poche decine di decisioni non prova nulla; su
+ /// migliaia, e con l'estremo inferiore dell'intervallo sopra lo zero, comincia a
+ /// dire qualcosa.
+ ///
+ public static class ShadowReport
+ {
+ public sealed class PolicyStats
+ {
+ public string Name { get; init; } = "";
+ public int Decisions { get; set; }
+ public int Bids { get; set; }
+ public int Wins { get; set; }
+ public double Spent { get; set; }
+ public double Gained { get; set; }
+ public double ForecastEv { get; set; }
+ public readonly List PerBid = new();
+
+ public double Net => Gained - Spent;
+ public double RoiPerBid => Bids > 0 ? Net / Bids : 0;
+ public double WinRate => Bids > 0 ? (double)Wins / Bids : 0;
+
+ /// Estremo inferiore (95%) del ROI per puntata: sotto zero, il null-model non è battuto.
+ public double RoiLower95
+ {
+ get
+ {
+ if (PerBid.Count < 2) return double.NaN;
+ var m = PerBid.Average();
+ var sd = Math.Sqrt(PerBid.Sum(x => (x - m) * (x - m)) / (PerBid.Count - 1));
+ return m - 1.96 * sd / Math.Sqrt(PerBid.Count);
+ }
+ }
+ }
+
+ public sealed class Report
+ {
+ public int Days { get; init; }
+ public int TotalDecisions { get; init; }
+ public int WithOutcome { get; init; }
+ public int LiveBids { get; init; }
+ public int LiveWins { get; init; }
+ public List Policies { get; } = new();
+ public string Text { get; set; } = "";
+ }
+
+ public static Report Compute(AuctionDatabase db, AppSettings settings, int days = 30)
+ {
+ db.Flush();
+ var since = AuctionDatabase.Iso(DateTime.Today.AddDays(-days));
+
+ var rows = db.Query(
+ "SELECT d.mode, d.action, d.outcome, d.ev_estimate, d.price, d.alt_action, d.executed, " +
+ " COALESCE(a.market_value, a.buy_now_price) AS v, a.shipping, a.bid_fee " +
+ "FROM bot_decisions d JOIN auctions a ON a.auction_id = d.auction_id " +
+ "WHERE d.ts >= ?1 AND d.outcome IS NOT NULL", since);
+
+ var total = (int)db.ScalarLong("SELECT COUNT(*) AS n FROM bot_decisions WHERE ts >= ?1", since);
+
+ var acting = new PolicyStats { Name = "modello + regime (in uso)" };
+ var alt = new PolicyStats { Name = "bandit Thompson (shadow)" };
+ var always = new PolicyStats { Name = "punta sempre" };
+ var never = new PolicyStats { Name = "non punta mai" };
+ int liveBids = 0, liveWins = 0;
+
+ foreach (var r in rows)
+ {
+ var outcome = r.Str("outcome");
+ var unanswered = outcome is "won" or "would_win" or "missed";
+ var v = r.DblOrNull("v") ?? 0;
+ var price = r.Dbl("price");
+ var fee = settings.TransactionFeeEuro;
+ var ship = r.DblOrNull("shipping") ?? 0;
+ var cost = r.DblOrNull("bid_fee") is > 0 ? r.Dbl("bid_fee") : settings.AverageBidCostEuro;
+
+ var gainIfWin = v - (price + Theory.PriceIncrement) - fee - ship;
+
+ void Apply(PolicyStats s, bool bid, double? forecast)
+ {
+ s.Decisions++;
+ if (!bid) return;
+ s.Bids++;
+ s.Spent += cost;
+ if (forecast is { } f) s.ForecastEv += f;
+ if (unanswered)
+ {
+ s.Wins++;
+ s.Gained += gainIfWin;
+ s.PerBid.Add(gainIfWin - cost);
+ }
+ else s.PerBid.Add(-cost);
+ }
+
+ var actingBid = r.Str("action") == "BID";
+ Apply(acting, actingBid, r.DblOrNull("ev_estimate"));
+ Apply(alt, r.Str("alt_action") == "BID", null);
+ Apply(always, true, null);
+ Apply(never, false, null);
+
+ if (r.Str("mode") == "live" && r.Bool("executed"))
+ {
+ liveBids++;
+ if (outcome == "won") liveWins++;
+ }
+ }
+
+ var report = new Report
+ {
+ Days = days, TotalDecisions = total, WithOutcome = rows.Count, LiveBids = liveBids, LiveWins = liveWins
+ };
+ report.Policies.AddRange(new[] { acting, alt, always, never });
+ report.Text = Format(report);
+ return report;
+ }
+
+ public static string Format(Report r)
+ {
+ var sb = new StringBuilder();
+ var c = System.Globalization.CultureInfo.GetCultureInfo("it-IT");
+
+ sb.AppendLine($"Decisioni negli ultimi {r.Days} giorni: {r.TotalDecisions:N0}, con esito noto {r.WithOutcome:N0}. " +
+ $"Puntate dal vivo: {r.LiveBids:N0}, di cui vincenti {r.LiveWins:N0}.");
+ sb.AppendLine();
+ sb.AppendLine(" policy decisioni puntate vincenti spesi € incassati € netto € ROI/puntata IC95% inf. EV previsto");
+ sb.AppendLine(" ------------------------------------------------------------------------------------------------------------------------");
+
+ foreach (var p in r.Policies)
+ {
+ sb.AppendLine(string.Format(c,
+ " {0,-30} {1,9} {2,8} {3,9} {4,9:F2} {5,12:F2} {6,9:F2} {7,12:F3} {8,10} {9,12}",
+ p.Name, p.Decisions, p.Bids, p.Wins, p.Spent, p.Gained, p.Net, p.RoiPerBid,
+ double.IsNaN(p.RoiLower95) ? "—" : p.RoiLower95.ToString("F3", c),
+ p.Name.Contains("in uso") ? p.ForecastEv.ToString("F2", c) : "—"));
+ }
+
+ sb.AppendLine();
+ sb.AppendLine(" Il null-model teorico (Augenblick 2016) prevede ROI per puntata = 0. Una policy batte il caso solo se");
+ sb.AppendLine(" l'estremo inferiore dell'intervallo di confidenza è sopra zero, e su abbastanza puntate da contare.");
+ sb.AppendLine(" «Vincenti» = il prezzo non è più salito dopo quell'istante: una puntata lì sarebbe stata l'ultima.");
+ sb.AppendLine(" Avvertenza: una puntata nostra può far rispondere chi stava per smettere; i dati non lo dicono.");
+
+ return sb.ToString();
+ }
+ }
+}
diff --git a/Mimante/Ml/SimulationLab.cs b/Mimante/Ml/SimulationLab.cs
new file mode 100644
index 0000000..2ed6515
--- /dev/null
+++ b/Mimante/Ml/SimulationLab.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using AutoBidder.Data;
+using AutoBidder.Utilities;
+
+namespace AutoBidder.Ml
+{
+ ///
+ /// Il laboratorio: mette le policy a confronto nel simulatore, su aste identiche
+ /// (stessi semi), e scrive un rapporto. Le policy: non puntare mai, puntare sempre
+ /// all'anticipo, la regola del valore atteso con P empirica, il bandit Thompson che
+ /// impara giocando, e l'agente Q addestrato nel simulatore.
+ ///
+ /// La taratura viene dal database quando ci sono abbastanza aste; altrimenti
+ /// dai predefiniti, e il rapporto lo dice. Il simulatore non è Bidoo: il confronto
+ /// fra policy è ciò che vale, non i numeri assoluti.
+ ///
+ public static class SimulationLab
+ {
+ public static string Run(AuctionDatabase? db, AppSettings settings, int auctions, Action? progress, CancellationToken ct)
+ {
+ var sb = new StringBuilder();
+
+ var cal = db != null ? PennyAuctionEnv.Calibration.FromDatabase(db) : new PennyAuctionEnv.Calibration();
+ cal.BidCost = settings.AverageBidCostEuro > 0 ? settings.AverageBidCostEuro : cal.BidCost;
+ cal.TransactionFee = settings.TransactionFeeEuro;
+
+ var leadSeconds = Math.Clamp(LatencyModel.RecommendedLeadMs(settings) / 1000.0, 0.3, 3.0);
+
+ sb.AppendLine("SIMULATORE — confronto fra policy su aste identiche");
+ sb.AppendLine();
+ sb.AppendLine(cal.Auctions > 0
+ ? $" Taratura da {cal.Auctions:N0} aste del database: {cal.MeanBidders:F1} puntatori per asta, autopuntate {cal.AutoShare:P0}, " +
+ $"chiusura mediana al {cal.MedianFinalPriceRatio:P1} del valore ({cal.Value:F2} €), puntata {cal.BidCost:F2} €."
+ : $" Nessuna asta nel database: taratura predefinita (valore {cal.Value:F2} €, {cal.MeanBidders:F0} puntatori, autopuntate {cal.AutoShare:P0}).");
+ sb.AppendLine($" Anticipo di puntata: {leadSeconds:F2} s (dal modello di latenza). Aste simulate per policy: {auctions:N0}.");
+ sb.AppendLine();
+
+ ct.ThrowIfCancellationRequested();
+ progress?.Invoke("policy di base…");
+
+ var results = new List
+ {
+ QLearningAgent.Evaluate("non punta mai", cal, (in PennyAuctionEnv.Observation _) => null, auctions),
+ QLearningAgent.Evaluate("punta sempre", cal, (in PennyAuctionEnv.Observation o) => o.ImLeading ? null : leadSeconds, auctions)
+ };
+
+ // P empirica per contesto, misurata proprio in questo simulatore: la quota di
+ // nostre puntate rimaste senza risposta, per numero di avversari attivi e
+ // fascia di prezzo. È il «modello» della regola del valore atteso.
+ ct.ThrowIfCancellationRequested();
+ progress?.Invoke("stima della P empirica…");
+ var empirical = EmpiricalP(cal, leadSeconds, Math.Max(200, auctions), seed: 5000);
+
+ double? EvRule(in PennyAuctionEnv.Observation o)
+ {
+ if (o.ImLeading) return null;
+ var p = empirical(o);
+ var s = new Theory.BidSituation(p, cal.Value, o.Price, cal.BidCost, cal.TransactionFee, cal.Shipping, cal.Value, o.MyBidsEuro, settings.BuyNowHedgeEnabled);
+ return Theory.BidExpectedValue(s) > 0 ? leadSeconds : null;
+ }
+ results.Add(QLearningAgent.Evaluate("valore atteso (P empirica)", cal, EvRule, auctions));
+
+ ct.ThrowIfCancellationRequested();
+ progress?.Invoke("bandit Thompson…");
+ results.Add(BanditPolicy(cal, leadSeconds, auctions, settings.BuyNowHedgeEnabled));
+
+ ct.ThrowIfCancellationRequested();
+ progress?.Invoke("addestramento dell'agente Q…");
+ var agent = new QLearningAgent(seed: 7);
+ var episodes = Math.Clamp(auctions * 2, 300, 1500);
+ for (var i = 0; i < episodes; i++)
+ {
+ if (i % 100 == 0) ct.ThrowIfCancellationRequested();
+ agent.TrainEpisode(new PennyAuctionEnv(cal, 10_000 + i), leadSeconds);
+ }
+ results.Add(QLearningAgent.Evaluate($"agente Q ({episodes} episodi)", cal, (in PennyAuctionEnv.Observation o) => agent.Act(o) == 1 ? leadSeconds : null, auctions));
+
+ sb.AppendLine(QLearningAgent.Format(results));
+ sb.AppendLine();
+ sb.AppendLine(" Il null-model prevede ROI per puntata = 0. Qui gli avversari sintetici hanno il costo affondato degli");
+ sb.AppendLine(" umani (Augenblick): una policy che guadagna lo fa sfruttando quello, non un vantaggio strutturale.");
+ sb.AppendLine(" I numeri assoluti sono del simulatore; il confronto fra policy è ciò che si porta a casa.");
+
+ return sb.ToString();
+ }
+
+ /// P(senza risposta) per contesto, misurata puntando sempre in aste simulate.
+ private static QLearningAgent.Policy EmpiricalPPolicy(Func p, double lead) =>
+ (in PennyAuctionEnv.Observation o) => p(o) > 0 ? lead : null;
+
+ private static Func EmpiricalP(PennyAuctionEnv.Calibration cal, double lead, int auctions, int seed)
+ {
+ var wins = new Dictionary<(int, int), int>();
+ var tries = new Dictionary<(int, int), int>();
+
+ for (var i = 0; i < auctions; i++)
+ {
+ var env = new PennyAuctionEnv(cal, seed + i);
+ var obs = env.Reset();
+ (int, int)? lastKey = null;
+ var steps = 0;
+
+ while (steps++ < 2000)
+ {
+ if (!obs.ImLeading)
+ {
+ var key = Key(obs);
+ tries[key] = tries.GetValueOrDefault(key) + 1;
+ lastKey = key;
+ var r = env.Step(lead);
+ if (r.Done) { if (r.Won && lastKey != null) wins[lastKey.Value] = wins.GetValueOrDefault(lastKey.Value) + 1; break; }
+ obs = r.Next;
+ }
+ else
+ {
+ var r = env.Step(null);
+ if (r.Done) { if (r.Won && lastKey != null) wins[lastKey.Value] = wins.GetValueOrDefault(lastKey.Value) + 1; break; }
+ obs = r.Next;
+ }
+ }
+ }
+
+ var overall = tries.Values.Sum() > 0 ? (double)wins.Values.Sum() / tries.Values.Sum() : 0.05;
+ return o =>
+ {
+ var k = Key(o);
+ var n = tries.GetValueOrDefault(k);
+ if (n < 20) return overall;
+ return (double)wins.GetValueOrDefault(k) / n;
+ };
+
+ static (int, int) Key(in PennyAuctionEnv.Observation o) =>
+ (Math.Min(5, o.ActiveOpponents), o.PriceRatio < 0.02 ? 0 : o.PriceRatio < 0.05 ? 1 : o.PriceRatio < 0.10 ? 2 : o.PriceRatio < 0.2 ? 3 : 4);
+ }
+
+ /// Il bandit impara giocando: a fine asta sa quale delle sue puntate è rimasta senza risposta.
+ private static QLearningAgent.PolicyResult BanditPolicy(PennyAuctionEnv.Calibration cal, double lead, int auctions, bool hedge)
+ {
+ var bandit = new ThompsonBandit { BaseRate = 0.05, PriorStrength = 2 };
+ var rng = new Random(77);
+ int wins = 0, bids = 0;
+ double spent = 0, gained = 0, balance = 0, peak = 0, drawdown = 0;
+
+ for (var i = 0; i < auctions; i++)
+ {
+ var env = new PennyAuctionEnv(cal, 1000 + i); // stessi semi di Evaluate
+ var obs = env.Reset();
+ var keys = new List();
+ var steps = 0;
+
+ while (steps++ < 2000)
+ {
+ double? action = null;
+ string? key = null;
+ if (!obs.ImLeading)
+ {
+ key = $"o{Math.Min(5, obs.ActiveOpponents)}|r{(obs.PriceRatio < 0.02 ? 0 : obs.PriceRatio < 0.05 ? 1 : obs.PriceRatio < 0.10 ? 2 : obs.PriceRatio < 0.2 ? 3 : 4)}|a{(obs.AutoBidPresent ? 1 : 0)}";
+ var s = new Theory.BidSituation(0, cal.Value, obs.Price, cal.BidCost, cal.TransactionFee, cal.Shipping, cal.Value, obs.MyBidsEuro, hedge);
+ var (bid, _) = bandit.Decide(key, s, rng);
+ if (bid) action = lead;
+ }
+
+ var r = env.Step(action);
+ if (action != null && key != null) { keys.Add(key); bids++; spent += cal.BidCost; }
+ if (r.Reward > 0) gained += r.Reward;
+ balance += r.Reward;
+ if (balance > peak) peak = balance;
+ if (peak - balance > drawdown) drawdown = peak - balance;
+
+ if (r.Done)
+ {
+ if (r.Won) wins++;
+ // Tutte le puntate tranne l'ultima sono state coperte; l'ultima
+ // è rimasta senza risposta solo se abbiamo vinto.
+ for (var k = 0; k < keys.Count; k++) bandit.Observe(keys[k], r.Won && k == keys.Count - 1);
+ break;
+ }
+ obs = r.Next;
+ }
+ }
+
+ return new QLearningAgent.PolicyResult
+ {
+ Name = "bandit Thompson (impara)", Auctions = auctions, Wins = wins, Bids = bids,
+ Spent = spent, Gained = gained, MaxDrawdown = drawdown
+ };
+ }
+ }
+}
diff --git a/Mimante/Ml/Theory.cs b/Mimante/Ml/Theory.cs
new file mode 100644
index 0000000..5e3c075
--- /dev/null
+++ b/Mimante/Ml/Theory.cs
@@ -0,0 +1,217 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace AutoBidder.Ml
+{
+ ///
+ /// Il modello matematico delle aste al centesimo, in formule esplicite e con la
+ /// fonte accanto. Tutto ciò che il motore calcola in euro passa di qui.
+ ///
+ /// Il fatto da cui partire. Augenblick (2016, Review of Economic
+ /// Studies 83(1)), su 166.000 aste: il margine medio del banco supera il 50% del
+ /// valore del bene; il 75% dei puntatori abbandona prima di 50 puntate. Platt, Price e
+ /// Tappen (2013) e Hinnosaar (2016): in ogni equilibrio che non finisce subito, il
+ /// profitto atteso di ogni puntata è zero. Un vantaggio, se esiste, viene solo
+ /// dall'irrazionalità altrui — costo affondato, gusto per il rischio — non dalla
+ /// struttura del gioco. È il null-model: ogni stima di profittabilità va
+ /// confrontata con zero, e se lo supera con sistematicità va guardata con sospetto.
+ ///
+ public static class Theory
+ {
+ /// Incremento di prezzo per puntata su Bidoo: un centesimo, verificato.
+ public const double PriceIncrement = 0.01;
+
+ /// Profitto atteso per puntata nell'equilibrio teorico (Augenblick 2016; Platt-Price-Tappen 2013).
+ public const double NullModelProfitPerBid = 0.0;
+
+ // ── 3.1 Valore atteso di una puntata ─────────────────────────────
+
+ ///
+ /// Ciò che serve a valutare una puntata in euro.
+ ///
+ /// Probabilità che, puntando adesso, nessuno rilanci prima della scadenza.
+ /// Valore reale V del bene (quello scelto dall'utente; non il listino gonfiato).
+ /// Prezzo corrente dell'asta.
+ /// Costo della puntata c.
+ /// Fee di transazione sulla vincita (~1 € su Bidoo).
+ /// Spedizione.
+ /// Prezzo «Compralo Ora» L, per la copertura. Null = nessuna copertura.
+ /// Quanto si è già speso in puntate su quest'asta.
+ /// La copertura «Compralo Ora» va considerata.
+ public readonly record struct BidSituation(
+ double PWin,
+ double Value,
+ double CurrentPrice,
+ double BidCost,
+ double TransactionFee,
+ double Shipping,
+ double? ListPrice,
+ double BidsSpentEuro,
+ bool HedgeEnabled);
+
+ ///
+ /// Valore atteso in euro di una puntata fatta adesso.
+ ///
+ /// Senza copertura:
+ /// EV = P · (V − (p + Δ) − fee − spedizione − c) − (1 − P) · c.
+ /// La puntata si paga in entrambi i rami — la piattaforma non la rimborsa — quindi
+ /// il costo c sta sia sotto P sia sotto (1 − P). È la forma corretta della formula
+ /// di §3.1 del documento di progetto, che lo metteva solo nel ramo di sconfitta.
+ ///
+ /// Con la 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; si esercita quando conviene,
+ /// cioè quando le puntate spese superano L − V. Da quel punto in poi il
+ /// ramo di sconfitta non costa più nulla in più per ogni puntata: il suo costo è
+ /// 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.
+ ///
+ public static double BidExpectedValue(in BidSituation s)
+ {
+ var p = Math.Clamp(s.PWin, 0, 1);
+ var gainIfWin = s.Value - (s.CurrentPrice + PriceIncrement) - s.TransactionFee - s.Shipping - s.BidCost;
+ var lossCost = LossBranchCost(s);
+ return p * gainIfWin - (1 - p) * lossCost;
+ }
+
+ /// Quanto costa questa puntata nel ramo di sconfitta, tenuto conto della copertura.
+ public static double LossBranchCost(in BidSituation s)
+ {
+ if (!s.HedgeEnabled || s.ListPrice is not > 0) return s.BidCost;
+
+ var boundedLoss = Math.Max(0, s.ListPrice.Value - s.Value);
+ return s.BidsSpentEuro + s.BidCost <= boundedLoss ? s.BidCost : 0;
+ }
+
+ ///
+ /// La regola operativa di §3.1: si punta se il guadagno atteso condizionato alla
+ /// vittoria supera il costo. Equivale a BidExpectedValue > 0.
+ ///
+ public static bool ShouldBid(in BidSituation s) => BidExpectedValue(s) > 0;
+
+ ///
+ /// La probabilità di vittoria minima perché la puntata abbia valore atteso nullo:
+ /// il punto di pareggio. Sopra si guadagna in media, sotto si perde.
+ ///
+ public static double BreakEvenPWin(in BidSituation s)
+ {
+ var gainIfWin = s.Value - (s.CurrentPrice + PriceIncrement) - s.TransactionFee - s.Shipping - s.BidCost;
+ var lossCost = LossBranchCost(s);
+ var denom = gainIfWin + lossCost;
+ if (denom <= 0) return 1; // non conviene neanche vincendo
+ return Math.Clamp(lossCost / denom, 0, 1);
+ }
+
+ // ── 3.2 Equilibrio a profitto nullo ──────────────────────────────
+
+ ///
+ /// Il rapporto critico V/c (Hinnosaar 2016): se i puntatori attivi sono più di
+ /// questo numero, il payoff atteso di almeno uno è zero, e in ogni equilibrio
+ /// simmetrico lo è per tutti.
+ ///
+ public static double CriticalBidders(double value, double bidCost) =>
+ bidCost > 0 ? value / bidCost : double.PositiveInfinity;
+
+ // ── 3.3 Sopravvivenza e hazard empirico ──────────────────────────
+
+ ///
+ /// Un ciclo del timer osservato: fin dove è sceso il cronometro prima che
+ /// arrivasse la puntata successiva, e se invece nessuno ha puntato e l'asta è
+ /// finita. Sono le righe della tabella resets, più l'ultimo ciclo di ogni
+ /// asta.
+ ///
+ public readonly record struct CycleObservation(double MinTimerSeconds, bool EndedAuction);
+
+ ///
+ /// Probabilità empirica che, arrivati con il cronometro a
+ /// senza che nessuno abbia puntato, nessuno punti fino alla fine — cioè la
+ /// P(vittoria) di una puntata piazzata a quell'anticipo, stimata come frequenza.
+ ///
+ /// È lo stimatore di sopravvivenza condizionata: fra i cicli che sono
+ /// scesi almeno fino a lead, la quota di quelli che hanno chiuso l'asta.
+ /// Un ciclo che ha chiuso l'asta è sceso fino a zero, quindi passa da ogni
+ /// soglia. Nulla quando non ci sono cicli abbastanza profondi da dirlo.
+ ///
+ public static double? WinProbabilityAtLead(IEnumerable cycles, double leadSeconds, int minCycles = 20)
+ {
+ var reached = 0;
+ var survived = 0;
+
+ foreach (var c in cycles)
+ {
+ if (c.EndedAuction) { reached++; survived++; continue; }
+ if (c.MinTimerSeconds <= leadSeconds) reached++;
+ }
+
+ return reached >= minCycles ? (double)survived / reached : null;
+ }
+
+ ///
+ /// Curva di sopravvivenza di Kaplan-Meier del ciclo: per ogni profondità, la
+ /// probabilità che il cronometro scenda fin lì senza puntate. Le osservazioni
+ /// «censurate» sono i cicli che hanno chiuso l'asta (si sa che hanno superato
+ /// tutto). Restituisce coppie (profondità in secondi dalla scadenza, S).
+ ///
+ public static List<(double TimerSeconds, double Survival)> KaplanMeier(IEnumerable cycles)
+ {
+ // Il «tempo» è quanto il ciclo è sceso: dal timer pieno verso zero. Un evento
+ // è una puntata che ferma la discesa a MinTimer; una censura è il ciclo che
+ // arriva a zero (fine asta).
+ var obs = cycles.ToList();
+ var result = new List<(double, double)>();
+ if (obs.Count == 0) return result;
+
+ var events = obs.Where(c => !c.EndedAuction).GroupBy(c => Math.Round(c.MinTimerSeconds, 1))
+ .Select(g => (Timer: g.Key, Count: g.Count()))
+ .OrderByDescending(e => e.Timer) // la discesa va dal timer alto al basso
+ .ToList();
+
+ var survival = 1.0;
+ var atRisk = obs.Count;
+
+ foreach (var (timer, count) in events)
+ {
+ if (atRisk <= 0) break;
+ survival *= 1.0 - (double)count / atRisk;
+ result.Add((timer, survival));
+ atRisk -= count;
+ }
+
+ return result;
+ }
+
+ // ── 3.4 Processo di arrivo delle puntate ─────────────────────────
+
+ ///
+ /// Con arrivi di Poisson a intensità λ (puntate al secondo), la probabilità che
+ /// nessuno punti nei prossimi τ secondi: exp(−λ τ). Alimenta P(vittoria)
+ /// quando si conosce solo il ritmo recente delle puntate.
+ ///
+ public static double NoBidProbability(double lambdaPerSecond, double tauSeconds) =>
+ lambdaPerSecond <= 0 || tauSeconds <= 0 ? 1.0 : Math.Exp(-lambdaPerSecond * tauSeconds);
+
+ ///
+ /// Intensità stimata dagli ultimi intervalli fra puntate (secondi): l'inverso
+ /// della media. Null senza intervalli.
+ ///
+ public static double? ArrivalRate(IEnumerable recentGapsSeconds)
+ {
+ var gaps = recentGapsSeconds.Where(g => g > 0).ToList();
+ if (gaps.Count == 0) return null;
+ return 1.0 / gaps.Average();
+ }
+
+ // ── Contabilità ──────────────────────────────────────────────────
+
+ /// Profitto realizzato da una vincita: valore reale meno tutto ciò che è costata.
+ public static double RealizedWinProfit(double value, double finalPrice, double bidsSpentEuro, double transactionFee, double shipping) =>
+ value - finalPrice - bidsSpentEuro - transactionFee - shipping;
+ }
+}
diff --git a/Mimante/Ml/ThompsonBandit.cs b/Mimante/Ml/ThompsonBandit.cs
new file mode 100644
index 0000000..8c553d8
--- /dev/null
+++ b/Mimante/Ml/ThompsonBandit.cs
@@ -0,0 +1,210 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+
+namespace AutoBidder.Ml
+{
+ ///
+ /// Un bandit contestuale con campionamento di Thompson (Agrawal e Goyal, 2013) per
+ /// la scelta PUNTA / NON PUNTARE.
+ ///
+ /// Come funziona. Il contesto — durata del ciclo, prezzo sul valore,
+ /// fascia oraria, puntatori distinti — viene ridotto a una chiave; per ogni chiave si
+ /// tiene una distribuzione Beta(α, β) sulla probabilità che una puntata resti senza
+ /// risposta. Al momento di decidere si estrae una probabilità dalla Beta
+ /// invece di usarne la media, e si applica la regola del valore atteso con quella.
+ /// Dove si è visto poco, la Beta è larga e l'estrazione varia: è l'esplorazione, che
+ /// si spegne da sola man mano che i dati arrivano.
+ ///
+ /// Da cosa impara. Da ogni decisione registrata, anche quelle in cui non
+ /// si è puntato: alla chiusura dell'asta si sa se il prezzo è rimasto fermo dopo
+ /// quell'istante (una puntata sarebbe stata l'ultima) oppure no. È informazione
+ /// completa, non solo sull'azione presa — cosa rara per un bandit, e che qui rende
+ /// l'apprendimento molto più rapido.
+ ///
+ /// È la seconda policy, accanto a quella del modello: gira in shadow su ogni
+ /// decisione e si confronta con l'altra sugli esiti. Vedi LEGGIMI.md.
+ ///
+ public sealed class ThompsonBandit
+ {
+ private sealed class Arm
+ {
+ public double Alpha;
+ public double Beta;
+ }
+
+ private readonly Dictionary _arms = new(StringComparer.Ordinal);
+ private readonly object _sync = new();
+
+ /// Peso del prior: equivale a tante osservazioni fittizie al tasso di base.
+ public double PriorStrength { get; set; } = 4;
+
+ /// Tasso di base P(senza risposta) usato come prior dove non si è visto nulla.
+ public double BaseRate { get; set; } = 0.02;
+
+ public int Arms { get { lock (_sync) return _arms.Count; } }
+
+ public long Observations { get; private set; }
+
+ /// La chiave del contesto: le fasce che contano di più, poche per avere dati in ognuna.
+ public static string KeyOf(in BidContext c) =>
+ $"g{BidFeatures.GapBucket(Math.Max(0, c.CycleSeconds))}|r{BidFeatures.RatioBucket(c.PriceRatio)}|h{BidFeatures.HourBand(c.Hour)}|b{BidFeatures.BiddersBucket(c.DistinctBiddersRecent)}|a{(c.AutoShareRecent >= 0.5 ? 1 : 0)}";
+
+ private Arm ArmFor(string key)
+ {
+ if (!_arms.TryGetValue(key, out var arm))
+ {
+ arm = new Arm { Alpha = BaseRate * PriorStrength, Beta = (1 - BaseRate) * PriorStrength };
+ _arms[key] = arm;
+ }
+ return arm;
+ }
+
+ /// Aggiorna il braccio del contesto con un esito osservato.
+ public void Observe(string key, bool unanswered)
+ {
+ lock (_sync)
+ {
+ var arm = ArmFor(key);
+ if (unanswered) arm.Alpha += 1; else arm.Beta += 1;
+ Observations++;
+ }
+ }
+
+ /// La media a posteriori per il contesto.
+ public double Mean(string key)
+ {
+ lock (_sync)
+ {
+ var arm = ArmFor(key);
+ return arm.Alpha / (arm.Alpha + arm.Beta);
+ }
+ }
+
+ /// Quante osservazioni vere ha visto questo contesto.
+ public double Evidence(string key)
+ {
+ lock (_sync)
+ {
+ var arm = ArmFor(key);
+ return arm.Alpha + arm.Beta - PriorStrength;
+ }
+ }
+
+ /// Un'estrazione dalla Beta del contesto: è la probabilità «di oggi».
+ public double Sample(string key, Random rng)
+ {
+ double a, b;
+ lock (_sync)
+ {
+ var arm = ArmFor(key);
+ a = arm.Alpha; b = arm.Beta;
+ }
+ var x = GammaSample(a, rng);
+ var y = GammaSample(b, rng);
+ return x + y > 0 ? x / (x + y) : 0;
+ }
+
+ ///
+ /// La decisione: si estrae p e si punta se il valore atteso con quel p è positivo.
+ /// Restituisce anche il p estratto, per il registro.
+ ///
+ public (bool Bid, double SampledP) Decide(string key, Theory.BidSituation situation, Random rng)
+ {
+ var p = Sample(key, rng);
+ var s = situation with { PWin = p };
+ return (Theory.BidExpectedValue(s) > 0, p);
+ }
+
+ /// Campione da Gamma(shape, 1) con il metodo di Marsaglia e Tsang.
+ private static double GammaSample(double shape, Random rng)
+ {
+ if (shape < 1)
+ {
+ // Gamma(a) = Gamma(a+1) · U^(1/a)
+ var u = rng.NextDouble();
+ return GammaSample(shape + 1, rng) * Math.Pow(Math.Max(u, 1e-300), 1.0 / shape);
+ }
+
+ var d = shape - 1.0 / 3.0;
+ var c = 1.0 / Math.Sqrt(9.0 * d);
+
+ while (true)
+ {
+ double x, v;
+ do
+ {
+ x = Gaussian(rng);
+ v = 1.0 + c * x;
+ } while (v <= 0);
+
+ v = v * v * v;
+ var u = rng.NextDouble();
+ if (u < 1 - 0.0331 * x * x * x * x) return d * v;
+ if (Math.Log(u) < 0.5 * x * x + d * (1 - v + Math.Log(v))) return d * v;
+ }
+ }
+
+ private static double Gaussian(Random rng)
+ {
+ var u1 = 1.0 - rng.NextDouble();
+ var u2 = rng.NextDouble();
+ return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
+ }
+
+ // ── Persistenza ──────────────────────────────────────────────────
+
+ private sealed class Snapshot
+ {
+ public double PriorStrength { get; set; }
+ public double BaseRate { get; set; }
+ public long Observations { get; set; }
+ public Dictionary Arms { get; set; } = new();
+ }
+
+ public string ToJson()
+ {
+ lock (_sync)
+ {
+ return JsonSerializer.Serialize(new Snapshot
+ {
+ PriorStrength = PriorStrength,
+ BaseRate = BaseRate,
+ Observations = Observations,
+ Arms = _arms.ToDictionary(kv => kv.Key, kv => new[] { kv.Value.Alpha, kv.Value.Beta })
+ });
+ }
+ }
+
+ public static ThompsonBandit FromJson(string json)
+ {
+ var bandit = new ThompsonBandit();
+ try
+ {
+ var s = JsonSerializer.Deserialize(json);
+ if (s == null) return bandit;
+ bandit.PriorStrength = s.PriorStrength > 0 ? s.PriorStrength : 4;
+ bandit.BaseRate = s.BaseRate > 0 ? s.BaseRate : 0.02;
+ bandit.Observations = s.Observations;
+ foreach (var kv in s.Arms)
+ bandit._arms[kv.Key] = new Arm { Alpha = kv.Value[0], Beta = kv.Value[1] };
+ }
+ catch { }
+ return bandit;
+ }
+
+ /// Le braccia con più evidenza, per la scheda.
+ public IReadOnlyList<(string Key, double Mean, double Evidence)> TopArms(int max = 30)
+ {
+ lock (_sync)
+ {
+ return _arms
+ .Select(kv => (kv.Key, kv.Value.Alpha / (kv.Value.Alpha + kv.Value.Beta), kv.Value.Alpha + kv.Value.Beta - PriorStrength))
+ .OrderByDescending(t => t.Item3)
+ .Take(max)
+ .ToList();
+ }
+ }
+ }
+}
diff --git a/Mimante/Models/AuctionInfo.cs b/Mimante/Models/AuctionInfo.cs
index 8053a1d..d56da70 100644
--- a/Mimante/Models/AuctionInfo.cs
+++ b/Mimante/Models/AuctionInfo.cs
@@ -567,6 +567,13 @@ namespace AutoBidder.Models
// Vedi Ml/LearningService. Non si salvano: valgono per l'istante in cui sono state
// calcolate, e al riavvio non ci sarebbe piu' lo stato che le ha prodotte.
+ ///
+ /// La decisione registrata nel database per il ciclo in corso: la puntata che
+ /// parte porta questo id, così esito e decisione restano legati.
+ ///
+ [JsonIgnore]
+ public long? PendingDecisionId { get; set; }
+
/// Probabilità che una puntata fatta adesso resti senza risposta.
[JsonIgnore]
public double? LearnedUnansweredProbability { get; set; }
diff --git a/Mimante/Models/WatchedProduct.cs b/Mimante/Models/WatchedProduct.cs
index 110a56d..c4e1745 100644
--- a/Mimante/Models/WatchedProduct.cs
+++ b/Mimante/Models/WatchedProduct.cs
@@ -45,6 +45,14 @@ namespace AutoBidder.Models
/// Tetto di puntate per singola asta. null = predefinito.
public int? MaxClicks { get; set; }
+ ///
+ /// Valore reale V del bene, scelto dall'utente. null = vale il «Compralo
+ /// Ora» di Bidoo. Il listino è gonfiato: un buono da 10 € spendibile solo su Bidoo
+ /// non vale 11 €. È il numero su cui si calcola il valore atteso di ogni puntata
+ /// (vedi Ml/Theory), quindi è il più importante di tutti.
+ ///
+ public double? MarketValue { get; set; }
+
///
/// Stato con cui entra un'asta nuova di questo prodotto: "Active",
/// "Watch" o "Stopped". null = usa il predefinito generale.
diff --git a/Mimante/Modifiche.txt b/Mimante/Modifiche.txt
index e69de29..1e163bf 100644
--- a/Mimante/Modifiche.txt
+++ b/Mimante/Modifiche.txt
@@ -0,0 +1,203 @@
+# PROMPT PER CLAUDE CODE — Sistema "Bidoo-RL": bot analitico e auto-apprendente per aste al centesimo (Bidoo)
+
+## TL;DR
+- Questo documento è un **prompt completo e autosufficiente** da incollare in Claude Code per far costruire un sistema `bidoo-rl` di raccolta dati + ML/RL + esecuzione della puntata a bassa latenza su Bidoo, con meccanica verificata, modello matematico esplicito (valore atteso, hazard rate, MDP), schema DB, pipeline ML/RL, architettura software e piano a milestone.
+- **Avvertenza sostanziale e non negoziabile**: le penny auction sono a valore atteso fortemente negativo per il bidder — Augenblick (2016), su 166.000 aste, calcola che "average profit margins actually exceed 50%" per il venditore; "75% of bidders leave the website forever before placing 50 bids, and 86% stop before placing 100 bids"; solo i bidder più esperti raggiungono profitti attesi "leggermente positivi". Inoltre l'automazione e i multi-account **violano i Termini e Condizioni di Bidoo** (un solo account, divieto di "software di monitoraggio dell'offerta"). Il sistema è progettato in modalità **SHADOW obbligatoria** con kill-switch e risk manager.
+- Il valore reale del progetto è **analitico** (stimare quando un'asta è profittevole e astenersi quasi sempre), non "vincere". Il prompt istruisce esplicitamente Claude Code a costruire un sistema scettico by-design, pronto a concludere "non conviene".
+
+## Key Findings (base di evidenza incorporata nel prompt)
+1. **Meccanica Bidoo verificata**: incremento €0,01/puntata; timer breve (~10–12 s, una pagina live mostrava "Timer: 12 sec") che si resetta ad ogni puntata; vince l'ultimo; puntate non rimborsabili; opzione "Compralo Ora" per recuperare le puntate spese; auto-puntata ufficiale che punta negli ultimi secondi fino a un massimo impostato; aste a prenotazione; slot/limiti di vincita ("7 ogni giorno" su pagina live); orari ~10:00/12:00–24:00; fee transazione €1,00; unit-cost puntata tra ~€0,07 e ~€0,50 secondo il pacchetto (fonti pubbliche incoerenti).
+2. **Teoria**: equilibrio a profitto atteso nullo per puntata (Augenblick 2016 *Review of Economic Studies* 83(1):58–86; Platt-Price-Tappen 2013 *Management Science* 59:2117–2134; Hinnosaar 2016); deviazioni spiegate da sunk-cost, risk-loving, asimmetrie informative (Byers-Mitzenmacher-Zervas 2010).
+3. **ML/RL trasferibile**: RTB come MDP (Cai et al., WSDM 2017), offline RL (CQL Kumar et al. 2020 / IQL Kostrikov et al. 2021), contextual bandit/Thompson sampling (Agrawal-Goyal 2013), gradient boosting + survival analysis (Cox/lifelines), sniping (Roth-Ockenfels 2002 AER).
+4. **Dati**: dataset pubblico Swoopo (Byers/Mitzenmacher/Zervas, people.bu.edu/zg/swoopo.html): 121.419 aste "Outcomes" (8 set 2008–12 dic 2009) + ~2,5M bid "Traces"; repo Bidoo-Bot (Selenium). Meccanismo real-time di Bidoo (AJAX/WebSocket/SSE) **NON confermato**.
+5. **Legale**: ToS Bidoo vietano bot e multi-account; AGCM (comunicato 25 settembre 2015, PS8243-PS9030) ha sanzionato operatori di aste al centesimo — €700.000 a Flamingo Intervest Ltd per pratiche commerciali scorrette e €300.000 a Marcandi Ltd (madbid.com) per "informazioni ingannevoli e omissive" e per non aver chiarito "l'aleatorietà" delle aste — ma **nessuna azione specifica contro Bidoo** risulta; in Italia le penny auction sono zona grigia (non gioco d'azzardo formale, trattate come pratica commerciale ex Codice del Consumo).
+
+---
+
+## IL DOCUMENTO-PROMPT (copiare da qui in Claude Code)
+
+Sei un ingegnere ML/RL senior e sviluppatore Python esperto. Costruisci un sistema modulare chiamato `bidoo-rl` per l'analisi, la simulazione e (opzionalmente, in modalità controllata) l'automazione della puntata su aste al centesimo, con Bidoo (it.bidoo.com) come target. Il proprietario del progetto (Alberto) è esperto C++/Python e costruisce sistemi analitici AI-driven. Scrivi codice, identificatori, nomi di file e commenti in inglese; la documentazione utente in italiano. Rispetta rigorosamente le sezioni seguenti.
+
+### 0. CONTESTO, OBIETTIVO E FILOSOFIA
+Obiettivo primario: costruire un sistema che **impari dai dati quando conviene puntare e, soprattutto, quando NON puntare**, stimando il valore atteso di ogni puntata e il rischio, con esecuzione a bassa latenza. L'obiettivo NON è "vincere sempre" ma massimizzare il ROI atteso di lungo periodo con controllo del rischio; l'esito atteso onesto di default è "astenersi dalla stragrande maggioranza delle aste".
+
+Realtà economica da codificare come prior nel sistema (fonti accademiche): nelle penny auction il margine medio del banco supera il 50% del valore del bene — Augenblick (2016), su un dataset di ~100 milioni di puntate su 166.000 aste, "calculate that average profit margins actually exceed 50%"; la maggioranza dei bidder perde denaro; "75% of bidders leave the website forever before placing 50 bids, and 86% stop before placing 100 bids"; solo i bidder più esperti/sofisticati raggiungono profitti attesi leggermente positivi. Il sistema deve essere scettico by-design e ancorare ogni stima di profittabilità al null-model teorico (profitto atteso per puntata = 0).
+
+### 1. VINCOLI, AVVERTENZE LEGALI/ToS E GESTIONE DEL RISCHIO (PRIORITÀ MASSIMA)
+Implementa questi vincoli PRIMA di ogni logica di puntata:
+- **Modalità SHADOW obbligatoria di default**: il sistema decide e logga le decisioni ("avrei puntato / non puntato, con questa motivazione e questo EV") ma NON invia puntate reali. L'esecuzione live è dietro un flag esplicito `--enable-live-bidding` + variabile d'ambiente `BIDOO_LIVE_CONFIRM=I_UNDERSTAND_THE_RISKS` + doppia conferma interattiva.
+- **Kill-switch** globale: file sentinella `KILL_SWITCH` e handler SIGTERM che fermano immediatamente ogni azione live.
+- **Avvertenza ToS**: i Termini e Condizioni di Bidoo (it.bidoo.com/terms.php) vietano esplicitamente "l'uso di software di monitoraggio dell'offerta", la "creazione di account multipli dello stesso utente" e stabiliscono "È permesso avere un solo account per persona; se si tenta di aprire più di un account, quelli in eccesso e anche il principale, potranno essere bloccati o chiusi". La violazione comporta blocco/chiusura account e perdita delle puntate. Il README deve riportarlo in evidenza. NON implementare creazione automatica di account multipli né farming multi-account (a differenza del repo pubblico HunterStile/Bidoo-Bot che include `Crea_Account.py` e `Boosting.py`: NON replicarli).
+- **Inquadramento legale**: in Italia le aste al centesimo sono in una zona grigia — non classificate formalmente come gioco d'azzardo (Art. 721 c.p. richiede vincita/perdita "interamente o quasi interamente aleatoria"; nelle penny auction è presente un elemento di abilità), trattate dall'AGCM come pratica commerciale (Codice del Consumo). L'AGCM (comunicato del 25 settembre 2015, provvedimenti PS8243 e PS9030) ha irrogato "una sanzione complessiva di un milione di euro" a due operatori: €700.000 a Flamingo Intervest Ltd per pratiche commerciali scorrette e €300.000 a Marcandi Ltd (operatore di madbid.com) per informazioni ingannevoli/omissive e per non aver chiarito "l'aleatorietà" delle aste. Non risulta alcuna azione specifica contro Bidoo. Le sanzioni AGCM per pratiche scorrette vanno da €5.000 a €10.000.000. Riportare questi fatti nel README.
+- **Risk manager** con parametri hard (config `risk.yaml`): bankroll totale, budget massimo puntate per asta, budget giornaliero, stop-loss giornaliero, limite di puntate per singolo articolo/categoria, numero massimo di aste concorrenti, drawdown massimo oltre il quale il sistema entra in stato `HALT`. Ogni decisione live deve passare da `RiskManager.authorize(decision) -> bool`.
+- **Hedge "Compralo Ora"**: modellare esplicitamente l'opzione buy-now come copertura (limita la perdita alla differenza tra prezzo di listino Bidoo e prezzo di mercato reale). Il risk manager deve valutare il buy-now come exit.
+
+### 2. MECCANICA DI BIDOO DA MODELLARE (dati verificati; incertezze segnalate)
+Modella una classe `AuctionMechanics` con questi parametri (default verificati, tutti overridabili da config perché possono cambiare):
+- **Incremento prezzo**: `price_increment = 0.01 €` per puntata (verificato, costante su tutte le fonti).
+- **Timer/countdown**: si resetta ad ogni puntata; durata breve, tipicamente 10–15 secondi (una pagina asta live mostrava "Timer: 12 sec"; altre fonti citano fino a 60 s di finestra massima). Alla scadenza parte una fase di "controllo" in cui Bidoo verifica puntate valide arrivate all'ultimo secondo prima di decretare il vincitore. **INCERTEZZA**: valore esatto del timer e logica di reset/estensione non documentati ufficialmente; renderli parametrici e stimarli dai dati.
+- **Costo puntata (bid fee)**: acquistata solo in pacchetti; unit-cost variabile ~€0,07–€0,50. Figure ricorrenti nelle guide: pacchetto piccolo ~150 puntate ≈ €0,20/puntata; pacchetto grande ~7000 puntate ≈ €0,07–0,08/puntata; alcune fonti indicano €0,10–0,12 medi, altre un listino nominale €0,50. **INCERTEZZA ALTA**: i prezzi esatti dei pacchetti 2025-26 non sono confermabili dalla pagina ufficiale (bloccata al fetch automatico); parametrizzare in `bid_packs.yaml` e ricalibrare all'uso reale.
+- **Fee di transazione**: ~€1,00 sulla vincita (visto su pagina live it.bidoo.com/auction.php?a=).
+- **Vincita**: vince l'ultimo che ha puntato quando il timer arriva a 0 senza rilanci; paga prezzo finale + spedizione + fee, tenendo conto delle puntate spese.
+- **Compralo Ora / buy-now**: chi non vince può comprare l'oggetto a prezzo di listino recuperando le puntate spese come credito.
+- **Auto-puntata ufficiale (autobid)**: Bidoo offre una funzione nativa che punta automaticamente negli ultimi secondi fino a un massimo di puntate impostato dall'utente; attivabile su più aste. Modellare gli autobid concorrenti come avversari.
+- **Tipi di asta**: aste normali, aste a prenotazione (solo utenti prenotati, con puntate prenotate scalate), aste su pacchetti di puntate, categorie prodotti (elettronica, buoni, elettrodomestici...). Aggiungere flag per aste principianti/con limitazioni per analogia con Swoopo (`flg_beginnerauction`; `flg_click_only` = "NailBiter"; `flg_fixedprice`).
+- **Orari**: aste attive con finestra tipica 10:00–24:00 (alcune fonti 12:00–24:00), pausa notturna; nuove aste avviate di continuo.
+- **Slot/limiti di vincita**: limiti settimanali/mensili e giornalieri al numero di aste vincibili da un utente (es. "7 ogni giorno" visto su pagina live). Codificare come vincolo che riduce il valore marginale di ulteriori vittorie.
+- **Feed real-time — INCERTEZZA CRITICA**: NON è documentato pubblicamente se Bidoo aggiorni prezzo/timer/ultimo-offerente via AJAX polling, WebSocket, long-polling o SSE, né esistono endpoint JSON noti/confermati. L'unico bot pubblico noto (github.com/HunterStile/Bidoo-Bot) usa **Selenium** e, come da README, "parteciperà all'asta cliccando l'ultimo millisecondo prima che scada" — il che suggerisce che non sia banale colpire un endpoint di stato diretto. Pattern URL asta confermato: `https://it.bidoo.com/auction.php?a=`. **Istruzione a Claude Code**: implementare un `feed adapter` astratto con due backend: (a) `BrowserFeed` basato su Playwright che legge il DOM (default, robusto), e (b) `HttpFeed` da attivare SOLO se, durante una fase esplicita di reverse-engineering manuale del traffico di rete (DevTools/Network), si identifica un endpoint di stato reale. NON assumere endpoint inventati; documentare come `UNKNOWN` finché non verificato.
+
+Includi parametri per confronto/portabilità (QuiBids, DealDash, Swoopo storico, MadBid) per riuso dei modelli.
+
+### 3. MODELLO MATEMATICO (formule esplicite)
+Implementa un modulo `models/theory.py` con le seguenti formule, ognuna documentata con la fonte.
+
+**3.1 Valore atteso di una puntata (decisione istantanea)**
+Sia `V` il valore reale di mercato del bene (prezzo Amazon/Idealo, NON il listino gonfiato Bidoo), `p_price` il prezzo corrente dell'asta, `c` il costo della puntata (bid fee), `Δ = 0,01€` l'incremento, `P_win` la probabilità stimata di vincere se punto ora (cioè che nessuno rilanci prima dello scadere del timer):
+
+```
+E[bid] = P_win · (V − (p_price + Δ) − fee_transaction − shipping)
+ − (1 − P_win) · c
+```
+Regola operativa: **puntare solo se `P_win · (V − p_price_next − costs) > c`**, cioè se il guadagno atteso condizionato alla vittoria supera il costo della puntata. Con l'hedge buy-now, la perdita in caso di sconfitta è limitata a `max(0, prezzo_listino_bidoo − V)`.
+
+**3.2 Equilibrio a profitto atteso nullo (benchmark teorico)**
+Augenblick (2016) e Platt-Price-Tappen (2013): in ogni equilibrio che non finisce subito, i giocatori puntano in modo probabilistico tale che **il profitto atteso di ogni puntata è zero**. Nel modello risk-neutral (Platt et al.) il ricavo atteso del venditore eguaglia il valore del bene ("produce the same expected revenue as standard auctions"); con preferenze risk-loving il ricavo eccede V (le penny auction sono "a mild form of gambling"). Il rapporto critico è `V/c`: se il numero di bidder attivi `n > V/c`, il payoff atteso di almeno un bidder è 0, e in ogni equilibrio simmetrico il payoff di tutti i bidder è 0 (Hinnosaar 2016). **Usa questo come null-model**: qualunque profittabilità stimata dai modelli ML che ecceda sistematicamente lo zero teorico va trattata con sospetto e attribuita ad asimmetrie/irrazionalità altrui (sunk-cost, risk-loving), non a un edge strutturale.
+
+**3.3 Hazard rate empirico e survival analysis**
+Modella la terminazione dell'asta come processo di sopravvivenza. Definisci `h(t | X)` = probabilità che l'asta termini al round `t` dato lo stato `X`. Stima con:
+- **Kaplan-Meier** per la curva di sopravvivenza empirica per categoria/valore (usa `lifelines`).
+- **Cox proportional hazards** e/o gradient boosting survival (XGBoost/LightGBM con obiettivo Cox, o modelli AFT) per `h(t|X)` con covariate (n. bidder attivi, sunk cost aggregato, prezzo/valore, orario). Deviazione chiave da modellare (Augenblick): l'hazard rate empirico scende sotto la predizione standard man mano che l'asta prosegue (effetto sunk-cost), riducendo il rendimento per puntata da alto a ~"18–24 cent per ogni puntata da 75 cent" nelle fasi avanzate del dataset Swoopo.
+
+**3.4 Processo puntuale per l'arrivo delle puntate**
+Modella gli arrivi come processo di Poisson non-omogeneo (o Hawkes per il clustering "una puntata chiama l'altra"): intensità `λ(t | X)`. La probabilità che nessuno punti entro il timer residuo `τ` è `P_no_bid = exp(−∫_{t}^{t+τ} λ(s) ds)`, che alimenta direttamente `P_win`.
+
+**3.5 Formulazione come Markov Decision Process (per RL)**
+- **Stato** `s`: [prezzo corrente, tempo residuo nel timer, n. bidder distinti attivi, n. puntate totali, intervalli tra puntate recenti, profilo aggressività avversari, presenza autobid, valore V e prezzo listino, categoria/articolo, orario/giorno, budget residuo per asta, sunk cost proprio, latenza di rete misurata, jitter].
+- **Azioni** `a`: {NO-OP (non puntare), BID (punta ora), BID@T−δ (schedula puntata a timer−δ), BUY-NOW (esci con hedge), LEAVE (abbandona)}.
+- **Reward** `r`: vittoria `+(V − prezzo_finale − costi_totali_puntate − fee − shipping)`; ogni puntata `−c`; buy-now `−(prezzo_listino − V)` limitato; abbandono `0`. Reward basato su V reale, non sul listino Bidoo.
+- **Sconto** `γ` vicino a 1 entro l'asta (orizzonte breve), con reward terminale dominante.
+- **Obiettivo**: massimizzare `E[Σ γ^t r_t]` sotto i vincoli del risk manager.
+Riferimenti: MDP di bidding in RTB (Cai et al., WSDM 2017, "formulate the bid decision process as a reinforcement learning problem"); optimal stopping / war of attrition; bid shading dalla teoria delle aste.
+
+### 4. PIPELINE ML/RL
+Struttura `ml/` con questi componenti:
+
+**4.1 Modelli supervisionati (feature tabellari + sequenza)**
+- `final_price_predictor`: regressione del prezzo finale (LightGBM/XGBoost/CatBoost).
+- `end_within_n_seconds`: classificatore **calibrato** P(asta termina entro N s | stato) — con calibrazione (Platt scaling/isotonic) perché `P_win` deve essere una probabilità onesta.
+- `active_bidders_estimator`: stima del numero di bidder attivi residui (variabile latente chiave: le asimmetrie su `n` guidano il profitto del banco, Byers et al. 2010).
+- `opponent_classifier`: profilazione bidder da bid history in classi {aggressivo, sniper, autobid, principiante} — feature: timing delle puntate (chi punta a inizio/fine countdown è più sofisticato, Wang & Xu 2012), frequenza, streak.
+- Modelli di sequenza (LSTM/GRU/TCN/Transformer) sulla sequenza temporale delle puntate per catturare dinamica e clustering.
+
+**4.2 Reinforcement Learning**
+- **Offline RL prima di tutto**: addestra su log storici con **CQL** (Conservative Q-Learning, Kumar et al. 2020) o **IQL** (Implicit Q-Learning, Kostrikov et al. 2021) per evitare overestimation di azioni out-of-distribution. La loss CQL penalizza le Q-value delle azioni OOD (`log Σ_a exp Q(s,a) − E_{a∼D}[Q(s,a)]`). Cruciale perché non possiamo esplorare liberamente col denaro reale.
+- **Contextual bandit / Thompson sampling** (Agrawal-Goyal 2013) come policy leggera e interpretabile per la decisione BID/NO-OP a livello di singola opportunità (context = feature dello stato; ricompensa = EV realizzato). Buon trade-off exploration/exploitation con regret basso.
+- **Simulatore gym-like** (`gymnasium.Env`) `PennyAuctionEnv`: avversari sintetici calibrati sui dati reali (mix di profili aggressivo/sniper/autobid/principiante con hazard e sunk-cost calibrati su Swoopo e sui dati Bidoo raccolti). Serve per (a) addestrare/valutare policy online in sicurezza, (b) stress test.
+- **On-policy** (PPO) o **off-policy** (SAC per azioni continue tipo δ di timing; DQN per azioni discrete) SOLO dentro il simulatore, mai live all'inizio.
+- Librerie: `stable-baselines3` + `gymnasium`; per offline RL `d3rlpy` (CQL/IQL/BCQ).
+
+**4.3 Feature engineering** (`ml/features.py`)
+Articolo/categoria; valore retail Bidoo vs prezzo di mercato reale (scraping Amazon/Idealo o input manuale); orario e giorno della settimana; prezzo corrente e rapporto prezzo/V; n. puntate totali; n. bidder distinti; intervalli tra puntate (media/varianza/ultimo); aggressività dei bidder presenti; presenza/intensità autobid; storico dello stesso articolo e di aste simili; latenza di rete e jitter; tempo residuo nel timer; posizione temporale della puntata nel countdown; sunk cost proprio e aggregato.
+
+**4.4 Valutazione e retraining continuo**
+- Metriche: **ROI per puntata**, ROI per asta, win rate, costo medio per vittoria, sconto reale vs prezzo di mercato, max drawdown, Sharpe delle sessioni, calibrazione (Brier score, reliability diagram) di `P_win`.
+- Valutazione offline: **off-policy evaluation** (importance sampling, doubly robust) prima di ogni promozione di policy.
+- A/B tra policy in shadow; promozione live solo se supera le soglie §8.
+- Retraining schedulato (es. settimanale) + drift detection sulle feature.
+- Tracking con `mlflow`; tuning con `optuna`.
+
+### 5. RACCOLTA DATI E SCHEMA DB
+**5.1 Ingestion** (`ingestion/`)
+- `Playwright` (async) come default per rendering JS e lettura DOM; `httpx` per eventuali endpoint HTTP solo se verificati manualmente. Gestione sessione/login via cookie storage; rispettare rate limiting (backoff esponenziale + jitter, User-Agent realistico); gestire eventuale Cloudflare/anti-bot con attese e sessione browser persistente.
+- **Campionamento sub-secondo** durante le fasi calde (timer < 20 s): polling adattivo (più frequente vicino allo zero). Registrare timestamp locale ad alta risoluzione (`time.perf_counter_ns`).
+- **Sincronizzazione temporale**: sincronizzare l'orologio via NTP; stimare l'offset con l'orologio del server Bidoo (response header `Date` o timer visualizzato) e la latenza RTT; salvare offset e RTT con ogni osservazione.
+- **Misura latenza end-to-end** della puntata: tempo dal comando di puntata alla conferma di registrazione; salvare per la compensazione (§6).
+- Comportamento rispettoso: throttling, niente scraping distruttivo, solo dati necessari; documentare che il monitoraggio automatico viola i ToS.
+
+**5.2 Schema DB** (SQLite in dev, PostgreSQL/Supabase in prod; SQLAlchemy + Alembic)
+- `items`: item_id, title, category, retail_price_bidoo, market_price_est, market_price_source, updated_at.
+- `auctions`: auction_id, item_id, type (normal/reservation/bidpack/beginner), start_time, end_time, final_price, winner_user_id, bid_increment, bid_fee_ref, shipping, transaction_fee, status.
+- `bids`: bid_id, auction_id, user_id, price_after, timer_before, timer_after, server_ts, local_ts, clock_offset_ms, rtt_ms, is_autobid_flag_est.
+- `bidders`: user_id, first_seen, n_auctions, n_bids, win_count, aggressiveness_score, profile_class.
+- `sessions`: session_id, start, end, ip/proxy, cookies_ref, notes.
+- `bot_decisions`: decision_id, auction_id, ts, state_snapshot (JSON), action, ev_estimate, p_win, model_version, shadow_or_live, executed, outcome, **reason_detail** (motivazione testuale dettagliata).
+- `network_metrics`: ts, rtt_ms, jitter_ms, clock_offset_ms.
+
+**5.3 Volumi e quanti dati servono**
+Per addestrare modelli supervisionati affidabili servono migliaia di aste chiuse con storico completo. Riferimenti di scala: il dataset Swoopo pubblico contiene 121.419 aste "Outcomes" (8 set 2008–12 dic 2009) e ~2,5 milioni di puntate "Traces"; Augenblick usa "an auction-level dataset of nearly 100 million bids on 166,000 auctions and a bid-level dataset of 13 million bids on 18,000 auctions from more than 129,000 users". Raccomanda di raccogliere ≥ 4–8 settimane di dati continui e ≥ qualche migliaio di aste chiuse per categoria prima di fidarsi delle stime; fino ad allora restare in shadow.
+
+**5.4 Fonti pubbliche da integrare**
+- **Swoopo dataset** (Byers/Mitzenmacher/Zervas, people.bu.edu/zg/swoopo.html): colonne outcomes (auction_id, product_id, item, retail, price, finalprice, bidincrement, bidfee, winner, placedbids, freebids, endtime, `flg_click_only`=NailBiter, `flg_beginnerauction`, `flg_fixedprice`) + traces con tempo e utente per ogni bid. Usare per pre-training/calibrazione del simulatore.
+- Repo pubblici (solo come riferimento tecnico, NON copiare la parte multi-account): HunterStile/Bidoo-Bot (Selenium, Asta.py), qix/penny-bot, plutokid/bid-o-matic.
+
+### 6. STRATEGIA DI ESECUZIONE E COMPENSAZIONE DELLA LATENZA
+- Puntare il più tardi possibile (sniping): la letteratura eBay (Roth-Ockenfels 2002 AER) mostra che il late bidding è razionale come best response a bidder incrementali; nelle penny auction puntare tardi minimizza le puntate sprecate.
+- **Compensazione latenza**: `t_send = t_expiry − (latency_est + safety_margin)`, dove `latency_est` = RTT/2 + tempo di elaborazione client + jitter buffer, stimato online dai `network_metrics`. `safety_margin` calibrato per una probabilità target di arrivo-in-tempo (es. 99° percentile della latenza osservata).
+- **Evitare puntate sprecate**: se tra la decisione e l'invio arriva la puntata di un altro (il pulsante "Punta" ricompare), rivalutare: spesso conviene NON re-puntare subito. Logica di cancellazione/riprogrammazione.
+- Gestione errori: timeout, disconnessioni, rifiuto della puntata, saldo insufficiente → stati espliciti e retry idempotenti; ogni tentativo loggato.
+
+### 7. ARCHITETTURA SOFTWARE, REPO E STACK
+```
+bidoo-rl/
+ README.md # avvertenze ToS/legali + realtà economica (EV negativo) in apertura
+ pyproject.toml
+ config/ (risk.yaml, bid_packs.yaml, mechanics.yaml, feeds.yaml)
+ ingestion/ (playwright_feed.py, http_feed.py, feed_base.py, clock_sync.py, rate_limit.py)
+ storage/ (models.py [SQLAlchemy], migrations/ [Alembic], repo.py)
+ features/ (build_features.py, opponent_profiling.py)
+ models/ (theory.py, supervised.py, survival.py, sequence.py)
+ ml/ (offline_rl.py [d3rlpy CQL/IQL], bandit.py [Thompson], env.py [PennyAuctionEnv], train.py, evaluate.py, ope.py)
+ policy/ (decision_engine.py, risk_manager.py, executor.py, latency.py)
+ backtest/ (simulator.py, replay.py)
+ monitoring/ (dashboard.py [Streamlit], metrics.py, alerts.py)
+ cli.py # comandi: collect, train, backtest, shadow, live (gated)
+ tests/ # pytest, coverage su risk_manager, executor, theory
+```
+Stack: Python 3.11+, `playwright`, `asyncio`, `httpx`, `pandas`/`polars`, `numpy`, `scikit-learn`, `lightgbm`/`xgboost`/`catboost`, `lifelines` (Kaplan-Meier/Cox), `torch`, `stable-baselines3`, `gymnasium`, `d3rlpy` (CQL/IQL), `optuna`, `mlflow`, `SQLAlchemy`+`alembic`, `pydantic` (config), `streamlit` (dashboard), `pytest`.
+
+### 8. METRICHE DI SUCCESSO E CRITERI DI STOP
+- **Gate shadow → live** (tutti richiesti su ≥ 4 settimane shadow): ROI per puntata atteso > 0 con estremo inferiore dell'intervallo di confidenza > 0; calibrazione di `P_win` con Brier score sotto soglia prefissata; drawdown simulato entro limite; OPE positiva e robusta.
+- **Criteri di STOP/kill definitivo** (se veri, dichiarare il progetto non profittevole e fermarsi): dopo N aste live il ROI cumulato per puntata è ≤ 0 con IC che esclude valori positivi rilevanti; oppure la varianza dei rendimenti rende il rischio di rovina inaccettabile dato il bankroll; oppure il null-model teorico (profitto atteso = 0) non viene battuto in modo statisticamente significativo. La posizione di default, coerente con Augenblick/Wang-Xu/Hinnosaar, è che un edge consistente sia improbabile; il sistema deve essere pronto a concludere "non conviene" e a limitarsi al ruolo analitico.
+- Nicchie dove un margine può esistere (da testare, non garantite): aste a bassa partecipazione/orari morti, aste per principianti/con limitazioni, articoli con V ben noto e liquidabile, uso disciplinato del buy-now come hedge, sfruttamento di avversari sunk-cost/irrazionali.
+
+### 9. LOG ED ESPORTAZIONI
+- Tutte le esportazioni analitiche in **CSV con delimitatore punto e virgola (`;`)** e colonna `reason_detail` con motivazione dettagliata per ogni decisione (EV stimato, P_win, n_bidder stimati, hazard, latenza, vincolo di rischio applicato, azione scelta e perché).
+- Logging strutturato (JSON) + `bot_decisions` su DB; dashboard con ROI, win rate, calibrazione, drawdown, distribuzione dei rendimenti.
+
+### 10. PIANO DI LAVORO A FASI (milestone per Claude Code)
+- **M1 — Scaffolding & risk core**: repo, config, `RiskManager`, kill-switch, modalità shadow, test. (Nessuna rete.)
+- **M2 — Ingestion & storage**: Playwright feed sul DOM, clock sync, schema DB, raccolta aste chiuse + live in shadow.
+- **M3 — Dati storici & simulatore**: import Swoopo dataset, `PennyAuctionEnv` con avversari calibrati, backtester.
+- **M4 — Modelli supervisionati**: final price, end-within-N, active bidders, opponent profiling, survival (Cox/GBM), calibrazione.
+- **M5 — Policy & offline RL**: `theory.py` (EV, hazard, MDP), bandit Thompson, CQL/IQL offline, OPE.
+- **M6 — Valutazione shadow**: girare in shadow ≥ 4 settimane, misurare metriche §8, A/B policy.
+- **M7 — Esecuzione & latenza (gated)**: `executor` + compensazione latenza, test in sandbox; live solo se i gate §8 sono superati e con importi minimi.
+- **M8 — Monitoraggio & retraining**: dashboard, drift detection, retraining schedulato, criteri di stop automatici.
+
+Consegna codice pulito, tipizzato (type hints), testato (pytest), con docstring e un README che apra con le avvertenze legali/ToS e la realtà economica (EV negativo atteso).
+
+---
+
+## Details (contesto e giustificazione delle scelte del prompt)
+
+**Perché il null-model è centrale.** Tre lavori indipendenti convergono: Augenblick (2016) dimostra che "in any equilibria that does not end immediately, players bid probabilistically such that the expected profit from every bid is zero"; Platt-Price-Tappen (2013) mostrano che con bidder risk-neutral le pay-to-bid producono lo stesso ricavo atteso delle aste standard, ma con lievi preferenze risk-loving il ricavo del venditore eccede V ("a mild form of gambling"); Hinnosaar (2016) prova che con agenti razionali risk-neutral le penny auction non possono spiegare l'alto profitto medio del venditore. La profittabilità del banco viene quindi da deviazioni comportamentali: sunk-cost fallacy (Augenblick: la probabilità di abbandono decresce all'aumentare del sunk cost) e asimmetrie informative sul numero di bidder (Byers-Mitzenmacher-Zervas 2010). Un bot può sperare in un edge SOLO sfruttando l'irrazionalità altrui, non un vantaggio strutturale — da qui il design scettico.
+
+**Perché offline RL e non RL online diretto.** Non si può esplorare liberamente con denaro reale; CQL/IQL imparano da log storici penalizzando le azioni out-of-distribution, e l'OPE valida una policy prima di rischiare. Il simulatore con avversari calibrati (sui dati Swoopo pubblici e sui dati Bidoo raccolti) è il banco di prova sicuro.
+
+**Perché il timing è decisivo.** Roth-Ockenfels documentano che lo sniping (puntare all'ultimo istante) è una best response razionale in aste a chiusura "hard"; unito alla compensazione della latenza misurata, è la leva tecnica principale per non sprecare puntate. Wang & Xu (2012) osservano che i bidder che puntano a inizio/fine countdown sono i più sofisticati: utile per la profilazione avversari.
+
+**Incertezze tecniche reali.** Il meccanismo real-time di Bidoo e gli eventuali endpoint JSON non sono confermabili da fonti pubbliche; l'unico bot noto usa Selenium con click sul DOM. Il prompt impone quindi Playwright/DOM come default e vieta endpoint inventati.
+
+## Recommendations
+1. **Costruire M1–M6 (analisi/shadow) prima di tutto** e trattare l'esecuzione live come opzionale e sconsigliata finché i dati non provano un edge statisticamente significativo. Benchmark che cambia la decisione: ROI/puntata con estremo inferiore dell'IC > 0 su ≥ 4 settimane shadow.
+2. **Verificare manualmente il meccanismo real-time di Bidoo (DevTools/Network)** prima di scrivere qualsiasi `HttpFeed`; default su Playwright/DOM. Se non emerge un endpoint di stato affidabile, restare su DOM.
+3. **Aggiornare `bid_packs.yaml` con i prezzi reali osservati** al momento dell'uso: le fonti pubbliche divergono (€0,07–€0,50/puntata) e la pagina ufficiale non è verificabile automaticamente.
+4. **Fermarsi (stop-loss di progetto) se il null-model teorico non viene battuto**: se dopo il periodo di validazione l'EV per puntata non è positivo con significatività, dichiarare il sistema strumento analitico (non di puntata) e disattivare il live.
+5. **Non implementare mai multi-account/farming**: viola i ToS e concentra il rischio di chiusura dell'account (inclusa perdita delle puntate acquistate).
+
+## Caveats
+- **Prezzi pacchetti, valore V e timer esatti non confermabili da fonte ufficiale**: tutti parametrizzati in config, da ricalibrare all'uso.
+- **Meccanismo real-time ed endpoint di Bidoo: sconosciuti/da verificare**; il prompt evita esplicitamente endpoint inventati.
+- **Automazione e multi-account violano i Termini e Condizioni di Bidoo** (divieto di software di monitoraggio dell'offerta, un solo account): rischio concreto di blocco account e perdita puntate.
+- **Evidenza accademica netta**: EV negativo per i bidder in media (margine banco > 50%; oltre il 75% dei bidder abbandona entro 50 puntate; profitto consistente improbabile). Le recensioni pubbliche (Trustpilot/Opinioni) contengono numerose accuse di "bot" che manterrebbero alte le puntate: sono affermazioni non verificate degli utenti, non fatti accertati, ma vanno tenute presenti come ipotesi da testare (comportamento anomalo degli avversari) e come rischio reputazionale/operativo.
+- **Quadro legale in evoluzione**: le penny auction in Italia sono in una zona grigia; l'AGCM è già intervenuta contro operatori del settore (2015) e potrebbe intervenire in futuro; nessuna azione specifica contro Bidoo risulta ad oggi, ma ciò non costituisce garanzia.
diff --git a/Mimante/Services/AuctionMonitor.cs b/Mimante/Services/AuctionMonitor.cs
index c8ee8b9..9cb3d43 100644
--- a/Mimante/Services/AuctionMonitor.cs
+++ b/Mimante/Services/AuctionMonitor.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Engine;
+using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Utilities;
@@ -162,7 +163,7 @@ namespace AutoBidder.Services
// Il dossier di un'asta ancora viva va chiuso dicendo che è incompleto: se
// l'asta era conclusa il riepilogo l'ha già scritto OnAuctionCompleted, e questa
// chiamata non trova più nulla da chiudere.
- AuctionDossier.Abandon(auctionId, "rimossa dal monitor");
+ AuctionRecorder.Abandon(auctionId, "rimossa dal monitor");
// Spegnimento e pulizia fuori dal lock e fuori dal thread dell'interfaccia:
// StopAsync attende la fine dei due anelli, che possono essere in mezzo a una
@@ -455,7 +456,7 @@ namespace AutoBidder.Services
// Il dossier registra tutto ciò che passa di qui: è materiale che esiste
// solo adesso.
var pollSettings = Settings;
- AuctionDossier.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls);
+ AuctionRecorder.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls);
if (state.RecentBidsHistory != null && state.RecentBidsHistory.Count > 0)
{
@@ -508,16 +509,15 @@ namespace AutoBidder.Services
/// Verdetto sulle strategie. Il runner decide QUANDO puntare, questo metodo SE.
/// Viene chiamato una volta per ciclo, ~60 ms prima dell'istante di fuoco.
///
- public bool EvaluateBid(AuctionInfo auction, AuctionState state)
+ public bool EvaluateBid(AuctionInfo auction, AuctionState state, bool shadow = false)
{
var settings = Settings;
+ auction.PendingDecisionId = null;
if (state.IsMyBid) return false;
// L'asta dev'essere davvero aperta. Questo controllo è sempre attivo e non
- // costa nulla: usa l'ultima risposta ricevuta, che nella finestra critica ha
- // al massimo qualche centinaio di millisecondi. Una verifica dedicata via rete
- // costerebbe invece un intero giro proprio nell'istante peggiore.
+ // costa nulla: usa l'ultima risposta ricevuta.
if (state.Status != AuctionStatus.Running && state.Status != AuctionStatus.Pending)
{
auction.AddLog($"⛔ Asta non aperta ({state.Status}): puntata annullata",
@@ -525,22 +525,38 @@ namespace AutoBidder.Services
return false;
}
- if (!ShouldBid(auction, state, settings))
+ var mode = shadow ? "shadow" : "live";
+
+ if (!ShouldBid(auction, state, settings, out var limitReason))
{
+ AuctionRecorder.RecordDecision(auction, state, "NO-OP", null, null, limitReason ?? "limiti dell'asta", mode, null, "paletti", null);
return false;
}
var session = _apiClient.GetSession();
- var decision = _bidStrategy.ShouldPlaceBid(auction, state, settings, session?.Username ?? "");
+ var decision = _bidStrategy.ShouldPlaceBid(auction, state, settings, session?.Username ?? "", GetAuctions(), shadow);
if (!decision.ShouldBid)
{
- auction.AddLog($"⛔ Strategia blocca: {decision.Reason}",
+ auction.AddLog($"⛔ {(shadow ? "Shadow, non avrei puntato" : "Strategia blocca")}: {decision.Reason}",
AuctionLogLevel.Strategy, AuctionLogCategory.Strategy);
- LogBlockThrottled(auction, "STRATEGY", $"[STRATEGY] {auction.Name}: {decision.Reason}");
+ if (!shadow) LogBlockThrottled(auction, "STRATEGY", $"[STRATEGY] {auction.Name}: {decision.Reason}");
+ AuctionRecorder.RecordDecision(auction, state, "NO-OP", decision.Probability, decision.ExpectedValue,
+ decision.Reason, mode, decision.Regime, decision.Policy, null, decision.ContextKey, decision.AltAction, decision.AltP);
return false;
}
+ var id = AuctionRecorder.RecordDecision(auction, state, "BID", decision.Probability, decision.ExpectedValue,
+ decision.Reason, mode, decision.Regime, decision.Policy, null, decision.ContextKey, decision.AltAction, decision.AltP);
+
+ if (shadow)
+ {
+ auction.AddLog($"[SHADOW] Avrei puntato a {state.Price:F2} € — {decision.Reason}",
+ AuctionLogLevel.Strategy, AuctionLogCategory.Strategy);
+ return false;
+ }
+
+ auction.PendingDecisionId = id;
auction.AddLog("✓ Tutti i controlli superati → PUNTATA!",
AuctionLogLevel.Bid, AuctionLogCategory.BidAttempt);
return true;
@@ -555,7 +571,7 @@ namespace AutoBidder.Services
// La coppia anticipo pianificato/effettivo è il dato per cui esiste il dossier:
// è l'unico modo, rileggendo a freddo, per capire se il valore impostato era
// quello giusto o se la puntata arrivava sistematicamente troppo presto o tardi.
- AuctionDossier.For(auction.AuctionId)?.MyBid(
+ AuctionRecorder.For(auction.AuctionId)?.MyBid(
price: state.Price,
plannedLeadMs: leadMs,
actualLeadMs: actualLeadMs,
@@ -563,7 +579,9 @@ namespace AutoBidder.Services
success: result?.Success ?? false,
error: result?.Error,
bidsUsed: auction.BidsUsedOnThisAuction,
- remainingBids: auction.RemainingBids);
+ remainingBids: auction.RemainingBids,
+ 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.
@@ -641,6 +659,14 @@ namespace AutoBidder.Services
? DateTimeOffset.FromUnixTimeSeconds(state.ExpiryUnixSeconds).ToLocalTime().DateTime
: DateTime.Now;
+ if (won)
+ {
+ var valoreReale = BidStrategyService.ValueOf(auction) ?? 0;
+ var impostazioniFine = SettingsManager.Load();
+ var netto = Ml.Theory.RealizedWinProfit(valoreReale, state.Price, 0, impostazioniFine.TransactionFeeEuro, auction.ShippingCost ?? 0);
+ RiskManager.NoteWin(auction.AuctionId, netto, $"vinta a {state.Price:F2} €, valore reale {valoreReale:F2} €");
+ }
+
auction.AddLog($"[ASTA TERMINATA] {statusMsg}");
OnLog?.Invoke($"[FINE] [{auction.AuctionId}] Asta {statusMsg}");
@@ -741,6 +767,7 @@ namespace AutoBidder.Services
// Registra metriche
bool isCollision = !result.Success && IsLateBidError(result.Error);
_bidStrategy.RecordBidAttempt(auction, result.Success);
+ if (result.Success) RiskManager.NoteBidSpent(auction.AuctionId, auction.BidCost);
// Il modello di latenza impara da ogni puntata: quanto ci ha messo, e se è
// arrivata tardi. Una tardiva alza l'anticipo subito; molte in tempo lo
@@ -831,9 +858,10 @@ namespace AutoBidder.Services
}
}
- private bool ShouldBid(AuctionInfo auction, AuctionState state, AppSettings? settings = null)
+ private bool ShouldBid(AuctionInfo auction, AuctionState state, AppSettings? settings, out string? reason)
{
settings ??= Utilities.SettingsManager.Load();
+ reason = null;
// CONTROLLO 0: Verifica convenienza (se abilitato e dati disponibili)
if (settings.ValueCheckEnabled &&
@@ -849,7 +877,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Risparmio {auction.CalculatedValue.SavingsPercentage.Value:F1}% < {settings.MinSavingsPercentage:F1}% richiesto",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Value);
LogBlockThrottled(auction, "VALUE", $"[VALUE] {auction.Name}: risparmio {auction.CalculatedValue.SavingsPercentage.Value:F1}% insufficiente");
- return false;
+ reason = $"risparmio {auction.CalculatedValue.SavingsPercentage.Value:F1}% sotto il minimo {settings.MinSavingsPercentage:F1}%"; return false;
}
}
else
@@ -867,7 +895,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Puntate residue ({session.RemainingBids}) ≤ limite ({settings.MinimumRemainingBids})",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "LIMIT", $"[LIMIT] {auction.Name}: puntate residue ({session.RemainingBids}) <= limite ({settings.MinimumRemainingBids})");
- return false;
+ reason = $"puntate residue {session.RemainingBids} ≤ limite {settings.MinimumRemainingBids}"; return false;
}
else if (session != null)
{
@@ -881,7 +909,7 @@ namespace AutoBidder.Services
{
auction.AddLog($"✓ Sono già vincitore corrente - skip",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.BidAttempt);
- return false;
+ reason = "già in testa"; return false;
}
// CONTROLLO 3: Limite puntate per questa asta
@@ -895,7 +923,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Limite puntate raggiunto ({usedBids}/{maxBids})",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "MAXBIDS", $"[LIMIT] {auction.Name}: puntate {usedBids}/{maxBids} - STOP");
- return false;
+ reason = $"limite puntate dell'asta raggiunto ({usedBids}/{maxBids})"; return false;
}
if (maxBids > 0)
@@ -911,7 +939,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Prezzo €{state.Price:F2} < Min €{auction.MinPrice:F2}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Price);
LogBlockThrottled(auction, "PRICE_LOW", $"[PRICE] {auction.Name}: €{state.Price:F2} < Min €{auction.MinPrice:F2} - NON PUNTA");
- return false;
+ reason = $"prezzo {state.Price:F2} € sotto il minimo {auction.MinPrice:F2} €"; return false;
}
if (auction.MaxPrice > 0 && state.Price > auction.MaxPrice)
@@ -919,7 +947,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Prezzo €{state.Price:F2} > Max €{auction.MaxPrice:F2}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Price);
LogBlockThrottled(auction, "PRICE_HIGH", $"[PRICE] {auction.Name}: €{state.Price:F2} > Max €{auction.MaxPrice:F2} - NON PUNTA");
- return false;
+ reason = $"prezzo {state.Price:F2} € oltre il massimo {auction.MaxPrice:F2} €"; return false;
}
auction.AddLog($"✓ Prezzo OK (€{state.Price:F2} in [{auction.MinPrice:F2}, {(auction.MaxPrice > 0 ? auction.MaxPrice.ToString("F2") : "∞")}])",
@@ -950,7 +978,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ {budget.Reason}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "BUDGET", $"[BUDGET] {auction.Name}: {budget.Reason}");
- return false;
+ reason = budget.Reason; return false;
}
}
@@ -962,7 +990,7 @@ namespace AutoBidder.Services
{
auction.AddLog($"Cooldown: {timeSinceLastClick.TotalMilliseconds:F0}ms < 800ms",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Ticker);
- return false;
+ reason = $"cooldown {timeSinceLastClick.TotalMilliseconds:F0} ms"; return false;
}
}
@@ -1012,7 +1040,7 @@ namespace AutoBidder.Services
{
auction.ResetCount++;
- AuctionDossier.For(auction.AuctionId)?.Reset(auction.ResetCount, state.Price, state.LastBidder);
+ AuctionRecorder.For(auction.AuctionId)?.Reset(auction.ResetCount, state.Price, state.LastBidder);
// Log quando c'è una nuova puntata (se logging attivo)
var settings = SettingsManager.Load();
@@ -1075,14 +1103,14 @@ namespace AutoBidder.Services
bids.Select(b => $"{b.Timestamp}_{b.Username}_{b.Price:F2}"));
var added = 0;
- var dossier = AuctionDossier.For(auction.AuctionId);
+ var dossier = AuctionRecorder.For(auction.AuctionId);
// Le puntate nostre vanno marcate qui, dove il nome di sessione c'è: il
// parser lo fa solo se glielo si passa, e nel percorso dei poll non gli
// arriva. Senza, nessun dossier ha mai portato una puntata con mine:true,
// e l'analisi non poteva dire cosa fosse successo alle nostre.
var me = _apiClient.GetSession()?.Username;
- if (!string.IsNullOrEmpty(me)) AuctionDossier.CurrentUsername = me;
+ if (!string.IsNullOrEmpty(me)) AuctionRecorder.CurrentUsername = me;
foreach (var bid in newBids)
{
diff --git a/Mimante/Services/BidStrategyService.cs b/Mimante/Services/BidStrategyService.cs
index 816ec4e..809a936 100644
--- a/Mimante/Services/BidStrategyService.cs
+++ b/Mimante/Services/BidStrategyService.cs
@@ -1,59 +1,82 @@
using System;
+using System.Collections.Generic;
+using AutoBidder.Ml;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
///
- /// Decide se puntare. Quattro passi, sempre nello stesso ordine — vedi
+ /// Decide se puntare. Cinque passi, sempre nello stesso ordine — vedi
/// Ml/LEGGIMI.md per il disegno completo e i numeri che lo giustificano.
///
///
+ /// Rischio — il gestore del rischio: kill-switch, HALT, stop-loss,
+ /// drawdown, tetto del giorno, aste in gioco insieme. Solo dal vivo: in shadow la
+ /// decisione si registra com'è, per poterla giudicare.
/// Paletti — regole dell'utente che non si discutono: tetti di
- /// puntate e di spesa, fascia oraria sospesa. Non imparano niente: sono il
- /// perimetro dentro cui tutto il resto è libero di muoversi.
+ /// puntate, fascia oraria sospesa.
/// Duello — riconoscimento dell'autopuntata avversaria dal ritmo
- /// delle risposte. È il segnale più forte che esista sul momento, e informa il
- /// regime prima ancora del modello.
+ /// delle risposte.
/// Valore atteso — il modello appreso dice con che probabilità una
- /// puntata fatta adesso resterebbe senza risposta; moltiplicata per il margine e
- /// al netto del costo dà quanto vale, in euro, puntare in questo istante.
+ /// puntata fatta adesso resterebbe senza risposta; la
+ /// trasforma in euro con valore reale del bene, fee, spedizione e copertura.
/// Regime — una macchina a stati per asta che trasforma la sequenza
/// dei valori attesi in una condotta: quando lasciar sfogare gli altri, quando
- /// sondare, quando tornare a giocare. Con isteresi, perché ogni rientro
- /// sbagliato costa una puntata.
+ /// sondare, quando tornare a giocare.
///
///
- /// Le vecchie euristiche a soglia fissa — calore, ritiro morbido, avversari
- /// aggressivi, anti-bot, puntata probabilistica, velocità del prezzo — non ci sono
- /// più: rigiocate sui dossier non hanno mai bloccato una puntata sbagliata senza
- /// bloccarne anche di giuste, e i loro numeri magici andavano tarati a mano. Il
- /// modello e il regime imparano dai dati storici e dalla sessione in corso.
- ///
/// Quando puntare — l'anticipo — non si decide qui ma nel cecchino,
- /// con : anche quello si adatta alla rete della
- /// sessione, dentro i paletti dell'utente.
+ /// con .
///
public class BidStrategyService
{
private int _sessionTotalBids = 0;
private DateTime _sessionStartedAt = DateTime.UtcNow;
+ private readonly Random _rng = new();
- public BidDecision ShouldPlaceBid(AuctionInfo auction, AuctionState state, AppSettings settings, string currentUsername)
+ ///
+ /// Il valore reale V del bene di quest'asta: quello scritto dall'utente sul
+ /// prodotto, se c'è; altrimenti il «Compralo Ora». Vedi : il
+ /// listino di Bidoo è gonfiato, e un valore atteso calcolato su di esso mente.
+ ///
+ public static double? ValueOf(AuctionInfo auction)
{
- var decision = new BidDecision { ShouldBid = true };
+ if (auction == null) return null;
+ var scheda = WatchedProductsStore.FindByName(auction.Name);
+ return scheda?.MarketValue is > 0 ? scheda.MarketValue : auction.BuyNowPrice;
+ }
- // ── 1. Paletti ──────────────────────────────────────────────────
+ public BidDecision ShouldPlaceBid(
+ AuctionInfo auction,
+ AuctionState state,
+ AppSettings settings,
+ string currentUsername,
+ IReadOnlyList? allAuctions = null,
+ bool shadow = false)
+ {
+ var decision = new BidDecision { ShouldBid = true, Policy = "ev-regime" };
- // Fascia oraria sospesa. L'asta NON viene fermata: resta Attiva, continua a
- // essere seguita e riprende a puntare da sola alla fine della fascia. Su 6060
- // aste concluse la stessa asta costa quasi il doppio alle 0 e alle 9 rispetto
- // alle 10-13: vedi BiddingHours.
+ // ── 1. Rischio ─────────────────────────────────────────────────
+ if (!shadow)
+ {
+ var risk = RiskManager.Authorize(auction, settings, allAuctions);
+ if (!risk.Allowed)
+ {
+ decision.ShouldBid = false;
+ decision.Reason = risk.Reason;
+ decision.Policy = "rischio";
+ return decision;
+ }
+ }
+
+ // ── 2. Paletti ─────────────────────────────────────────────────
if (QuietHoursApply(auction, settings) &&
BiddingHours.IsQuiet(DateTime.Now, settings.QuietHoursStart, settings.QuietHoursEnd))
{
decision.ShouldBid = false;
decision.Reason = BiddingHours.Spiegazione(settings.QuietHoursStart, settings.QuietHoursEnd);
+ decision.Policy = "paletti";
return decision;
}
@@ -64,17 +87,12 @@ namespace AutoBidder.Services
{
decision.ShouldBid = false;
decision.Reason = bankrollCheck.Reason;
+ decision.Policy = "paletti";
return decision;
}
}
- // ── 2. Duello con l'autopuntata ─────────────────────────────────
- //
- // L'autopuntata del sito scatta a 2 secondi e si riarma ogni volta che il suo
- // padrone perde la testa: risponde sempre, in ~6 secondi, e non si stanca.
- // Nelle prove reali il motore ci ha lasciato 191 puntate su tre aste per zero
- // vittorie. Il riconoscimento vive in AutoBidDuel; qui si applica e si passa
- // l'informazione al regime, che da quel momento pretende più pazienza.
+ // ── 3. Duello con l'autopuntata ────────────────────────────────
if (auction.AutoBidDuelDetected)
{
auction.Regime.AutopuntataRiconosciuta();
@@ -83,50 +101,75 @@ namespace AutoBidder.Services
{
decision.ShouldBid = false;
decision.Reason = AutoBidDuel.Spiegazione(auction.AutoResponsesInARow);
+ decision.Policy = "duello";
+ decision.Regime = auction.Regime.StatoAttuale.ToString();
return decision;
}
}
- // ── 3. Valore atteso appreso ────────────────────────────────────
+ // ── 4. Valore atteso ───────────────────────────────────────────
//
// Il modello parla sempre; decide solo quando ha appreso abbastanza aste. La
- // probabilità e il valore atteso finiscono comunque sull'asta, per il registro
- // e per l'interfaccia.
+ // probabilità e il valore atteso finiscono comunque sull'asta e sulla decisione.
double? ev = null;
string? spiegazioneEv = null;
var pronto = false;
+ var value = ValueOf(auction);
- if (settings.LearningGateEnabled && auction.BuyNowPrice is > 0)
+ if (settings.LearningGateEnabled && value is > 0)
{
- var prob = Ml.LearningService.PredictUnanswered(auction, state, DateTime.Now, currentUsername);
- if (prob is { } p)
+ var ctx = LearningService.BuildContext(auction, state, DateTime.Now, currentUsername);
+ var prob = ctx is { } c0 ? LearningService.PredictUnanswered(c0) : (double?)null;
+ if (prob is { } p && ctx is { } c)
{
var costo = settings.AverageBidCostEuro * Math.Max(0.1, settings.LearningEvMultiplier);
- var margine = auction.BuyNowPrice.Value - state.Price - 0.01;
- var valore = p * margine - costo;
+ var speso = (auction.BidsUsedOnThisAuction ?? 0) * auction.BidCost;
+
+ var situazione = new Theory.BidSituation(
+ PWin: p,
+ Value: value.Value,
+ CurrentPrice: state.Price,
+ BidCost: costo,
+ TransactionFee: settings.TransactionFeeEuro,
+ Shipping: auction.ShippingCost ?? 0,
+ ListPrice: auction.BuyNowPrice,
+ BidsSpentEuro: speso,
+ HedgeEnabled: settings.BuyNowHedgeEnabled);
+
+ var valore = Theory.BidExpectedValue(situazione);
auction.LearnedUnansweredProbability = p;
auction.LearnedExpectedValue = valore;
- pronto = Ml.LearningService.IsReady(settings);
+ pronto = LearningService.IsReady(settings);
ev = valore;
+ decision.Probability = p;
+ decision.ExpectedValue = valore;
+
+ var margine = value.Value - state.Price - Theory.PriceIncrement - settings.TransactionFeeEuro - (auction.ShippingCost ?? 0);
spiegazioneEv =
$"valore atteso negativo: P(senza risposta) {p:P2} × margine {margine:F2} € " +
- $"− puntata {costo:F2} € = {valore:+0.000;-0.000} €";
+ $"− puntata {costo:F2} €{(Theory.LossBranchCost(situazione) == 0 ? " (coperta)" : "")} = {valore:+0.000;-0.000} €";
- Ml.LearningService.RecordDecision(auction.Name, state.Price, p, valore,
+ LearningService.RecordDecision(auction.Name, state.Price, p, valore,
blocked: pronto && valore < 0, ready: pronto);
+
+ // La seconda policy, sempre in shadow: il bandit dice la sua sullo
+ // stesso istante, e alla chiusura entrambe vengono giudicate sugli esiti.
+ var key = ThompsonBandit.KeyOf(c);
+ var (altBid, altP) = LearningService.Bandit.Decide(key, situazione, _rng);
+ decision.ContextKey = key;
+ decision.AltAction = altBid ? "BID" : "NO-OP";
+ decision.AltP = altP;
}
}
- // ── 4. Regime di concorrenza ────────────────────────────────────
- //
- // Finché il modello non è pronto il regime non riceve segnali: resta Calmo e
- // l'unica cosa che lo muove è il duello riconosciuto sopra.
+ // ── 5. Regime di concorrenza ───────────────────────────────────
if (pronto && ev is { } valoreAtteso)
{
var statoPrima = auction.Regime.StatoAttuale;
var consentito = auction.Regime.Osserva(valoreAtteso >= 0);
+ decision.Regime = auction.Regime.StatoAttuale.ToString();
if (auction.Regime.StatoAttuale != statoPrima)
{
@@ -137,11 +180,21 @@ namespace AutoBidder.Services
if (!consentito)
{
decision.ShouldBid = false;
- decision.Reason = auction.Regime.StatoAttuale == Ml.CompetitionRegime.Stato.Calmo
+ decision.Reason = auction.Regime.StatoAttuale == CompetitionRegime.Stato.Calmo
? spiegazioneEv
: auction.Regime.Spiegazione();
return decision;
}
+
+ decision.Reason = $"valore atteso {valoreAtteso:+0.000;-0.000} € (P {ev:P2}), regime {auction.Regime.StatoAttuale}";
+ }
+ else if (ev is { } evNonPronto)
+ {
+ decision.Reason = $"modello non ancora pronto ({LearningService.AuctionsLearned}/{settings.LearningMinAuctions} aste): parere EV {evNonPronto:+0.000;-0.000} €, si punta";
+ }
+ else
+ {
+ decision.Reason = "nessuna stima disponibile (valore ignoto o nessuna puntata recente): si punta";
}
return decision;
@@ -151,7 +204,6 @@ namespace AutoBidder.Services
{
var result = new BankrollCheckResult { CanBid = true };
- // Limite puntate per asta
var maxPerAuction = auction.MaxBidsOverride ?? settings.MaxBidsPerAuction;
if (maxPerAuction > 0 && auction.SessionBidCount >= maxPerAuction)
{
@@ -160,7 +212,6 @@ namespace AutoBidder.Services
return result;
}
- // Limite puntate per sessione
if (settings.MaxBidsPerSession > 0 && _sessionTotalBids >= settings.MaxBidsPerSession)
{
result.CanBid = false;
@@ -168,35 +219,18 @@ namespace AutoBidder.Services
return result;
}
- // Budget giornaliero
- if (settings.DailyBudgetEuro > 0)
- {
- var spent = _sessionTotalBids * settings.AverageBidCostEuro;
- if (spent >= settings.DailyBudgetEuro)
- {
- result.CanBid = false;
- result.Reason = $"Budget giornaliero esaurito (€{spent:F2}/€{settings.DailyBudgetEuro:F2})";
- return result;
- }
- }
-
return result;
}
///
- /// La sospensione a fascia oraria vale per quest'asta?
- ///
- /// Tre livelli, dal più specifico al più generale: l'asta scavalca il
- /// prodotto, il prodotto scavalca le impostazioni generali. Un null non
- /// significa "no", significa "non ho un'opinione": è la differenza che permette di
- /// spegnere il filtro su una singola asta senza toccare il resto.
+ /// La sospensione a fascia oraria vale per quest'asta? Tre livelli, dal più
+ /// specifico al più generale: l'asta scavalca il prodotto, il prodotto scavalca
+ /// le impostazioni generali.
///
public static bool QuietHoursApply(AuctionInfo auction, AppSettings settings)
{
if (auction?.QuietHoursEnabledOverride is { } perAsta) return perAsta;
- // Le aste del monitor non portano con sé l'id prodotto di Bidoo: la scheda si
- // ritrova dal nome, che è la via prevista da WatchedProductsStore.
var scheda = WatchedProductsStore.FindByName(auction?.Name);
if (scheda?.QuietHoursEnabled is { } perProdotto) return perProdotto;
@@ -212,10 +246,6 @@ namespace AutoBidder.Services
if (success)
{
auction.SuccessfulBidCount++;
-
- // Da qui si misura quanto tarda la risposta avversaria: e' l'unico punto
- // attraversato sia dal motore dal vivo sia dalla rigiocata sui dossier,
- // quindi duello e regime vedono le stesse cose in entrambi.
auction.LastMyBidAtUtc = DateTime.UtcNow;
auction.Regime.PuntataPiazzata();
}
@@ -247,10 +277,32 @@ namespace AutoBidder.Services
}
}
+ /// Il verdetto della strategia, con i numeri che l'hanno prodotto.
public class BidDecision
{
public bool ShouldBid { get; set; }
public string? Reason { get; set; }
+
+ /// P(senza risposta) stimata dal modello, se c'era.
+ public double? Probability { get; set; }
+
+ /// Valore atteso in euro della puntata, se c'era.
+ public double? ExpectedValue { get; set; }
+
+ /// Regime di concorrenza al momento della decisione.
+ public string? Regime { get; set; }
+
+ /// Quale parte ha deciso: rischio, paletti, duello, ev-regime.
+ public string Policy { get; set; } = "";
+
+ /// Chiave del contesto per il bandit, se il modello ha parlato.
+ public string? ContextKey { get; set; }
+
+ /// Cosa avrebbe fatto il bandit Thompson sullo stesso istante.
+ public string? AltAction { get; set; }
+
+ /// La probabilità estratta dal bandit.
+ public double? AltP { get; set; }
}
public class BankrollCheckResult
diff --git a/Mimante/Services/RiskManager.cs b/Mimante/Services/RiskManager.cs
new file mode 100644
index 0000000..3282971
--- /dev/null
+++ b/Mimante/Services/RiskManager.cs
@@ -0,0 +1,278 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using AutoBidder.Data;
+using AutoBidder.Models;
+using AutoBidder.Utilities;
+
+namespace AutoBidder.Services
+{
+ ///
+ /// Il gestore del rischio: i paletti duri che nessuna strategia può scavalcare.
+ /// Ogni puntata dal vivo passa da prima di partire.
+ ///
+ /// 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.
+ ///
+ /// HALT è persistente. Si scrive nel database e sopravvive al riavvio:
+ /// una regola di sicurezza che si azzera chiudendo e riaprendo il programma non è una
+ /// regola. Si toglie a mano, dalla scheda Impostazioni, e la rimozione finisce nel
+ /// registro.
+ ///
+ /// La contabilità sta nella tabella risk_ledger: una riga per puntata
+ /// riuscita (uscita) e una per vincita (entrata, al valore reale del bene meno i
+ /// costi). I totali del giorno si tengono in memoria e si rileggono a ogni cambio di
+ /// giorno, così la verifica per puntata non interroga il disco.
+ ///
+ public static class RiskManager
+ {
+ public readonly record struct Verdict(bool Allowed, string? Reason);
+
+ private static readonly object Sync = new();
+ private static AuctionDatabase Db => AuctionDatabase.Instance;
+
+ private static DateTime _ledgerDay = DateTime.MinValue;
+ private static double _todaySpent; // euro usciti oggi (puntate)
+ private static double _todayGained; // euro entrati oggi (vincite)
+ private static double _cumulative; // saldo cumulato di sempre
+ private static double _peak; // massimo del saldo cumulato
+ private static bool _ledgerLoaded;
+
+ 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 ─────────────────────────────────────────────────────────
+
+ /// Il motivo dell'HALT in corso, o null.
+ public static string? HaltReason
+ {
+ get
+ {
+ lock (Sync)
+ {
+ if (!_haltLoaded)
+ {
+ _haltLoaded = true;
+ try { _haltReason = Db.ScalarString("SELECT value FROM meta WHERE key = 'risk_halt'"); }
+ catch { _haltReason = null; }
+ }
+ return _haltReason;
+ }
+ }
+ }
+
+ public static bool Halted => !string.IsNullOrEmpty(HaltReason);
+
+ public static void Halt(string reason)
+ {
+ lock (Sync)
+ {
+ _haltLoaded = true;
+ _haltReason = $"{DateTime.Now:dd/MM HH:mm} — {reason}";
+ Db.Enqueue("INSERT INTO meta(key, value) VALUES('risk_halt', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", _haltReason);
+ Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, note) VALUES(?1, 'halt', 0, ?2)", AuctionDatabase.Now(), reason);
+ }
+ }
+
+ public static void Resume()
+ {
+ lock (Sync)
+ {
+ _haltLoaded = true;
+ _haltReason = null;
+ Db.Enqueue("DELETE FROM meta WHERE key = 'risk_halt'");
+ Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, note) VALUES(?1, 'resume', 0, 'HALT tolto a mano')", AuctionDatabase.Now());
+ }
+ }
+
+ // ── Contabilità ──────────────────────────────────────────────────
+
+ private static void EnsureLedger()
+ {
+ var today = DateTime.Today;
+ if (_ledgerLoaded && _ledgerDay == today) return;
+
+ _ledgerDay = today;
+ _ledgerLoaded = true;
+ _todaySpent = 0;
+ _todayGained = 0;
+ _cumulative = 0;
+ _peak = 0;
+
+ try
+ {
+ var since = AuctionDatabase.Iso(today); // mezzanotte locale, scritta in UTC
+
+ foreach (var r in Db.Query("SELECT kind, amount_euro FROM risk_ledger WHERE ts >= ?1 AND kind IN ('bid', 'win')", since))
+ {
+ if (r.Str("kind") == "bid") _todaySpent += -r.Dbl("amount_euro");
+ else _todayGained += r.Dbl("amount_euro");
+ }
+
+ // Il picco del saldo si ricostruisce scorrendo la storia in ordine.
+ foreach (var r in Db.Query("SELECT amount_euro FROM risk_ledger WHERE kind IN ('bid', 'win') ORDER BY id"))
+ {
+ _cumulative += r.Dbl("amount_euro");
+ if (_cumulative > _peak) _peak = _cumulative;
+ }
+ }
+ catch { /* senza contabilità si applicano solo i vincoli che non ne hanno bisogno */ }
+ }
+
+ /// Una puntata riuscita: esce il suo costo.
+ public static void NoteBidSpent(string auctionId, double bidCost)
+ {
+ if (bidCost <= 0) return;
+ lock (Sync)
+ {
+ EnsureLedger();
+ _todaySpent += bidCost;
+ _cumulative -= bidCost;
+ Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, auction_id) VALUES(?1, 'bid', ?2, ?3)",
+ AuctionDatabase.Now(), -bidCost, auctionId);
+ }
+ }
+
+ /// Una vincita: entra il valore reale del bene meno prezzo, fee e spedizione (le puntate sono già uscite).
+ public static void NoteWin(string auctionId, double netGain, string note)
+ {
+ lock (Sync)
+ {
+ EnsureLedger();
+ _todayGained += netGain;
+ _cumulative += netGain;
+ if (_cumulative > _peak) _peak = _cumulative;
+ Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, auction_id, note) VALUES(?1, 'win', ?2, ?3, ?4)",
+ AuctionDatabase.Now(), netGain, auctionId, note);
+ }
+ }
+
+ /// I numeri del giorno e di sempre, per la scheda.
+ public static (double TodaySpent, double TodayGained, double TodayNet, double Cumulative, double Peak, double Drawdown) Snapshot()
+ {
+ lock (Sync)
+ {
+ EnsureLedger();
+ return (_todaySpent, _todayGained, _todayGained - _todaySpent, _cumulative, _peak, _peak - _cumulative);
+ }
+ }
+
+ // ── 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).
+ ///
+ 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");
+
+ lock (Sync)
+ {
+ EnsureLedger();
+
+ if (settings.DailyStopLossEuro > 0 && _todaySpent - _todayGained >= settings.DailyStopLossEuro)
+ {
+ var reason = $"stop-loss giornaliero raggiunto: perdita netta di oggi {_todaySpent - _todayGained:F2} € ≥ {settings.DailyStopLossEuro:F2} €";
+ HaltUnlocked(reason);
+ return new Verdict(false, reason);
+ }
+
+ if (settings.MaxDrawdownEuro > 0 && _peak - _cumulative >= settings.MaxDrawdownEuro)
+ {
+ var reason = $"drawdown massimo raggiunto: {_peak - _cumulative:F2} € sotto il picco (limite {settings.MaxDrawdownEuro:F2} €)";
+ HaltUnlocked(reason);
+ return new Verdict(false, reason);
+ }
+
+ if (settings.DailyBudgetEuro > 0 && _todaySpent + Math.Max(0, auction.BidCost) > settings.DailyBudgetEuro)
+ return new Verdict(false, $"tetto di spesa del giorno: spesi {_todaySpent:F2} € su {settings.DailyBudgetEuro:F2} €");
+ }
+
+ if (settings.MaxConcurrentActiveAuctions > 0 && allAuctions != null)
+ {
+ // Aste su cui si sta già spendendo in questa sessione, quest'asta esclusa.
+ var inGioco = allAuctions.Count(a =>
+ a.State == RunState.Active && a.SessionBidCount > 0 &&
+ !string.Equals(a.AuctionId, auction.AuctionId, StringComparison.Ordinal));
+
+ if (auction.SessionBidCount == 0 && inGioco >= settings.MaxConcurrentActiveAuctions)
+ return new Verdict(false, $"già {inGioco} aste in gioco insieme (limite {settings.MaxConcurrentActiveAuctions})");
+ }
+
+ return new Verdict(true, null);
+ }
+
+ private static void HaltUnlocked(string reason)
+ {
+ _haltLoaded = true;
+ _haltReason = $"{DateTime.Now:dd/MM HH:mm} — {reason}";
+ Db.Enqueue("INSERT INTO meta(key, value) VALUES('risk_halt', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", _haltReason);
+ Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, note) VALUES(?1, 'halt', 0, ?2)", AuctionDatabase.Now(), reason);
+ }
+
+ /// Solo per i test: dimentica contabilità e HALT in memoria.
+ public static void ResetForTests()
+ {
+ lock (Sync)
+ {
+ _ledgerLoaded = false;
+ _haltLoaded = false;
+ _haltReason = null;
+ _killSwitchCheckedAt = DateTime.MinValue;
+ }
+ }
+ }
+}
diff --git a/Mimante/Tests/AuctionDossierTests.cs b/Mimante/Tests/AuctionDossierTests.cs
deleted file mode 100644
index ece5800..0000000
--- a/Mimante/Tests/AuctionDossierTests.cs
+++ /dev/null
@@ -1,302 +0,0 @@
-using System;
-using System.IO;
-using System.Linq;
-using System.Text.Json;
-using AutoBidder.Models;
-using AutoBidder.Utilities;
-using Xunit;
-
-namespace AutoBidder.Tests;
-
-///
-/// Il dossier è l'unico posto in cui certi dati esistono: Bidoo non espone né i tempi delle
-/// puntate né l'andamento del prezzo di un'asta conclusa. Le proprietà da difendere sono
-/// quindi tre — che il file si scriva, che ogni riga sia JSON valido da sola
-/// (è quello che permette di leggerlo a pezzi e di sopravvivere a un'interruzione), e che il
-/// riepilogo finale dica se la storia è completa.
-///
-public class AuctionDossierTests
-{
- private static AppSettings Settings(bool rawPolls = true) => new()
- {
- WriteAuctionDossiers = true,
- DossierIncludeRawPolls = rawPolls
- };
-
- private static AuctionInfo NewAuction() => new()
- {
- AuctionId = "test-" + Guid.NewGuid().ToString("N")[..8],
- Name = "Cuffie Sony WH-1000XM5",
- BidBeforeDeadlineMs = 800,
- BuyNowPrice = 299,
- ShippingCost = 6.90
- };
-
- private static AuctionState State(double price, double timer, int ping = 70) => new()
- {
- Price = price,
- Timer = timer,
- PollingLatencyMs = ping,
- Status = AuctionStatus.Running,
- LastBidder = "marco_82",
- ExpiryUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (long)timer
- };
-
- private static AuctionDetailRecord Detail(AuctionInfo auction) => new()
- {
- AuctionId = auction.AuctionId,
- Name = auction.Name,
- Outcome = "Persa",
- FinalPrice = 4.87,
- BuyNowPrice = auction.BuyNowPrice,
- ShippingCost = auction.ShippingCost,
- MyBids = 9,
- EndedAt = DateTime.Now,
- FirstSeenAt = DateTime.Now.AddMinutes(-35),
- ObservedMinutes = 35,
- ConfiguredLeadMs = 800
- };
-
- private static string[] LinesOf(AuctionDossier dossier)
- {
- FileLogWriter.FlushAll();
- return File.ReadAllLines(dossier.Path).Where(l => l.Length > 0).ToArray();
- }
-
- [Fact]
- public void Aprire_il_dossier_scrive_lintestazione()
- {
- var auction = NewAuction();
- var dossier = AuctionDossier.OpenFor(auction, Settings())!;
-
- var header = JsonDocument.Parse(LinesOf(dossier)[0]).RootElement;
-
- Assert.Equal("header", header.GetProperty("type").GetString());
- Assert.Equal(auction.AuctionId, header.GetProperty("auctionId").GetString());
-
- // Il valore del prodotto deve stare nel file: senza, il prezzo finale non dice
- // se è stato un affare.
- Assert.Equal(299, header.GetProperty("product").GetProperty("buyNowPrice").GetDouble());
-
- // E con che anticipo la si stava seguendo: due aste con anticipi diversi non sono
- // confrontabili fra loro.
- Assert.Equal(800, header.GetProperty("config").GetProperty("bidBeforeDeadlineMs").GetInt32());
-
- AuctionDossier.Abandon(auction.AuctionId, "fine test");
- }
-
- // ── Coalescenza dei poll ─────────────────────────────────────────────
- //
- // Con ~17 interrogazioni al secondo per asta, su 40 dossier veri l'88,8% dei poll
- // aveva la stessa faccia del precedente. Si scrive solo quando cambia qualcosa di
- // visibile: la profondita' di ogni ciclo resta ricostruibile, il file pesa un decimo.
-
- private static int Conta(AuctionDossier dossier, string tipo) =>
- LinesOf(dossier).Count(l => l.Contains($"\"type\":\"{tipo}\"", StringComparison.Ordinal));
-
- [Fact]
- public void I_poll_identici_di_fila_si_scrivono_una_volta_sola()
- {
- var auction = NewAuction();
- var dossier = AuctionDossier.OpenFor(auction, Settings())!;
-
- for (var i = 0; i < 10; i++) dossier.Poll(State(1.24, 15.6), includeRaw: true);
- Assert.Equal(1, Conta(dossier, "poll"));
-
- // Cambia il secondo sul cronometro: si scrive, perche' e' da qui che si legge
- // fin dove e' sceso il timer.
- dossier.Poll(State(1.24, 14.9), includeRaw: true);
- Assert.Equal(2, Conta(dossier, "poll"));
-
- // Cambia il prezzo: si scrive.
- dossier.Poll(State(1.25, 15.0), includeRaw: true);
- Assert.Equal(3, Conta(dossier, "poll"));
-
- AuctionDossier.Abandon(auction.AuctionId, "fine test");
- }
-
- [Fact]
- public void Il_reset_porta_il_minimo_del_ciclo_chiuso_e_la_sua_durata()
- {
- var auction = NewAuction();
- var dossier = AuctionDossier.OpenFor(auction, Settings())!;
-
- dossier.Poll(State(1.24, 8.0), includeRaw: true);
- dossier.Poll(State(1.24, 3.4), includeRaw: true);
- dossier.Poll(State(1.24, 1.2), includeRaw: true);
- dossier.Poll(State(1.25, 8.0), includeRaw: true); // il prezzo sale: ciclo chiuso
- dossier.Reset(1, 1.25, "marco_82");
-
- var reset = LinesOf(dossier).Single(l => l.Contains("\"type\":\"reset\"", StringComparison.Ordinal));
- var r = JsonDocument.Parse(reset).RootElement;
-
- Assert.Equal(1.2, r.GetProperty("minTimer").GetDouble(), 3);
- Assert.True(r.GetProperty("cycleSeconds").GetDouble() >= 0);
-
- AuctionDossier.Abandon(auction.AuctionId, "fine test");
- }
-
- [Fact]
- public void Le_righe_RESET_del_registro_non_si_copiano_nel_dossier()
- {
- // Ripetevano parola per parola l'evento reset gia' scritto: una riga di log per
- // ogni reset, e nessuna diceva qualcosa in piu'.
- var auction = NewAuction();
- var dossier = AuctionDossier.OpenFor(auction, Settings())!;
-
- dossier.Log("info", "General", "[RESET #3] marco_82 → €1,25 | Timer: 8,0s");
- dossier.Log("info", "General", "qualcosa che invece conta");
-
- var logs = LinesOf(dossier).Where(l => l.Contains("\"type\":\"log\"", StringComparison.Ordinal)).ToArray();
-
- Assert.Single(logs);
- Assert.Contains("qualcosa che invece conta", logs[0]);
-
- AuctionDossier.Abandon(auction.AuctionId, "fine test");
- }
-
- [Fact]
- public void Ogni_riga_e_json_valido_per_conto_suo()
- {
- // È la ragione del formato: un file troncato a metà perde l'ultima riga, non tutto.
- var auction = NewAuction();
- var dossier = AuctionDossier.OpenFor(auction, Settings())!;
-
- dossier.Poll(State(1.24, 15), includeRaw: true);
- dossier.Reset(1, 1.25, "marco_82");
- dossier.MyBid(1.25, 800, 762.4, 68, success: true, error: null, bidsUsed: 4, remainingBids: 255);
- dossier.Log("info", "Status", "asta in corso");
-
- var lines = LinesOf(dossier);
-
- Assert.All(lines, line => JsonDocument.Parse(line));
- Assert.Equal(5, lines.Length); // intestazione + quattro eventi
-
- AuctionDossier.Abandon(auction.AuctionId, "fine test");
- }
-
- [Fact]
- public void La_mia_puntata_registra_anticipo_voluto_e_ottenuto()
- {
- // È la coppia su cui si tara l'anticipo: da sola nessuna delle due dice nulla.
- var auction = NewAuction();
- var dossier = AuctionDossier.OpenFor(auction, Settings())!;
-
- dossier.MyBid(1.25, plannedLeadMs: 800, actualLeadMs: 762.4, pingMs: 68,
- success: true, error: null, bidsUsed: 4, remainingBids: 255);
-
- var bid = JsonDocument.Parse(LinesOf(dossier)[1]).RootElement;
-
- Assert.Equal("my_bid", bid.GetProperty("type").GetString());
- Assert.Equal(800, bid.GetProperty("plannedLeadMs").GetInt32());
- Assert.Equal(762.4, bid.GetProperty("actualLeadMs").GetDouble(), 1);
- Assert.Equal(-37.6, bid.GetProperty("leadErrorMs").GetDouble(), 1);
- Assert.Equal(68, bid.GetProperty("pingMs").GetInt32());
- Assert.Equal("ok", bid.GetProperty("result").GetString());
-
- AuctionDossier.Abandon(auction.AuctionId, "fine test");
- }
-
- [Fact]
- public void Senza_poll_grezzi_il_ping_si_misura_lo_stesso()
- {
- // Spegnere i poll grezzi riduce la dimensione del file, non la qualità delle
- // statistiche di rete: quelle si accumulano comunque.
- var auction = NewAuction();
- var dossier = AuctionDossier.OpenFor(auction, Settings(rawPolls: false))!;
-
- dossier.Poll(State(1.24, 15, ping: 60), includeRaw: false);
- dossier.Poll(State(1.30, 12, ping: 80), includeRaw: false);
-
- Assert.Single(LinesOf(dossier)); // solo l'intestazione
-
- AuctionDossier.Close(auction, Detail(auction), null);
-
- var summary = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
- var network = summary.GetProperty("network");
-
- Assert.Equal(2, network.GetProperty("samples").GetInt32());
- Assert.Equal(70, network.GetProperty("avgPingMs").GetDouble());
- Assert.Equal(60, network.GetProperty("minPingMs").GetInt32());
- Assert.Equal(80, network.GetProperty("maxPingMs").GetInt32());
- }
-
- [Fact]
- public void Il_riepilogo_dice_se_la_storia_e_completa()
- {
- var auction = NewAuction();
- auction.ObservedFromStart = true;
- auction.ObservedToEnd = true;
-
- var dossier = AuctionDossier.OpenFor(auction, Settings())!;
- AuctionDossier.Close(auction, Detail(auction), null);
-
- var summary = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
-
- Assert.Equal("summary", summary.GetProperty("type").GetString());
- Assert.True(summary.GetProperty("coverage").GetProperty("complete").GetBoolean());
-
- // Costo reale se avessi vinto: prezzo + puntate + spedizione.
- Assert.True(summary.GetProperty("value").GetProperty("totalCostIfWon").GetDouble() > 4.87);
- }
-
- [Fact]
- public void Dopo_il_riepilogo_non_si_scrive_piu_nulla()
- {
- // Una riga in coda al riepilogo farebbe dubitare di tutto ciò che c'è sopra.
- var auction = NewAuction();
- var dossier = AuctionDossier.OpenFor(auction, Settings())!;
-
- AuctionDossier.Close(auction, Detail(auction), null);
-
- var before = LinesOf(dossier).Length;
- dossier.Log("info", "Status", "questa riga non deve comparire");
-
- Assert.Equal(before, LinesOf(dossier).Length);
- }
-
- [Fact]
- public void Unasta_tolta_prima_della_fine_lo_dichiara()
- {
- // Una serie di prezzi che si interrompe sembra un'asta finita a quel prezzo: va
- // detto che i dati sono incompleti.
- var auction = NewAuction();
- var dossier = AuctionDossier.OpenFor(auction, Settings())!;
-
- AuctionDossier.Abandon(auction.AuctionId, "rimossa dal monitor");
-
- var last = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
-
- Assert.Equal("abandoned", last.GetProperty("type").GetString());
- Assert.Contains("incompleti", last.GetProperty("note").GetString());
- }
-
- [Fact]
- public void Riaprire_la_stessa_asta_riprende_lo_stesso_file()
- {
- // Un riavvio a metà asta non deve spezzare la storia in due file.
- var auction = NewAuction();
-
- var first = AuctionDossier.OpenFor(auction, Settings())!;
- var path = first.Path;
-
- AuctionDossier.Abandon(auction.AuctionId, "simulazione riavvio");
-
- var second = AuctionDossier.OpenFor(auction, Settings())!;
-
- Assert.Equal(path, second.Path);
-
- var lines = LinesOf(second);
- Assert.Equal("resumed", JsonDocument.Parse(lines[^1]).RootElement.GetProperty("type").GetString());
-
- AuctionDossier.Abandon(auction.AuctionId, "fine test");
- }
-
- [Fact]
- public void Con_i_dossier_spenti_non_si_scrive_nessun_file()
- {
- var auction = NewAuction();
-
- Assert.Null(AuctionDossier.OpenFor(auction, new AppSettings { WriteAuctionDossiers = false }));
- }
-}
diff --git a/Mimante/Tests/AuctionRecorderTests.cs b/Mimante/Tests/AuctionRecorderTests.cs
new file mode 100644
index 0000000..186e066
--- /dev/null
+++ b/Mimante/Tests/AuctionRecorderTests.cs
@@ -0,0 +1,254 @@
+using System;
+using System.Linq;
+using AutoBidder.Data;
+using AutoBidder.Models;
+using AutoBidder.Utilities;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+///
+/// Il registratore è l'unico posto in cui certi dati esistono: Bidoo non espone né i
+/// tempi delle puntate né l'andamento del prezzo di un'asta conclusa. Le proprietà da
+/// difendere: l'apertura crea la riga dell'asta e del prodotto, ogni evento finisce
+/// nella sua tabella, il riepilogo chiude la riga, l'abbandono la marca, e le
+/// interrogazioni uguali non si scrivono due volte.
+///
+[Collection("Database")]
+public class AuctionRecorderTests
+{
+ private static AuctionDatabase Db => AuctionDatabase.Instance;
+
+ private static AppSettings Settings(bool rawPolls = true) => new()
+ {
+ WriteAuctionDossiers = true,
+ DossierIncludeRawPolls = rawPolls
+ };
+
+ private static AuctionInfo NewAuction() => new()
+ {
+ AuctionId = "rec-" + Guid.NewGuid().ToString("N")[..10],
+ Name = "Cuffie Sony WH-1000XM5",
+ BidBeforeDeadlineMs = 800,
+ BuyNowPrice = 299,
+ ShippingCost = 6.90
+ };
+
+ private static AuctionState State(double price, double timer, int ping = 70, string bidder = "marco_82") => new()
+ {
+ Price = price,
+ Timer = timer,
+ PollingLatencyMs = ping,
+ Status = AuctionStatus.Running,
+ LastBidder = bidder,
+ ExpiryUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (long)timer,
+ ServerUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
+ };
+
+ private static AuctionDetailRecord Detail(AuctionInfo auction) => new()
+ {
+ AuctionId = auction.AuctionId,
+ Name = auction.Name,
+ ProductKey = ProductKeyHelper.GenerateProductKey(auction.Name),
+ Outcome = "Persa",
+ Winner = "marco_82",
+ FinalPrice = 4.87,
+ BuyNowPrice = auction.BuyNowPrice,
+ ShippingCost = auction.ShippingCost,
+ MyBids = 9,
+ EndedAt = DateTime.Now,
+ FirstSeenAt = DateTime.Now.AddMinutes(-35),
+ ObservedMinutes = 35,
+ ConfiguredLeadMs = 800,
+ DistinctBidders = 2,
+ TotalObservedBids = 12,
+ Resets = 11
+ };
+
+ [Fact]
+ public void Aprire_crea_la_riga_dellasta_e_del_prodotto()
+ {
+ var auction = NewAuction();
+ var rec = AuctionRecorder.OpenFor(auction, Settings())!;
+ Db.Flush();
+
+ var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auction.AuctionId)!;
+ Assert.Equal("open", row.Str("status"));
+ Assert.Equal(auction.Name, row.Str("name"));
+ Assert.Equal(299, row.Dbl("buy_now_price"));
+ Assert.Equal(800, row.Int("configured_lead_ms"));
+ Assert.True(row.Long("item_id") > 0);
+
+ var item = Db.QueryOne("SELECT * FROM items WHERE item_id = ?1", row.Long("item_id"))!;
+ Assert.Equal(ProductKeyHelper.GenerateProductKey(auction.Name), item.Str("product_key"));
+
+ AuctionRecorder.Abandon(auction.AuctionId, "fine test");
+ }
+
+ [Fact]
+ public void Ogni_evento_finisce_nella_sua_tabella()
+ {
+ var auction = NewAuction();
+ var rec = AuctionRecorder.OpenFor(auction, Settings())!;
+
+ rec.Poll(State(1.24, 15), includeRaw: true);
+ rec.ForeignBid(new BidHistoryEntry { Username = "marco_82", Price = 1.25m, BidType = "Manuale", Timestamp = 1_700_000_000 }, 1.25);
+ rec.Reset(1, 1.25, "marco_82");
+ rec.MyBid(1.26, 800, 762.4, 68, success: true, error: null, bidsUsed: 4, remainingBids: 255, rttMs: 90);
+ rec.Log("info", "Status", "asta in corso");
+ Db.Flush();
+
+ Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM polls WHERE auction_id = ?1", auction.AuctionId));
+ Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM bids WHERE auction_id = ?1", auction.AuctionId));
+ Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM resets WHERE auction_id = ?1", auction.AuctionId));
+ Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM my_bids WHERE auction_id = ?1", auction.AuctionId));
+ Assert.True(Db.ScalarLong("SELECT COUNT(*) AS n FROM auction_log WHERE auction_id = ?1", auction.AuctionId) >= 2);
+
+ var bid = Db.QueryOne("SELECT * FROM bids WHERE auction_id = ?1", auction.AuctionId)!;
+ Assert.Equal("Manuale", bid.Str("bid_type"));
+ Assert.Equal(1_700_000_000, bid.Long("server_ts"));
+ Assert.Equal(15, bid.Dbl("timer_before"));
+
+ var mine = Db.QueryOne("SELECT * FROM my_bids WHERE auction_id = ?1", auction.AuctionId)!;
+ Assert.Equal(800, mine.Int("planned_lead_ms"));
+ Assert.Equal(762.4, mine.Dbl("actual_lead_ms"), 1);
+ Assert.Equal(90, mine.Int("rtt_ms"));
+
+ AuctionRecorder.Abandon(auction.AuctionId, "fine test");
+ }
+
+ [Fact]
+ public void Una_puntata_ricostruita_non_sovrascrive_il_tipo_vero()
+ {
+ var auction = NewAuction();
+ var rec = AuctionRecorder.OpenFor(auction, Settings())!;
+
+ rec.ForeignBid(new BidHistoryEntry { Username = "anna", Price = 2.00m, BidType = "Auto", Timestamp = 1_700_000_100 }, 2.00);
+ rec.ForeignBid(new BidHistoryEntry { Username = "anna", Price = 2.00m, BidType = BidHistoryEntry.TipoSconosciuto }, 2.00);
+ Db.Flush();
+
+ var rows = Db.Query("SELECT * FROM bids WHERE auction_id = ?1", auction.AuctionId);
+ Assert.Single(rows);
+ Assert.Equal("Auto", rows[0].Str("bid_type"));
+
+ AuctionRecorder.Abandon(auction.AuctionId, "fine test");
+ }
+
+ [Fact]
+ public void Le_interrogazioni_uguali_non_si_scrivono_due_volte()
+ {
+ var auction = NewAuction();
+ var rec = AuctionRecorder.OpenFor(auction, Settings())!;
+
+ for (var i = 0; i < 10; i++) rec.Poll(State(1.24, 15.9 - i * 0.05), includeRaw: true);
+ rec.Poll(State(1.25, 15), includeRaw: true); // cambia il prezzo: si scrive
+ Db.Flush();
+
+ Assert.Equal(2, Db.ScalarLong("SELECT COUNT(*) AS n FROM polls WHERE auction_id = ?1", auction.AuctionId));
+
+ AuctionRecorder.Abandon(auction.AuctionId, "fine test");
+ }
+
+ [Fact]
+ public void Il_riepilogo_chiude_la_riga_e_aggrega_i_puntatori()
+ {
+ var auction = NewAuction();
+ var rec = AuctionRecorder.OpenFor(auction, Settings())!;
+
+ rec.ForeignBid(new BidHistoryEntry { Username = "marco_82", Price = 1.25m, BidType = "Manuale", Timestamp = 1_700_000_000 }, 1.25);
+ rec.ForeignBid(new BidHistoryEntry { Username = "anna", Price = 1.26m, BidType = "Auto", Timestamp = 1_700_000_006 }, 1.26);
+ rec.ForeignBid(new BidHistoryEntry { Username = "marco_82", Price = 1.27m, BidType = "Manuale", Timestamp = 1_700_000_012 }, 1.27);
+
+ AuctionRecorder.Close(auction, Detail(auction), State(4.87, 0));
+ Db.Flush();
+
+ var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auction.AuctionId)!;
+ Assert.Equal("closed", row.Str("status"));
+ Assert.Equal("Persa", row.Str("outcome"));
+ Assert.Equal(4.87, row.Dbl("final_price"));
+ Assert.Equal(9, row.Int("my_bids"));
+ Assert.Null(AuctionRecorder.For(auction.AuctionId));
+
+ var marco = Db.QueryOne("SELECT * FROM bidders WHERE username = 'marco_82'")!;
+ Assert.True(marco.Int("n_bids") >= 2);
+ Assert.True(marco.Int("n_manual") >= 2);
+ Assert.True(marco.Int("win_count") >= 1);
+
+ // Lo storico compatto vede la stessa riga.
+ var record = CompletedAuctionsStore.LoadAll().FirstOrDefault(r => r.AuctionId == auction.AuctionId);
+ Assert.NotNull(record);
+ Assert.Equal("Persa", record!.Outcome);
+
+ // E la scheda dettagliata pure.
+ var detail = AuctionDetailStore.Load(auction.AuctionId);
+ Assert.NotNull(detail);
+ Assert.Equal(2, detail!.DistinctBidders);
+ }
+
+ [Fact]
+ public void Le_decisioni_ricevono_lesito_alla_chiusura()
+ {
+ var auction = NewAuction();
+ var rec = AuctionRecorder.OpenFor(auction, Settings())!;
+
+ // Shadow: avrei puntato a 4.86 e nessuno ha più puntato -> avrei vinto.
+ var vincente = AuctionRecorder.RecordDecision(auction, State(4.86, 0.5), "BID", 0.4, 0.5, "ev positivo", "shadow", "Calmo", "ev-regime", 500);
+ // Shadow: non avrei puntato a 3.00 e poi il prezzo è salito -> giusto non puntare.
+ var giusta = AuctionRecorder.RecordDecision(auction, State(3.00, 0.5), "NO-OP", 0.01, -0.1, "ev negativo", "shadow", "Sfogo", "ev-regime", 500);
+ // Live: ho puntato a 4.85 (prezzo dopo la mia puntata 4.86) e nessuno ha rilanciato -> vinta.
+ var coperta = AuctionRecorder.RecordDecision(auction, State(4.85, 0.5), "BID", 0.4, 0.5, "ev positivo", "live", "Calmo", "ev-regime", 500);
+ rec.MyBid(4.85, 500, 480, 60, success: true, error: null, bidsUsed: 1, remainingBids: 10, rttMs: 80, decisionId: coperta);
+
+ var detail = Detail(auction);
+ detail.FinalPrice = 4.86;
+ AuctionRecorder.Close(auction, detail, State(4.86, 0));
+ Db.Flush();
+
+ Assert.Equal("would_win", Db.ScalarString("SELECT outcome FROM bot_decisions WHERE decision_id = ?1", vincente));
+ Assert.Equal("right_skip", Db.ScalarString("SELECT outcome FROM bot_decisions WHERE decision_id = ?1", giusta));
+ Assert.Equal("won", Db.ScalarString("SELECT outcome FROM bot_decisions WHERE decision_id = ?1", coperta));
+ Assert.Equal(1, Db.ScalarLong("SELECT executed FROM bot_decisions WHERE decision_id = ?1", coperta));
+ }
+
+ [Fact]
+ public void Labbandono_marca_la_riga_senza_riepilogo()
+ {
+ var auction = NewAuction();
+ AuctionRecorder.OpenFor(auction, Settings());
+ AuctionRecorder.Abandon(auction.AuctionId, "rimossa dal monitor");
+ Db.Flush();
+
+ var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auction.AuctionId)!;
+ Assert.Equal("abandoned", row.Str("status"));
+ Assert.Null(row.StrOrNull("outcome"));
+ Assert.Equal("rimossa dal monitor", row.Str("abandon_reason"));
+
+ // Non è nello storico delle concluse.
+ Assert.DoesNotContain(CompletedAuctionsStore.LoadAll(), r => r.AuctionId == auction.AuctionId);
+ }
+
+ [Fact]
+ public void Riaprire_la_stessa_asta_riusa_la_riga()
+ {
+ var auction = NewAuction();
+ var first = AuctionRecorder.OpenFor(auction, Settings())!;
+ first.Poll(State(1.00, 10), includeRaw: true);
+ AuctionRecorder.Abandon(auction.AuctionId, "simulazione riavvio");
+
+ var second = AuctionRecorder.OpenFor(auction, Settings())!;
+ second.Poll(State(1.01, 10), includeRaw: true);
+ Db.Flush();
+
+ Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM auctions WHERE auction_id = ?1", auction.AuctionId));
+ Assert.Equal(2, Db.ScalarLong("SELECT COUNT(*) AS n FROM polls WHERE auction_id = ?1", auction.AuctionId));
+
+ AuctionRecorder.Abandon(auction.AuctionId, "fine test");
+ }
+
+ [Fact]
+ public void Con_la_registrazione_spenta_non_si_apre_nulla()
+ {
+ var auction = NewAuction();
+ Assert.Null(AuctionRecorder.OpenFor(auction, new AppSettings { WriteAuctionDossiers = false }));
+ }
+}
diff --git a/Mimante/Tests/CompletedAuctionsStoreTests.cs b/Mimante/Tests/CompletedAuctionsStoreTests.cs
index fadd992..3cd5f8f 100644
--- a/Mimante/Tests/CompletedAuctionsStoreTests.cs
+++ b/Mimante/Tests/CompletedAuctionsStoreTests.cs
@@ -18,6 +18,7 @@ namespace AutoBidder.Tests;
/// AppendMany deve dare lo stesso risultato di tanti Append, e la copia in memoria non deve
/// mai servire un contenuto che sul disco non c'e' piu'.
///
+[Collection("Database")]
public class CompletedAuctionsStoreTests
{
private static CompletedAuctionRecord R(string id, double price = 1.00, string? nome = null) =>
@@ -102,18 +103,19 @@ public class CompletedAuctionsStoreTests
}
[Fact]
- public void La_copia_in_memoria_si_accorge_se_il_file_cambia_da_fuori()
+ public void La_copia_in_memoria_si_accorge_delle_scritture_fatte_da_altri()
{
Svuota();
CompletedAuctionsStore.Append(R("uno"));
Assert.Single(CompletedAuctionsStore.LoadAll());
- // Ripristino di un backup o modifica a mano: la copia non deve continuare a
- // servire il contenuto vecchio. La data si forza per non dipendere dalla
- // granularita' con cui il file system aggiorna l'ora di scrittura.
- File.WriteAllText(CompletedAuctionsStore.StorePath,
- JsonSerializer.Serialize(new List { R("due"), R("tre") }));
- File.SetLastWriteTimeUtc(CompletedAuctionsStore.StorePath, DateTime.UtcNow.AddSeconds(5));
+ // Una scrittura che non passa da questo archivio (il registratore, una
+ // riparazione): la copia non deve continuare a servire il contenuto vecchio.
+ var db = AutoBidder.Data.AuctionDatabase.Instance;
+ db.ExecuteNow("DELETE FROM auctions WHERE auction_id = 'uno'");
+ var now = AutoBidder.Data.AuctionDatabase.Now();
+ db.ExecuteNow("INSERT INTO auctions(auction_id, name, added_at, first_seen_at, opened_at, end_time, outcome, status) VALUES('due', 'Asta due', ?1, ?1, ?1, ?1, 'Persa', 'closed')", now);
+ db.ExecuteNow("INSERT INTO auctions(auction_id, name, added_at, first_seen_at, opened_at, end_time, outcome, status) VALUES('tre', 'Asta tre', ?1, ?1, ?1, ?1, 'Persa', 'closed')", now);
var dopo = CompletedAuctionsStore.LoadAll();
diff --git a/Mimante/Tests/DossierSummaryTests.cs b/Mimante/Tests/DossierSummaryTests.cs
deleted file mode 100644
index 2402275..0000000
--- a/Mimante/Tests/DossierSummaryTests.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-using System;
-using System.IO;
-using System.Linq;
-using AutoBidder.Utilities;
-using Xunit;
-
-namespace AutoBidder.Tests;
-
-///
-/// La scheda dettagliata di un'asta si legge dal dossier: intestazione in testa e
-/// riepilogo in coda, senza scorrere il resto. Un dossier senza riepilogo non e' una
-/// scheda: e' un'asta ancora in corso o interrotta, e non deve entrare nelle statistiche.
-///
-public class DossierSummaryTests
-{
- private const string Header =
- """{"type":"header","schema":"autobidder.auction.v1","auctionId":"12345","me":"io","name":"Buono 10","productKey":"buono 10","url":"https://it.bidoo.com/x","addedAt":"2026-09-01T10:00:00+02:00","product":{"buyNowPrice":11,"bidCostEuro":0.2}}""";
-
- private const string Summary =
- """{"type":"summary","closedAt":"10:30:00.000","endedAt":"2026-09-01T10:30:00+02:00","outcome":"Vinta","wonByMe":true,"winner":"io","finalPrice":0.42,"coverage":{"observedFromStart":true,"observedToEnd":true,"complete":true,"firstSeenAt":"2026-09-01T10:00:00+02:00","observedMinutes":30.5},"participation":{"myBids":3,"totalObservedBids":42,"distinctBidders":4,"resets":41,"topBidderShare":0.5,"bidsByUser":{"io":3,"tizio":21,"caio":18}},"value":{"buyNowPrice":11,"shippingCost":1.5,"bidCostEuro":0.2},"network":{"avgPingMs":61,"polls":1200,"pollErrors":2},"engine":{"configuredLeadMs":500,"timerExpiredCount":1,"successfulBids":3,"failedBids":0},"priceSeries":[{"t":0,"price":0.01},{"t":1800,"price":0.42}],"priceVelocityPerMinute":0.0138}""";
-
- private static string Temp(params string[] lines)
- {
- var path = Path.Combine(Path.GetTempPath(), $"dossier-{Guid.NewGuid():N}.jsonl");
- File.WriteAllText(path, string.Join("\n", lines) + "\n");
- return path;
- }
-
- [Fact]
- public void Ricompone_la_scheda_da_intestazione_e_riepilogo()
- {
- var path = Temp(Header, """{"t":1,"type":"poll","price":0.01}""", Summary);
- try
- {
- var r = AuctionDetailStore.ReadDossier(path);
-
- Assert.NotNull(r);
- Assert.Equal("12345", r!.AuctionId);
- Assert.Equal("Buono 10", r.Name);
- Assert.Equal("buono 10", r.ProductKey);
- Assert.True(r.WonByMe);
- Assert.Equal(0.42, r.FinalPrice, 3);
- Assert.Equal(11, r.BuyNowPrice);
- Assert.Equal(1.5, r.ShippingCost);
- Assert.Equal(3, r.MyBids);
- Assert.Equal(42, r.TotalObservedBids);
- Assert.Equal(4, r.DistinctBidders);
- Assert.Equal(41, r.Resets);
- Assert.Equal(21, r.BidsByUser["tizio"]);
- Assert.True(r.IsComplete);
- Assert.Equal(30.5, r.ObservedMinutes, 2);
- Assert.Equal(61, r.AveragePingMs);
- Assert.Equal(1200, r.PollCount);
- Assert.Equal(500, r.ConfiguredLeadMs);
- Assert.Equal(2, r.PriceSeries.Count);
- Assert.Equal(path, r.DossierPath);
- Assert.Equal(2026, r.EndedAt.Year);
- Assert.Equal(9, r.EndedAt.Month);
- }
- finally { File.Delete(path); }
- }
-
- [Fact]
- public void Un_dossier_senza_riepilogo_non_e_una_scheda()
- {
- var path = Temp(Header, """{"t":1,"type":"poll","price":0.01}""", """{"t":2,"type":"reset","price":0.02}""");
- try
- {
- Assert.Null(AuctionDetailStore.ReadDossier(path));
- }
- finally { File.Delete(path); }
- }
-
- [Fact]
- public void Legge_solo_la_coda_anche_su_un_file_grande()
- {
- // Un dossier vero pesa decine di megabyte: qui un megabyte di poll finti in mezzo.
- var filler = new string[20_000];
- for (var i = 0; i < filler.Length; i++)
- filler[i] = $$$"""{"t":{{{i}}},"type":"poll","price":0.01,"timer":9.5,"bidder":"tizio","status":"Running"}""";
-
- var path = Temp(new[] { Header }.Concat(filler).Append(Summary).ToArray());
- try
- {
- var r = AuctionDetailStore.ReadDossier(path);
- Assert.NotNull(r);
- Assert.Equal(42, r!.TotalObservedBids);
- }
- finally { File.Delete(path); }
- }
-
- [Fact]
- public void Un_file_che_non_e_un_dossier_da_null_senza_eccezioni()
- {
- var path = Temp("questo non e' json", "{}");
- try
- {
- Assert.Null(AuctionDetailStore.ReadDossier(path));
- }
- finally { File.Delete(path); }
- }
-}
diff --git a/Mimante/Tests/MlModelBacktest.cs b/Mimante/Tests/MlModelBacktest.cs
index 64aef5d..61b4116 100644
--- a/Mimante/Tests/MlModelBacktest.cs
+++ b/Mimante/Tests/MlModelBacktest.cs
@@ -12,8 +12,8 @@ namespace AutoBidder.Tests;
/// variabili d'ambiente e la condizione minima.
///
///
-/// AUTOBIDDER_ML_DIR — cartella dei dossier;
-/// AUTOBIDDER_ML_MAX — quanti file al massimo (0 = tutti);
+/// AUTOBIDDER_ML_DB — file del database SQLite;
+/// AUTOBIDDER_ML_MAX — quante aste al massimo (0 = tutte);
/// AUTOBIDDER_ML_OUT — dove scrivere il rapporto;
/// AUTOBIDDER_ML_ME — il nostro nome utente, per i dossier vecchi.
///
@@ -25,20 +25,21 @@ public class MlModelBacktest
public MlModelBacktest(ITestOutputHelper output) => _output = output;
[Fact]
- public void Valuta_il_modello_sui_dossier_reali()
+ public void Valuta_il_modello_sulle_aste_reali()
{
- var folder = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_DIR");
+ var dbPath = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_DB");
- if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
+ if (string.IsNullOrWhiteSpace(dbPath) || !File.Exists(dbPath))
{
- _output.WriteLine("AUTOBIDDER_ML_DIR non impostata o inesistente: valutazione saltata.");
+ _output.WriteLine("AUTOBIDDER_ML_DB non impostata o inesistente: valutazione saltata.");
return;
}
var max = int.TryParse(Environment.GetEnvironmentVariable("AUTOBIDDER_ML_MAX"), out var m) ? m : 0;
var me = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_ME");
- var report = LearningEvaluator.Run(folder, max, me, _output.WriteLine);
+ using var db = AutoBidder.Data.AuctionDatabase.Open(dbPath);
+ var report = LearningEvaluator.Run(db, max, me, _output.WriteLine);
_output.WriteLine(report.Text);
var outFile = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_OUT");
diff --git a/Mimante/Tests/MlModelsTests.cs b/Mimante/Tests/MlModelsTests.cs
new file mode 100644
index 0000000..44ac405
--- /dev/null
+++ b/Mimante/Tests/MlModelsTests.cs
@@ -0,0 +1,225 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutoBidder.Ml;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+///
+/// La rete neurale impara una funzione che un modello lineare non può (XOR), regredisce
+/// una funzione continua, si salva e si ricarica identica.
+///
+public class NeuralNetTests
+{
+ [Fact]
+ public void Impara_lo_xor()
+ {
+ var net = new NeuralNet(new[] { 2, 8, 1 }, seed: 3) { LearningRate = 0.02 };
+ var data = new[]
+ {
+ (new[] { 0.0, 0.0 }, false), (new[] { 0.0, 1.0 }, true),
+ (new[] { 1.0, 0.0 }, true), (new[] { 1.0, 1.0 }, false)
+ };
+ var rng = new Random(1);
+ for (var i = 0; i < 6000; i++)
+ {
+ var (x, y) = data[rng.Next(4)];
+ net.Update(x, y);
+ }
+
+ Assert.True(net.Predict(new[] { 0.0, 0.0 }) < 0.2);
+ Assert.True(net.Predict(new[] { 0.0, 1.0 }) > 0.8);
+ Assert.True(net.Predict(new[] { 1.0, 0.0 }) > 0.8);
+ Assert.True(net.Predict(new[] { 1.0, 1.0 }) < 0.2);
+ }
+
+ [Fact]
+ public void Regredisce_una_funzione_continua()
+ {
+ var net = new NeuralNet(new[] { 1, 16, 1 }, seed: 5) { LearningRate = 0.005 };
+ var rng = new Random(2);
+ for (var i = 0; i < 8000; i++)
+ {
+ var x = rng.NextDouble() * 2 - 1;
+ net.UpdateRegression(new[] { x }, new[] { x * x });
+ }
+
+ var err = 0.0;
+ for (var x = -1.0; x <= 1.0; x += 0.1) err += Math.Abs(net.Forward(new[] { x })[0] - x * x);
+ Assert.True(err / 21 < 0.05, $"errore medio {err / 21:F3}");
+ }
+
+ [Fact]
+ public void Si_salva_e_si_ricarica_identica()
+ {
+ var net = new NeuralNet(new[] { 3, 4, 1 }, seed: 9);
+ for (var i = 0; i < 100; i++) net.Update(new[] { 0.1 * i, 0.5, 1.0 }, i % 3 == 0);
+
+ var back = NeuralNet.FromJson(net.ToJson(), new[] { 3, 4, 1 });
+
+ Assert.Equal(net.Updates, back.Updates);
+ Assert.Equal(net.Predict(new[] { 0.3, 0.5, 1.0 }), back.Predict(new[] { 0.3, 0.5, 1.0 }), 12);
+ }
+
+ [Fact]
+ public void Una_forma_diversa_riparte_da_capo_senza_eccezioni()
+ {
+ var net = new NeuralNet(new[] { 3, 4, 1 });
+ var back = NeuralNet.FromJson(net.ToJson(), new[] { 5, 4, 1 });
+ Assert.Equal(0, back.Updates);
+ Assert.Equal(5, back.InputSize);
+ }
+
+ [Fact]
+ public void Aggiornare_una_sola_uscita_non_tocca_le_altre_in_modo_diretto()
+ {
+ var net = new NeuralNet(new[] { 2, 4, 2 }, seed: 11) { LearningRate = 0.01 };
+ var x = new[] { 0.5, -0.5 };
+ for (var i = 0; i < 500; i++) net.UpdateOutput(x, 1, 3.0);
+
+ var q = net.Forward(x);
+ Assert.True(Math.Abs(q[1] - 3.0) < 0.3, $"q1 = {q[1]:F2}");
+ }
+}
+
+///
+/// Il bandit: parte dal prior, converge all'evidenza, esplora dove ha visto poco, e
+/// decide con la regola del valore atteso sul p estratto.
+///
+public class ThompsonBanditTests
+{
+ private static BidContext Ctx(double gap = 5, double ratio = 0.05, int hour = 12, int bidders = 2) =>
+ new(gap, 0.3, bidders, ratio, hour, DayOfWeek.Monday, 10, false, 1, 50, false, "p");
+
+ [Fact]
+ public void Senza_osservazioni_la_media_e_il_prior()
+ {
+ var b = new ThompsonBandit { BaseRate = 0.05 };
+ Assert.Equal(0.05, b.Mean("qualunque"), 6);
+ Assert.Equal(0, b.Evidence("qualunque"), 6);
+ }
+
+ [Fact]
+ public void Con_le_osservazioni_la_media_si_sposta_verso_la_frequenza()
+ {
+ var b = new ThompsonBandit { BaseRate = 0.05, PriorStrength = 4 };
+ var key = ThompsonBandit.KeyOf(Ctx());
+ for (var i = 0; i < 100; i++) b.Observe(key, i % 2 == 0);
+
+ Assert.True(Math.Abs(b.Mean(key) - 0.5) < 0.05);
+ Assert.Equal(100, b.Evidence(key), 6);
+ }
+
+ [Fact]
+ public void Lestrazione_varia_dove_si_e_visto_poco_e_si_stringe_con_i_dati()
+ {
+ var b = new ThompsonBandit { BaseRate = 0.3, PriorStrength = 2 };
+ var rng = new Random(4);
+ var poco = Enumerable.Range(0, 400).Select(_ => b.Sample("poco", rng)).ToList();
+
+ for (var i = 0; i < 2000; i++) b.Observe("tanto", i % 10 < 3);
+ var tanto = Enumerable.Range(0, 400).Select(_ => b.Sample("tanto", rng)).ToList();
+
+ Assert.True(Std(poco) > 3 * Std(tanto), $"poco {Std(poco):F3} vs tanto {Std(tanto):F3}");
+ Assert.True(Math.Abs(tanto.Average() - 0.3) < 0.03);
+ }
+
+ [Fact]
+ public void Decide_con_il_valore_atteso_sul_p_estratto()
+ {
+ var b = new ThompsonBandit { BaseRate = 0.5, PriorStrength = 1000 }; // praticamente fisso a 0,5
+ var rng = new Random(1);
+
+ var conviene = new Theory.BidSituation(0, 11, 0.50, 0.20, 1.0, 0, null, 0, false);
+ var (bid, p) = b.Decide("k", conviene, rng);
+ Assert.True(bid);
+ Assert.True(Math.Abs(p - 0.5) < 0.1);
+
+ var nonConviene = conviene with { Value = 1.0, CurrentPrice = 0.95 };
+ Assert.False(b.Decide("k", nonConviene, rng).Bid);
+ }
+
+ [Fact]
+ public void Si_salva_e_si_ricarica()
+ {
+ var b = new ThompsonBandit { BaseRate = 0.1 };
+ for (var i = 0; i < 20; i++) b.Observe("k", i % 4 == 0);
+ var back = ThompsonBandit.FromJson(b.ToJson());
+ Assert.Equal(b.Mean("k"), back.Mean("k"), 9);
+ Assert.Equal(20, back.Observations);
+ }
+
+ private static double Std(List xs)
+ {
+ var m = xs.Average();
+ return Math.Sqrt(xs.Average(x => (x - m) * (x - m)));
+ }
+}
+
+///
+/// Il simulatore produce aste con la forma giusta — finiscono, il prezzo in centesimi è
+/// il numero di puntate, chi non punta mai non vince mai, chi punta sempre spende — e
+/// l'agente Q impara a non buttare via soldi.
+///
+public class SimulatorTests
+{
+ private static PennyAuctionEnv.Calibration Cal() => new() { Value = 11, BidCost = 0.20, MeanBidders = 3, MedianFinalPriceRatio = 0.05 };
+
+ [Fact]
+ public void Le_aste_finiscono_e_il_prezzo_conta_le_puntate()
+ {
+ var env = new PennyAuctionEnv(Cal(), seed: 3);
+ var obs = env.Reset();
+ var steps = 0;
+ PennyAuctionEnv.StepResult r;
+ do { r = env.Step(null); steps++; } while (!r.Done && steps < 5000);
+
+ Assert.True(r.Done);
+ Assert.False(r.Won);
+ Assert.Equal(r.Next.TotalBids, (int)Math.Round(r.Next.Price * 100));
+ }
+
+ [Fact]
+ public void Chi_non_punta_mai_non_vince_mai_e_non_spende()
+ {
+ var mai = QLearningAgent.Evaluate("mai", Cal(), (in PennyAuctionEnv.Observation _) => null, 200);
+ Assert.Equal(0, mai.Wins);
+ Assert.Equal(0, mai.Spent);
+ }
+
+ [Fact]
+ public void Chi_punta_sempre_vince_qualcosa_ma_spende_molto()
+ {
+ var sempre = QLearningAgent.Evaluate("sempre", Cal(), (in PennyAuctionEnv.Observation o) => o.ImLeading ? null : 0.5, 200);
+ Assert.True(sempre.Wins > 0);
+ Assert.True(sempre.Bids > sempre.Wins);
+ Assert.True(sempre.Spent > 0);
+ }
+
+ [Fact]
+ public void Una_regola_prudente_spende_meno_di_chi_punta_sempre()
+ {
+ // Punta solo quando gli avversari attivi sono pochi e il prezzo è basso.
+ var prudente = QLearningAgent.Evaluate("prudente", Cal(),
+ (in PennyAuctionEnv.Observation o) => !o.ImLeading && o.ActiveOpponents <= 1 && o.PriceRatio < 0.3 ? 0.5 : null, 200);
+ var sempre = QLearningAgent.Evaluate("sempre", Cal(), (in PennyAuctionEnv.Observation o) => o.ImLeading ? null : 0.5, 200);
+
+ Assert.True(prudente.Spent < sempre.Spent);
+ }
+
+ [Fact]
+ public void Lagente_q_impara_a_fare_meglio_del_caso()
+ {
+ var cal = Cal();
+ var agent = new QLearningAgent(seed: 5);
+ for (var i = 0; i < 600; i++) agent.TrainEpisode(new PennyAuctionEnv(cal, 100 + i), 0.5);
+
+ var q = QLearningAgent.Evaluate("q", cal, (in PennyAuctionEnv.Observation o) => agent.Act(o) == 1 ? 0.5 : null, 300);
+ var caso = QLearningAgent.Evaluate("caso", cal, (in PennyAuctionEnv.Observation o) =>
+ new Random(o.TotalBids * 31 + o.MyBids).NextDouble() < 0.5 ? 0.5 : null, 300);
+
+ Assert.True(q.Net > caso.Net, $"q {q.Net:F2} vs caso {caso.Net:F2}");
+ Assert.True(agent.Episodes == 600);
+ }
+}
diff --git a/Mimante/Tests/RealDossierBacktest.cs b/Mimante/Tests/RealDossierBacktest.cs
index b3a6fce..a089b29 100644
--- a/Mimante/Tests/RealDossierBacktest.cs
+++ b/Mimante/Tests/RealDossierBacktest.cs
@@ -18,7 +18,7 @@ namespace AutoBidder.Tests;
///
/// Si lancia con backtest.ps1, che imposta le variabili qui sotto:
///
-/// AUTOBIDDER_BACKTEST_DIR — cartella dei dossier;
+/// AUTOBIDDER_BACKTEST_DB — file del database SQLite;
/// AUTOBIDDER_BACKTEST_MAX — quanti file al massimo (0 = tutti);
/// AUTOBIDDER_BACKTEST_OUT — dove scrivere il riepilogo.
///
@@ -39,11 +39,11 @@ public class RealDossierBacktest
[Fact]
public void Rigioca_i_dossier_reali()
{
- var folder = Environment.GetEnvironmentVariable("AUTOBIDDER_BACKTEST_DIR");
+ var dbPath = Environment.GetEnvironmentVariable("AUTOBIDDER_BACKTEST_DB");
- if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
+ if (string.IsNullOrWhiteSpace(dbPath) || !File.Exists(dbPath))
{
- _output.WriteLine("AUTOBIDDER_BACKTEST_DIR non impostata o inesistente: rigiocata saltata.");
+ _output.WriteLine("AUTOBIDDER_BACKTEST_DB non impostata o inesistente: rigiocata saltata.");
return;
}
@@ -53,7 +53,8 @@ public class RealDossierBacktest
// vuole mettere alla prova, non una configurazione inventata per l'occasione.
var settings = new AppSettings();
- var aggregates = BacktestReport.Run(folder, Leads, settings, username: "", maxFiles: max);
+ using var db = AutoBidder.Data.AuctionDatabase.Open(dbPath);
+ var aggregates = BacktestReport.Run(db, Leads, settings, username: "", maxAuctions: max);
var text = BacktestReport.Format(aggregates);
_output.WriteLine(text);
diff --git a/Mimante/Tests/RealDossierSummaries.cs b/Mimante/Tests/RealDossierSummaries.cs
deleted file mode 100644
index 5cf1e37..0000000
--- a/Mimante/Tests/RealDossierSummaries.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System;
-using System.Diagnostics;
-using System.IO;
-using System.Linq;
-using AutoBidder.Utilities;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace AutoBidder.Tests;
-
-///
-/// Legge le schede da una cartella vera di dossier, se indicata con
-/// AUTOBIDDER_DOSSIER_DIR. Serve a verificare due promesse: che il lettore ricomponga
-/// le schede da migliaia di file in pochi secondi (legge solo testa e coda), e che
-/// la seconda lettura, dalla cache, sia istantanea.
-///
-public class RealDossierSummaries
-{
- private readonly ITestOutputHelper _output;
-
- public RealDossierSummaries(ITestOutputHelper output) => _output = output;
-
- [Fact]
- public void Legge_le_schede_dai_dossier_veri()
- {
- var folder = Environment.GetEnvironmentVariable("AUTOBIDDER_DOSSIER_DIR");
- if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
- {
- _output.WriteLine("AUTOBIDDER_DOSSIER_DIR non impostata: prova saltata.");
- return;
- }
-
- var files = Directory.GetFiles(folder, "*.jsonl");
- var sw = Stopwatch.StartNew();
-
- var records = files
- .Select(AuctionDetailStore.ReadDossier)
- .Where(r => r != null)
- .Select(r => r!)
- .ToList();
-
- sw.Stop();
-
- _output.WriteLine($"{files.Length} dossier, {records.Count} con riepilogo, letti in {sw.Elapsed.TotalSeconds:F2} s");
- _output.WriteLine($"vinte da me: {records.Count(r => r.WonByMe)}, con mie puntate: {records.Count(r => r.MyBids > 0)}, complete: {records.Count(r => r.IsComplete)}");
- _output.WriteLine($"prima chiusura: {records.Min(r => r.EndedAt):yyyy-MM-dd}, ultima: {records.Max(r => r.EndedAt):yyyy-MM-dd}");
- _output.WriteLine($"senza prodotto: {records.Count(r => string.IsNullOrEmpty(r.ProductKey))}, senza valore: {records.Count(r => r.BuyNowPrice is null or 0)}, senza serie prezzi: {records.Count(r => r.PriceSeries.Count == 0)}");
-
- Assert.True(records.Count > 0);
- Assert.All(records, r => Assert.False(string.IsNullOrEmpty(r.AuctionId)));
- Assert.True(sw.Elapsed.TotalSeconds < 60, "la lettura di testa e coda non deve scorrere i file");
- }
-}
diff --git a/Mimante/Tests/RiskManagerTests.cs b/Mimante/Tests/RiskManagerTests.cs
new file mode 100644
index 0000000..c52d43e
--- /dev/null
+++ b/Mimante/Tests/RiskManagerTests.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using AutoBidder.Data;
+using AutoBidder.Models;
+using AutoBidder.Services;
+using AutoBidder.Utilities;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+///
+/// Il gestore del rischio: il kill-switch ferma tutto, lo stop-loss porta in HALT e
+/// l'HALT resta finché non lo si toglie a mano, il tetto del giorno conta le puntate
+/// del giorno, le aste in gioco insieme si contano davvero.
+///
+[Collection("Database")]
+public class RiskManagerTests
+{
+ private static AuctionInfo Auction(string id, RunState state = RunState.Active, int bids = 0) => new()
+ {
+ AuctionId = id, Name = "Prova", State = state, SessionBidCount = bids, BidCost = 0.20
+ };
+
+ 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()
+ {
+ Pulito();
+ var settings = new AppSettings { DailyStopLossEuro = 1.00 };
+
+ for (var i = 0; i < 4; i++) RiskManager.NoteBidSpent("a", 0.20);
+ Assert.True(RiskManager.Authorize(Auction("a"), settings).Allowed); // 0,80 sotto la soglia
+
+ // Con 1,00 € spesi la perdita netta eguaglia la soglia: scatta.
+ RiskManager.NoteBidSpent("a", 0.20);
+ var v = RiskManager.Authorize(Auction("a"), settings);
+ Assert.False(v.Allowed);
+ Assert.True(RiskManager.Halted);
+
+ // Anche senza limite impostato, l'HALT resta.
+ Assert.False(RiskManager.Authorize(Auction("a"), new AppSettings()).Allowed);
+
+ // Sopravvive a un «riavvio» della memoria: è nel database.
+ AuctionDatabase.Instance.Flush();
+ RiskManager.ResetForTests();
+ Assert.True(RiskManager.Halted);
+
+ RiskManager.Resume();
+ Assert.False(RiskManager.Halted);
+ Assert.True(RiskManager.Authorize(Auction("a"), new AppSettings()).Allowed);
+ }
+
+ [Fact]
+ public void Una_vincita_riduce_la_perdita_netta_del_giorno()
+ {
+ Pulito();
+ var settings = new AppSettings { DailyStopLossEuro = 1.00 };
+
+ for (var i = 0; i < 5; i++) RiskManager.NoteBidSpent("a", 0.20);
+ RiskManager.NoteWin("a", 3.00, "vinta");
+
+ Assert.True(RiskManager.Authorize(Auction("a"), settings).Allowed);
+ var s = RiskManager.Snapshot();
+ Assert.Equal(1.00, s.TodaySpent, 6);
+ Assert.Equal(3.00, s.TodayGained, 6);
+ Assert.Equal(2.00, s.Cumulative, 6);
+ }
+
+ [Fact]
+ public void Il_drawdown_si_misura_dal_picco()
+ {
+ Pulito();
+ var settings = new AppSettings { MaxDrawdownEuro = 0.50 };
+
+ RiskManager.NoteWin("a", 5.00, "vinta"); // picco 5.00
+ for (var i = 0; i < 2; i++) RiskManager.NoteBidSpent("b", 0.20); // 4.60: drawdown 0.40
+ Assert.True(RiskManager.Authorize(Auction("b"), settings).Allowed);
+
+ RiskManager.NoteBidSpent("b", 0.20); // 4.40: drawdown 0.60
+ Assert.False(RiskManager.Authorize(Auction("b"), settings).Allowed);
+ Assert.True(RiskManager.Halted);
+ RiskManager.Resume();
+ }
+
+ [Fact]
+ public void Il_tetto_del_giorno_conta_la_puntata_che_sta_per_partire()
+ {
+ Pulito();
+ var settings = new AppSettings { DailyBudgetEuro = 0.50 };
+
+ RiskManager.NoteBidSpent("a", 0.20);
+ RiskManager.NoteBidSpent("a", 0.20);
+ Assert.False(RiskManager.Authorize(Auction("a"), settings).Allowed); // 0.40 + 0.20 > 0.50
+ Assert.False(RiskManager.Halted); // il tetto non e' un HALT: domani si riparte
+ }
+
+ [Fact]
+ public void Le_aste_in_gioco_insieme_hanno_un_tetto()
+ {
+ Pulito();
+ var settings = new AppSettings { MaxConcurrentActiveAuctions = 2 };
+
+ var tutte = new List
+ {
+ Auction("a", bids: 3), Auction("b", bids: 1), Auction("c"), Auction("d", RunState.Watch, bids: 9)
+ };
+
+ // c non ha ancora puntato e ce ne sono già due in gioco: no.
+ Assert.False(RiskManager.Authorize(tutte[2], settings, tutte).Allowed);
+ // a è già in gioco: continua.
+ Assert.True(RiskManager.Authorize(tutte[0], settings, tutte).Allowed);
+ }
+}
diff --git a/Mimante/Tests/SqliteTests.cs b/Mimante/Tests/SqliteTests.cs
new file mode 100644
index 0000000..778f698
--- /dev/null
+++ b/Mimante/Tests/SqliteTests.cs
@@ -0,0 +1,159 @@
+using System;
+using System.IO;
+using System.Linq;
+using AutoBidder.Data;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+///
+/// Il wrapper su winsqlite3 e il database: si apre, si scrive in coda, si rilegge dopo
+/// Flush, i blocchi sono atomici, le date tornano in UTC, e la copia con VACUUM INTO
+/// produce un archivio leggibile.
+///
+public class SqliteTests
+{
+ private static string TempDb() =>
+ Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", $"db-{Guid.NewGuid():N}.sqlite");
+
+ [Fact]
+ public void La_libreria_di_sistema_risponde()
+ {
+ var v = SqliteConnection.LibraryVersion;
+ Assert.Matches(@"^\d+\.\d+", v);
+ }
+
+ [Fact]
+ public void Scrive_in_coda_e_rilegge_dopo_flush()
+ {
+ var path = TempDb();
+ using var db = AuctionDatabase.Open(path);
+
+ db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "io");
+ db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "tu");
+ db.Flush();
+
+ Assert.Equal(2, db.Count("sessions"));
+ var rows = db.Query("SELECT username FROM sessions ORDER BY session_id");
+ Assert.Equal(new[] { "io", "tu" }, rows.Select(r => r.Str("username")).ToArray());
+ Assert.Equal(0, db.WritesFailed);
+ }
+
+ [Fact]
+ public void Un_blocco_e_tutto_o_niente()
+ {
+ var path = TempDb();
+ using var db = AuctionDatabase.Open(path);
+
+ db.EnqueueBatch(new (string, object?[])[]
+ {
+ ("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", new object?[] { AuctionDatabase.Now(), "a" }),
+ ("INSERT INTO tabella_che_non_esiste(x) VALUES(1)", Array.Empty()),
+ ("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", new object?[] { AuctionDatabase.Now(), "b" })
+ });
+ db.Flush();
+
+ Assert.Equal(0, db.Count("sessions"));
+ Assert.True(db.WritesFailed > 0);
+ }
+
+ [Fact]
+ public void Le_date_tornano_in_utc()
+ {
+ var path = TempDb();
+ using var db = AuctionDatabase.Open(path);
+
+ var local = new DateTime(2026, 9, 8, 10, 30, 0, DateTimeKind.Local);
+ db.ExecuteNow("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", local, "x");
+
+ var row = db.QueryOne("SELECT started_at FROM sessions")!;
+ var back = row.Date("started_at")!.Value;
+
+ Assert.Equal(DateTimeKind.Utc, back.Kind);
+ Assert.Equal(local.ToUniversalTime(), back);
+ }
+
+ [Fact]
+ public void I_tipi_si_leggono_con_tolleranza()
+ {
+ var path = TempDb();
+ using var db = AuctionDatabase.Open(path);
+
+ db.ExecuteNow("INSERT INTO items(product_key, title, retail_price_bidoo, first_seen_at, updated_at) VALUES('k', 'T', 11.5, ?1, ?1)", AuctionDatabase.Now());
+
+ var row = db.QueryOne("SELECT * FROM items")!;
+ Assert.Equal("k", row.Str("product_key"));
+ Assert.Equal(11.5, row.Dbl("retail_price_bidoo"));
+ Assert.Null(row.DblOrNull("market_price_est"));
+ Assert.Equal(1, row.Long("item_id"));
+ Assert.True(row.Has("shipping"));
+ Assert.False(row.Has("colonna_inventata"));
+ }
+
+ [Fact]
+ public void La_cancellazione_di_unasta_trascina_le_sue_righe()
+ {
+ var path = TempDb();
+ using var db = AuctionDatabase.Open(path);
+
+ var now = AuctionDatabase.Now();
+ db.Enqueue("INSERT INTO auctions(auction_id, added_at, first_seen_at, opened_at) VALUES('a1', ?1, ?1, ?1)", now);
+ db.Enqueue("INSERT INTO bids(auction_id, username, price_after) VALUES('a1', 'tizio', 0.01)");
+ db.Enqueue("INSERT INTO polls(auction_id, t, price) VALUES('a1', 0, 0.01)");
+ db.ExecuteNow("DELETE FROM auctions WHERE auction_id = 'a1'");
+
+ Assert.Equal(0, db.Count("bids"));
+ Assert.Equal(0, db.Count("polls"));
+ }
+
+ [Fact]
+ public void Una_scrittura_sbagliata_non_ferma_le_altre()
+ {
+ var path = TempDb();
+ using var db = AuctionDatabase.Open(path);
+
+ db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "prima");
+ db.Enqueue("INSERT INTO nessuna_tabella(x) VALUES(1)");
+ db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "dopo");
+ db.Flush();
+
+ Assert.Equal(2, db.Count("sessions"));
+ Assert.Equal(1, db.WritesFailed);
+ }
+
+ [Fact]
+ public void La_copia_di_sicurezza_e_un_archivio_leggibile()
+ {
+ var path = TempDb();
+ var backup = path + ".copia.sqlite";
+
+ using (var db = AuctionDatabase.Open(path))
+ {
+ db.ExecuteNow("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "io");
+ db.BackupTo(backup);
+ }
+
+ Assert.True(File.Exists(backup));
+ using var copy = AuctionDatabase.Open(backup);
+ Assert.Equal(1, copy.Count("sessions"));
+ }
+
+ [Fact]
+ public void Lo_svuotamento_lascia_prodotti_e_sessioni()
+ {
+ var path = TempDb();
+ using var db = AuctionDatabase.Open(path);
+
+ var now = AuctionDatabase.Now();
+ db.Enqueue("INSERT INTO items(product_key, title, first_seen_at, updated_at) VALUES('k', 'T', ?1, ?1)", now);
+ db.Enqueue("INSERT INTO auctions(auction_id, added_at, first_seen_at, opened_at) VALUES('a1', ?1, ?1, ?1)", now);
+ db.Enqueue("INSERT INTO sessions(started_at) VALUES(?1)", now);
+ db.Flush();
+
+ db.WipeObservations();
+
+ Assert.Equal(0, db.Count("auctions"));
+ Assert.Equal(1, db.Count("items"));
+ Assert.Equal(1, db.Count("sessions"));
+ }
+}
diff --git a/Mimante/Tests/StatsWipeTests.cs b/Mimante/Tests/StatsWipeTests.cs
index ead2916..b1a51f5 100644
--- a/Mimante/Tests/StatsWipeTests.cs
+++ b/Mimante/Tests/StatsWipeTests.cs
@@ -9,6 +9,7 @@ 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.
///
+[Collection("Database")]
public class StatsWipeTests
{
[Fact]
@@ -41,7 +42,7 @@ public class StatsWipeTests
Assert.True(File.Exists(altro));
Assert.Null(report.BackupPath);
Assert.Empty(report.Errors);
- Assert.Contains(report.Steps, s => s.StartsWith("archivi mensili"));
+ Assert.Contains(report.Steps, s => s.StartsWith("archivi delle versioni precedenti"));
}
finally
{
diff --git a/Mimante/Tests/TestEnvironment.cs b/Mimante/Tests/TestEnvironment.cs
index 19253a8..7bd6855 100644
--- a/Mimante/Tests/TestEnvironment.cs
+++ b/Mimante/Tests/TestEnvironment.cs
@@ -26,7 +26,8 @@ internal static class TestEnvironment
AppPaths.Configure(
dataFolder: Path.Combine(sandbox, "Dati"),
- statsFolder: Path.Combine(sandbox, "Statistiche"));
+ statsFolder: Path.Combine(sandbox, "Statistiche"),
+ databaseFile: Path.Combine(sandbox, "autobidder.sqlite"));
AppPaths.EnsureFolders();
}
diff --git a/Mimante/Tests/TheoryTests.cs b/Mimante/Tests/TheoryTests.cs
new file mode 100644
index 0000000..a07abd8
--- /dev/null
+++ b/Mimante/Tests/TheoryTests.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutoBidder.Ml;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+///
+/// Le formule del modello matematico devono dare i numeri che si calcolano a mano,
+/// e la copertura «Compralo Ora» deve azzerare il costo della sconfitta solo quando
+/// le puntate spese superano la perdita coperta.
+///
+public class TheoryTests
+{
+ private static Theory.BidSituation S(double p, double value = 11, double price = 0.50, double c = 0.20,
+ double fee = 1.0, double ship = 0, double? list = null, double spent = 0, bool hedge = false) =>
+ new(p, value, price, c, fee, ship, list, spent, hedge);
+
+ [Fact]
+ public void Senza_copertura_la_puntata_si_paga_in_entrambi_i_rami()
+ {
+ // P=0.5: vinco 11 − 0.51 − 1 − 0.20 = 9.29; perdo 0.20.
+ var ev = Theory.BidExpectedValue(S(0.5));
+ Assert.Equal(0.5 * 9.29 - 0.5 * 0.20, ev, 6);
+ }
+
+ [Fact]
+ public void Con_p_zero_si_perde_esattamente_il_costo_della_puntata()
+ {
+ Assert.Equal(-0.20, Theory.BidExpectedValue(S(0)), 6);
+ }
+
+ [Fact]
+ public void Il_pareggio_e_dove_il_valore_atteso_e_zero()
+ {
+ var s = S(0.123);
+ var pStar = Theory.BreakEvenPWin(s);
+ var atBreakEven = s with { PWin = pStar };
+ Assert.Equal(0, Theory.BidExpectedValue(atBreakEven), 6);
+ }
+
+ [Fact]
+ public void Se_non_conviene_neanche_vincendo_il_pareggio_e_uno()
+ {
+ var s = S(0.9, value: 1.0, price: 0.90, fee: 1.0);
+ Assert.Equal(1, Theory.BreakEvenPWin(s));
+ Assert.False(Theory.ShouldBid(s));
+ }
+
+ [Fact]
+ public void La_copertura_azzera_il_costo_di_sconfitta_solo_oltre_la_perdita_coperta()
+ {
+ // Listino 11, valore reale 8.80: perdita coperta 2.20 = 11 puntate da 0.20.
+ var prima = S(0.1, value: 8.80, list: 11, spent: 1.00, hedge: true);
+ Assert.Equal(0.20, Theory.LossBranchCost(prima));
+
+ var dopo = S(0.1, value: 8.80, list: 11, spent: 2.20, hedge: true);
+ Assert.Equal(0, Theory.LossBranchCost(dopo));
+ }
+
+ [Fact]
+ public void Per_un_buono_al_nominale_la_copertura_e_totale_dal_primo_centesimo()
+ {
+ var s = S(0.01, value: 11, list: 11, spent: 0, hedge: true);
+ Assert.Equal(0, Theory.LossBranchCost(s));
+ Assert.True(Theory.BidExpectedValue(s) > 0);
+ }
+
+ [Fact]
+ public void Con_la_copertura_spenta_il_listino_non_conta()
+ {
+ var s = S(0.1, value: 8.80, list: 11, spent: 5, hedge: false);
+ Assert.Equal(0.20, Theory.LossBranchCost(s));
+ }
+
+ [Fact]
+ public void Il_numero_critico_di_puntatori_e_valore_su_costo()
+ {
+ Assert.Equal(55, Theory.CriticalBidders(11, 0.20), 6);
+ }
+
+ [Fact]
+ public void La_probabilita_di_vittoria_a_un_anticipo_e_la_frequenza_dei_cicli_sopravvissuti()
+ {
+ var cycles = new List();
+ // 30 cicli fermati a 5 s (puntate presto), 15 scesi fino a 1 s, 5 che hanno chiuso l'asta.
+ for (var i = 0; i < 30; i++) cycles.Add(new(5.0, false));
+ for (var i = 0; i < 15; i++) cycles.Add(new(1.0, false));
+ for (var i = 0; i < 5; i++) cycles.Add(new(0, true));
+
+ // A 2 s di anticipo: raggiunto da 15 + 5 = 20 cicli, sopravvissuti 5.
+ Assert.Equal(0.25, Theory.WinProbabilityAtLead(cycles, 2.0)!.Value, 6);
+ // A 6 s: raggiunto da tutti e 50, sopravvissuti 5.
+ Assert.Equal(0.10, Theory.WinProbabilityAtLead(cycles, 6.0)!.Value, 6);
+ // Con pochi cicli non si dice nulla.
+ Assert.Null(Theory.WinProbabilityAtLead(cycles.Take(10), 2.0));
+ }
+
+ [Fact]
+ public void Kaplan_meier_scende_a_ogni_puntata_e_non_alle_censure()
+ {
+ var cycles = new[]
+ {
+ new Theory.CycleObservation(8, false),
+ new Theory.CycleObservation(8, false),
+ new Theory.CycleObservation(3, false),
+ new Theory.CycleObservation(0, true)
+ };
+
+ var km = Theory.KaplanMeier(cycles);
+
+ Assert.Equal(2, km.Count);
+ Assert.Equal(8, km[0].TimerSeconds);
+ Assert.Equal(0.5, km[0].Survival, 6); // 2 su 4 puntano a 8 s
+ Assert.Equal(0.25, km[1].Survival, 6); // poi 1 su 2 a 3 s
+ }
+
+ [Fact]
+ public void Poisson_da_la_probabilita_che_nessuno_punti()
+ {
+ Assert.Equal(Math.Exp(-0.5 * 2), Theory.NoBidProbability(0.5, 2), 9);
+ Assert.Equal(1, Theory.NoBidProbability(0, 2));
+ Assert.Equal(0.25, Theory.ArrivalRate(new[] { 4.0, 4.0, 4.0 })!.Value, 6);
+ Assert.Null(Theory.ArrivalRate(Array.Empty()));
+ }
+}
diff --git a/Mimante/Utilities/AppPaths.cs b/Mimante/Utilities/AppPaths.cs
index bb8b498..b3d0b32 100644
--- a/Mimante/Utilities/AppPaths.cs
+++ b/Mimante/Utilities/AppPaths.cs
@@ -123,6 +123,17 @@ namespace AutoBidder.Utilities
/// Profilo del browser integrato (cache WebView2).
public static string BrowserProfile => Path.Combine(Root, "WebView2");
+ /// Percorso del database scelto dall'utente; vuoto = predefinito.
+ private static string _databaseFileOverride = "";
+
+ ///
+ /// L'archivio SQLite con aste, puntate, decisioni e misure. Sta nella radice
+ /// fissa, non in Documenti: una cartella sincronizzata (OneDrive) è il modo
+ /// classico di corrompere un database aperto.
+ ///
+ public static string DatabaseFile =>
+ _databaseFileOverride.Length > 0 ? _databaseFileOverride : Path.Combine(Root, "Database", "autobidder.sqlite");
+
/// Percorso dati scelto dall'utente; vuoto = predefinito.
private static string _dataFolderOverride = "";
@@ -200,11 +211,29 @@ namespace AutoBidder.Utilities
/// ignorato in favore del predefinito: è preferibile scrivere nel posto sbagliato
/// che non scrivere affatto e perdere lo storico.
///
- public static void Configure(string? dataFolder, string? statsFolder, string? logFolder = null)
+ public static void Configure(string? dataFolder, string? statsFolder, string? logFolder = null, string? databaseFile = null)
{
_dataFolderOverride = Sanitize(dataFolder);
_statsFolderOverride = Sanitize(statsFolder);
_logFolderOverride = Sanitize(logFolder);
+ _databaseFileOverride = SanitizeFile(databaseFile);
+ }
+
+ private static string SanitizeFile(string? candidate)
+ {
+ if (string.IsNullOrWhiteSpace(candidate)) return "";
+
+ try
+ {
+ var full = Path.GetFullPath(candidate.Trim());
+ var folder = Path.GetDirectoryName(full);
+ if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
+ return full;
+ }
+ catch
+ {
+ return "";
+ }
}
private static string Sanitize(string? candidate)
@@ -231,7 +260,7 @@ namespace AutoBidder.Utilities
TryCreate(DataFolder);
TryCreate(StatsFolder);
TryCreate(LogFolder);
- TryCreate(AuctionLogFolder);
+ TryCreate(Path.GetDirectoryName(DatabaseFile) ?? Root);
}
private static void TryCreate(string path)
diff --git a/Mimante/Utilities/AuctionDetailStore.cs b/Mimante/Utilities/AuctionDetailStore.cs
index 3e79668..de40e61 100644
--- a/Mimante/Utilities/AuctionDetailStore.cs
+++ b/Mimante/Utilities/AuctionDetailStore.cs
@@ -1,257 +1,122 @@
using System;
using System.Collections.Generic;
-using System.IO;
using System.Linq;
-using System.Text;
using System.Text.Json;
+using AutoBidder.Data;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
///
- /// Le schede dettagliate delle aste concluse, lette dai dossier.
+ /// Le schede dettagliate delle aste concluse, lette dalla tabella auctions.
///
- /// Fino a ieri ogni asta chiusa veniva scritta due volte: una riga nel dossier
- /// (il riepilogo in coda al file) e la stessa riga, con gli stessi numeri, in un
- /// archivio mensile aste-AAAA-MM.jsonl. Cento megabyte di copia, e due fonti
- /// che potevano divergere. Ora la fonte è una: il dossier. Questa classe ne legge
- /// intestazione e riepilogo — la prima e l'ultima riga — e ricompone la scheda.
- ///
- /// Un dossier pesa anche decine di megabyte, ma qui non lo si scorre: si legge
- /// la testa e la coda del file, poche decine di kilobyte. Seimila dossier si
- /// rileggono in un paio di secondi, e comunque solo la prima volta — la cache
- /// ricorda ogni file per dimensione e data, e rilegge soltanto quelli cambiati.
+ /// La scheda è la stessa riga dello storico compatto, vista per intero: serie
+ /// dei prezzi, puntate per utente, copertura dell'osservazione, rete, motore. Non
+ /// c'è più nulla da ricomporre da file diversi.
///
public static class AuctionDetailStore
{
- private static readonly object Sync = new();
+ private static AuctionDatabase Db => AuctionDatabase.Instance;
- /// Quanto si legge dalla coda: il riepilogo con 600 punti di prezzo e centinaia di utenti sta in ~40 KB.
- private const int TailBytes = 256 * 1024;
- private const int HeadBytes = 8 * 1024;
+ private const string Select =
+ "SELECT * FROM auctions WHERE outcome IS NOT NULL ORDER BY end_time DESC";
- private sealed record CacheEntry(long Length, DateTime LastWrite, AuctionDetailRecord? Record);
-
- private static readonly Dictionary Cache = new(StringComparer.OrdinalIgnoreCase);
-
- /// Tutte le schede delle aste con dossier concluso, dalla più recente.
+ /// Tutte le schede, dalla più recente.
public static List LoadAll()
{
- var result = new List();
-
- try
- {
- var folder = AppPaths.AuctionLogFolder;
- if (!Directory.Exists(folder)) return result;
-
- lock (Sync)
- {
- var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- foreach (var file in Directory.EnumerateFiles(folder, "*.jsonl"))
- {
- seen.Add(file);
-
- FileInfo info;
- try { info = new FileInfo(file); } catch { continue; }
-
- if (Cache.TryGetValue(file, out var hit) &&
- hit.Length == info.Length && hit.LastWrite == info.LastWriteTimeUtc)
- {
- if (hit.Record != null) result.Add(hit.Record);
- continue;
- }
-
- var record = ReadDossier(file);
- Cache[file] = new CacheEntry(info.Length, info.LastWriteTimeUtc, record);
- if (record != null) result.Add(record);
- }
-
- // File spariti: via dalla cache, o resterebbero per sempre.
- foreach (var stale in Cache.Keys.Where(k => !seen.Contains(k)).ToList())
- Cache.Remove(stale);
- }
- }
- catch { }
-
- return result.OrderByDescending(r => r.EndedAt).ToList();
+ Db.Flush();
+ return Db.Query(Select).Select(FromRow).ToList();
}
- public static int Count() => LoadAll().Count;
-
- ///
- /// La scheda di un singolo dossier, o null se il file non ha un riepilogo
- /// (asta ancora in corso, o abbandonata a metà).
- ///
- public static AuctionDetailRecord? ReadDossier(string path)
+ /// La scheda di una sola asta, o null.
+ public static AuctionDetailRecord? Load(string auctionId)
{
- try
- {
- string? headerLine, lastLine;
-
- using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
- {
- headerLine = ReadFirstLine(fs);
- lastLine = ReadLastLine(fs);
- }
-
- if (headerLine == null || lastLine == null) return null;
- if (!lastLine.Contains("\"type\":\"summary\"", StringComparison.Ordinal)) return null;
-
- using var header = JsonDocument.Parse(headerLine);
- using var summary = JsonDocument.Parse(lastLine);
-
- return Compose(header.RootElement, summary.RootElement, path);
- }
- catch
- {
- return null;
- }
+ Db.Flush();
+ var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1 AND outcome IS NOT NULL", auctionId);
+ return row == null ? null : FromRow(row);
}
- // ── Lettura di testa e coda ──────────────────────────────────────
+ public static int Count() => (int)Db.ScalarLong("SELECT COUNT(*) AS n FROM auctions WHERE outcome IS NOT NULL");
- private static string? ReadFirstLine(FileStream fs)
+ /// Le aste di cui esistono eventi registrati (interrogazioni o puntate).
+ public static HashSet WithEvents()
{
- fs.Seek(0, SeekOrigin.Begin);
- var buffer = new byte[Math.Min(HeadBytes, fs.Length)];
- var read = fs.Read(buffer, 0, buffer.Length);
- var text = Encoding.UTF8.GetString(buffer, 0, read).TrimStart('');
- var nl = text.IndexOf('\n');
- var line = nl >= 0 ? text[..nl] : text;
- return line.Contains("\"type\":\"header\"", StringComparison.Ordinal) ? line.TrimEnd('\r') : null;
+ Db.Flush();
+ var set = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var r in Db.Query("SELECT DISTINCT auction_id FROM polls UNION SELECT DISTINCT auction_id FROM bids"))
+ set.Add(r.Str("auction_id"));
+ return set;
}
- private static string? ReadLastLine(FileStream fs)
- {
- var take = (int)Math.Min(TailBytes, fs.Length);
- fs.Seek(-take, SeekOrigin.End);
- var buffer = new byte[take];
- var read = fs.Read(buffer, 0, take);
- var text = Encoding.UTF8.GetString(buffer, 0, read).TrimEnd('\r', '\n', ' ');
-
- var nl = text.LastIndexOf('\n');
- // Se non c'è un a-capo nella coda letta, la riga è più lunga della finestra
- // (o il file è di una riga sola): si tiene solo se comincia con la graffa.
- var line = nl >= 0 ? text[(nl + 1)..] : text;
- line = line.TrimEnd('\r');
- return line.StartsWith('{') ? line : null;
- }
-
- // ── Ricomposizione ───────────────────────────────────────────────
-
- private static AuctionDetailRecord Compose(JsonElement h, JsonElement s, string path)
+ internal static AuctionDetailRecord FromRow(SqliteRow row)
{
var r = new AuctionDetailRecord
{
- AuctionId = Str(h, "auctionId"),
- Name = Str(h, "name"),
- ProductKey = Str(h, "productKey"),
- Url = Str(h, "url"),
- DossierPath = path,
+ AuctionId = row.Str("auction_id"),
+ Name = row.Str("name"),
+ ProductKey = row.Str("product_key"),
+ Url = row.Str("url"),
- Outcome = Str(s, "outcome"),
- WonByMe = Bool(s, "wonByMe"),
- Winner = Str(s, "winner"),
- FinalPrice = Num(s, "finalPrice"),
- EndedAt = Date(s, "endedAt") ?? Date(s, "closedAt") ?? DateTime.MinValue,
- PriceVelocityPerMinute = Num(s, "priceVelocityPerMinute")
+ Outcome = row.Str("outcome"),
+ WonByMe = row.Bool("won_by_me"),
+ Winner = row.Str("winner_user"),
+ FinalPrice = row.Dbl("final_price"),
+ BuyNowPrice = row.DblOrNull("buy_now_price"),
+ ShippingCost = row.DblOrNull("shipping"),
+
+ FirstSeenAt = row.Date("first_seen_at") ?? row.Date("added_at") ?? DateTime.MinValue,
+ EndedAt = row.Date("end_time") ?? row.Date("closed_at") ?? DateTime.MinValue,
+ ObservedMinutes = row.Dbl("observed_minutes"),
+ ObservedFromStart = row.Bool("observed_from_start"),
+ ObservedToEnd = row.Bool("observed_to_end"),
+
+ MyBids = row.Int("my_bids"),
+ TotalObservedBids = row.Int("total_observed_bids"),
+ DistinctBidders = row.Int("distinct_bidders"),
+ Resets = row.Int("resets"),
+ TopBidderShare = row.Dbl("top_bidder_share"),
+
+ PriceVelocityPerMinute = row.Dbl("price_velocity_per_minute"),
+ AveragePingMs = row.Dbl("avg_ping_ms"),
+ PollCount = row.Long("polls"),
+ PollErrors = row.Long("poll_errors"),
+ ConfiguredLeadMs = row.Int("configured_lead_ms"),
+ TimerExpiredCount = row.Int("timer_expired"),
+ SuccessfulBids = row.Int("successful_bids"),
+ FailedBids = row.Int("failed_bids"),
+ BidCostEuro = row.DblOrNull("bid_fee") is { } fee && fee > 0 ? fee : 0.20
};
- if (h.TryGetProperty("product", out var product))
- {
- r.BuyNowPrice = NullableNum(product, "buyNowPrice");
- r.BidCostEuro = Num(product, "bidCostEuro", 0.20);
- }
+ if (r.ObservedMinutes <= 0 && r.EndedAt > r.FirstSeenAt)
+ r.ObservedMinutes = Math.Max(0, (r.EndedAt - r.FirstSeenAt).TotalMinutes);
- if (s.TryGetProperty("coverage", out var cov))
+ try
{
- r.ObservedFromStart = Bool(cov, "observedFromStart");
- r.ObservedToEnd = Bool(cov, "observedToEnd");
- r.FirstSeenAt = Date(cov, "firstSeenAt") ?? Date(h, "addedAt") ?? r.EndedAt;
- r.ObservedMinutes = Num(cov, "observedMinutes");
- }
- else
- {
- r.FirstSeenAt = Date(h, "addedAt") ?? r.EndedAt;
- }
-
- if (s.TryGetProperty("participation", out var part))
- {
- r.MyBids = Int(part, "myBids");
- r.TotalObservedBids = Int(part, "totalObservedBids");
- r.DistinctBidders = Int(part, "distinctBidders");
- r.Resets = Int(part, "resets");
- r.TopBidderShare = Num(part, "topBidderShare");
-
- if (part.TryGetProperty("bidsByUser", out var byUser) && byUser.ValueKind == JsonValueKind.Object)
+ var series = row.StrOrNull("price_series_json");
+ if (!string.IsNullOrEmpty(series))
{
- foreach (var p in byUser.EnumerateObject())
- if (p.Value.TryGetInt32(out var n)) r.BidsByUser[p.Name] = n;
+ using var doc = JsonDocument.Parse(series);
+ foreach (var p in doc.RootElement.EnumerateArray())
+ {
+ r.PriceSeries.Add(new PricePoint
+ {
+ T = p.TryGetProperty("t", out var t) ? t.GetDouble() : 0,
+ Price = p.TryGetProperty("price", out var pr) ? pr.GetDouble() : 0
+ });
+ }
+ }
+
+ var byUser = row.StrOrNull("bids_by_user_json");
+ if (!string.IsNullOrEmpty(byUser))
+ {
+ var dict = JsonSerializer.Deserialize>(byUser);
+ if (dict != null)
+ foreach (var kv in dict) r.BidsByUser[kv.Key] = kv.Value;
}
}
-
- if (s.TryGetProperty("value", out var value))
- {
- r.BuyNowPrice = NullableNum(value, "buyNowPrice") ?? r.BuyNowPrice;
- r.ShippingCost = NullableNum(value, "shippingCost");
- var costo = NullableNum(value, "bidCostEuro");
- if (costo is > 0) r.BidCostEuro = costo.Value;
- }
-
- if (s.TryGetProperty("network", out var net))
- {
- r.AveragePingMs = Num(net, "avgPingMs");
- r.PollCount = Long(net, "polls");
- r.PollErrors = Long(net, "pollErrors");
- }
-
- if (s.TryGetProperty("engine", out var eng))
- {
- r.ConfiguredLeadMs = Int(eng, "configuredLeadMs");
- r.TimerExpiredCount = Int(eng, "timerExpiredCount");
- r.SuccessfulBids = Int(eng, "successfulBids");
- r.FailedBids = Int(eng, "failedBids");
- }
-
- if (s.TryGetProperty("priceSeries", out var series) && series.ValueKind == JsonValueKind.Array)
- {
- foreach (var p in series.EnumerateArray())
- r.PriceSeries.Add(new PricePoint { T = Num(p, "t"), Price = Num(p, "price") });
- }
+ catch { /* una colonna JSON rotta non deve togliere la scheda */ }
return r;
}
-
- private static string Str(JsonElement e, string name) =>
- e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
-
- private static bool Bool(JsonElement e, string name) =>
- e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
-
- private static double Num(JsonElement e, string name, double fallback = 0) =>
- e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : fallback;
-
- private static double? NullableNum(JsonElement e, string name) =>
- e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : null;
-
- private static int Int(JsonElement e, string name) =>
- e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : 0;
-
- private static long Long(JsonElement e, string name) =>
- e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n) ? n : 0;
-
- private static DateTime? Date(JsonElement e, string name) =>
- e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String &&
- DateTime.TryParse(v.GetString(), System.Globalization.CultureInfo.InvariantCulture,
- System.Globalization.DateTimeStyles.RoundtripKind, out var d)
- ? d
- : null;
-
- /// Solo per i test: dimentica i file già letti.
- public static void ResetCacheForTests()
- {
- lock (Sync) Cache.Clear();
- }
}
}
diff --git a/Mimante/Utilities/AuctionDossier.cs b/Mimante/Utilities/AuctionDossier.cs
deleted file mode 100644
index dbbf7a5..0000000
--- a/Mimante/Utilities/AuctionDossier.cs
+++ /dev/null
@@ -1,571 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text.Encodings.Web;
-using System.Text.Json;
-using System.Text.RegularExpressions;
-using AutoBidder.Models;
-
-namespace AutoBidder.Utilities
-{
- ///
- /// Il dossier di una singola asta: un file per asta, scritto mentre l'asta è in corso.
- ///
- /// Formato JSON Lines. La prima riga è l'intestazione (che asta è, con che
- /// impostazioni la si sta seguendo, quanto vale il prodotto), poi una riga per ogni cosa
- /// che succede — ogni interrogazione a Bidoo, ogni puntata di ogni utente con l'ora al
- /// millisecondo, ogni mia puntata con anticipo pianificato ed effettivo, ogni riga di
- /// registro — e l'ultima riga è il riepilogo. Si accoda senza rileggere, un'interruzione
- /// perde al massimo l'ultima riga, e un'AI lo legge senza doverlo convertire.
- ///
- /// Perché tanto dettaglio: questi dati non esistono a posteriori. Bidoo non
- /// espone lo storico dei prezzi né i tempi delle puntate di un'asta conclusa. O li si
- /// raccoglie mentre l'asta va avanti, o sono persi — e sono esattamente quelli su cui si
- /// capisce se l'anticipo impostato è giusto e come si comportano gli avversari.
- ///
- /// La scrittura passa da , quindi non tocca mai il
- /// disco sul thread che segue l'asta: si accoda una riga e si prosegue.
- ///
- public sealed class AuctionDossier
- {
- /// Campioni di ping tenuti in memoria per il riepilogo finale.
- private const int MaxPingSamples = 50_000;
-
- private static readonly ConcurrentDictionary Open =
- new(StringComparer.Ordinal);
-
- private static readonly JsonSerializerOptions Json = new()
- {
- WriteIndented = false,
- // Senza, accenti e apostrofi dei nomi prodotto finiscono in \uXXXX e il file
- // diventa illeggibile proprio per chi (o cosa) deve analizzarlo.
- Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
- DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
- };
-
- private readonly FileLogWriter _writer;
- private readonly DateTime _startedAt;
- private readonly List _pings = new();
- private readonly object _sync = new();
-
- private long _polls;
- private long _events;
- private bool _closed;
-
- // ── Coalescenza dei poll ─────────────────────────────────────────
- //
- // Con il tetto delle richieste tolto arrivano ~17 interrogazioni al secondo per
- // asta, e su 40 dossier veri l'88,8% ha la stessa faccia della precedente: stesso
- // prezzo, stesso puntatore, stesso stato, stesso secondo sul cronometro. Scriverle
- // tutte faceva dossier da 30 MB per una sola asta, e 8,8 GB in tutto — di cui nove
- // decimi ripetizioni. Si scrive quando cambia qualcosa, e comunque una volta al
- // secondo perché il ping resti misurabile. Ogni cambio di secondo e ogni reset
- // vengono scritti: la profondità di ogni ciclo resta ricostruibile esattamente.
- private string? _lastPollSignature;
- private DateTime _lastPollWrittenAt = DateTime.MinValue;
-
- // Quanto è sceso il timer nel ciclo appena chiuso e quanto è durato: sono i due
- // numeri che l'apprendimento vuole da un reset, e si ricavano solo qui, dove
- // passano tutti i poll.
- private double _cycleMinTimer = double.MaxValue;
- private double _cycleStartedElapsed = -1;
- private double _completedCycleMinTimer = double.NaN;
- private double _completedCycleSeconds = double.NaN;
- private double _lastPollPrice = double.NaN;
-
- private AuctionDossier(string path, DateTime startedAt)
- {
- _writer = FileLogWriter.For(path);
- _startedAt = startedAt;
- }
-
- public string Path => _writer.Path;
-
- ///
- /// Il nostro nome utente su Bidoo, scritto nell'intestazione di ogni dossier aperto
- /// da qui in poi. Lo imposta il motore appena conosce la sessione. Serve a chi
- /// rilegge il file per sapere quali puntate erano nostre senza dipendere da niente
- /// di esterno al file.
- ///
- public static volatile string CurrentUsername = "";
-
- /// Righe scritte finora, intestazione esclusa.
- public long EventCount => _events;
-
- // ── Apertura e chiusura ──────────────────────────────────────────
-
- ///
- /// Apre (o riapre) il dossier di quest'asta. Riaprendo l'applicazione mentre l'asta
- /// è ancora viva si riprende lo stesso file: un'asta seguita a cavallo di un
- /// riavvio deve restare una storia sola.
- ///
- public static AuctionDossier? OpenFor(AuctionInfo auction, AppSettings settings)
- {
- if (auction == null || !settings.WriteAuctionDossiers) return null;
-
- return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings));
- }
-
- /// Il dossier già aperto per quest'asta, se c'è.
- public static AuctionDossier? For(string auctionId) =>
- auctionId != null && Open.TryGetValue(auctionId, out var dossier) ? dossier : null;
-
- ///
- /// Fa confluire nel dossier tutte le righe di registro delle aste. Da chiamare una
- /// volta all'avvio: le righe nascono in decine di punti del motore, e agganciarsi
- /// alla sorgente è l'unico modo per non doverle inseguire una per una.
- ///
- public static void CaptureAuctionLogs()
- {
- AuctionInfo.LogSink = (auction, entry) =>
- For(auction.AuctionId)?.Log(
- entry.Level.ToString().ToLowerInvariant(),
- entry.Category.ToString(),
- entry.Message);
- }
-
- ///
- /// Chiude il dossier di un'asta tolta dal monitor prima della fine.
- ///
- /// Senza riepilogo, e detto esplicitamente: quel file racconta una storia
- /// interrotta, e chi lo analizzerà deve saperlo — una serie di prezzi che si ferma a
- /// metà sembra un'asta finita a quel prezzo.
- ///
- public static void Abandon(string auctionId, string reason)
- {
- if (auctionId == null) return;
- if (!Open.TryRemove(auctionId, out var dossier)) return;
-
- dossier.Write(new
- {
- t = dossier.Elapsed(),
- type = "abandoned",
- at = Now(),
- reason,
- note = "asta tolta dal monitor prima della conclusione: dati incompleti"
- });
-
- FileLogWriter.FlushAll();
- }
-
- /// Svuota sul disco i dossier aperti. Alla chiusura, o prima di esportare.
- public static void FlushAll() => FileLogWriter.FlushAll();
-
- /// Quanti dossier sono aperti adesso.
- public static int OpenCount => Open.Count;
-
- private static AuctionDossier Create(AuctionInfo auction, AppSettings settings)
- {
- AppPaths.EnsureFolders();
-
- var path = ResolvePath(auction);
- var isNew = !File.Exists(path);
-
- var dossier = new AuctionDossier(path, DateTime.Now);
-
- if (isNew) dossier.WriteHeader(auction, settings);
- else dossier.Write(new { type = "resumed", at = Now(), note = "applicazione riavviata, asta ancora in corso" });
-
- return dossier;
- }
-
- ///
- /// Percorso del file. Se ne esiste già uno per quest'asta si riusa quello, comunque
- /// si chiami: il nome porta la data, e un'asta cominciata ieri non deve spezzarsi in
- /// due file solo perché è mezzanotte.
- ///
- private static string ResolvePath(AuctionInfo auction)
- {
- try
- {
- var existing = Directory
- .GetFiles(AppPaths.AuctionLogFolder, $"*_{auction.AuctionId}.jsonl")
- .FirstOrDefault();
-
- if (existing != null) return existing;
- }
- catch { /* cartella non ancora disponibile: si crea il file nuovo */ }
-
- var slug = Slug(auction.Name);
- var name = $"{DateTime.Now:yyyy-MM-dd_HHmm}_{slug}_{auction.AuctionId}.jsonl";
-
- return System.IO.Path.Combine(AppPaths.AuctionLogFolder, name);
- }
-
- private void WriteHeader(AuctionInfo auction, AppSettings settings)
- {
- Write(new
- {
- type = "header",
- schema = "autobidder.auction.v1",
- app = AppInfo.Version,
- auctionId = auction.AuctionId,
- me = string.IsNullOrEmpty(CurrentUsername) ? null : CurrentUsername,
- name = auction.Name,
- productKey = ProductKeyHelper.GenerateProductKey(auction.Name),
- url = auction.OriginalUrl,
- openedAt = Now(),
- addedAt = auction.AddedAt.ToLocalTime().ToString("o"),
-
- // Valore del prodotto: senza questo, prezzo finale e convenienza non
- // significano nulla quando si rileggeranno i dati.
- product = new
- {
- buyNowPrice = auction.BuyNowPrice,
- shippingCost = auction.ShippingCost,
- bidCostEuro = auction.BidCost,
- hasWinLimit = auction.HasWinLimit,
- winLimit = auction.WinLimitDescription
- },
-
- // Con che regole la si sta seguendo: due aste identiche con anticipi
- // diversi non sono confrontabili, e senza questo non si saprebbe.
- config = new
- {
- state = auction.State.ToString(),
- bidBeforeDeadlineMs = auction.BidBeforeDeadlineMs > 0
- ? auction.BidBeforeDeadlineMs
- : settings.DefaultBidBeforeDeadlineMs,
- minPrice = auction.MinPrice,
- maxPrice = auction.MaxPrice,
- maxBids = auction.MaxClicks,
- minResets = auction.MinResets,
- maxResets = auction.MaxResets,
- bidLeadIsManual = auction.BidLeadIsManual,
- adaptiveLead = settings.AdaptiveLeadEnabled,
- leadMinMs = settings.LeadMinMs,
- leadMaxMs = settings.LeadMaxMs,
- valueCheckEnabled = settings.ValueCheckEnabled,
- minSavingsPercentage = settings.MinSavingsPercentage,
- rawPollsIncluded = settings.DossierIncludeRawPolls
- }
- });
- }
-
- ///
- /// Chiude il dossier con il riepilogo. Da qui in avanti il file non cresce più:
- /// è la riga che dice a chi analizza che la storia è completa.
- ///
- public static void Close(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
- {
- if (auction == null) return;
- if (!Open.TryRemove(auction.AuctionId, out var dossier)) return;
-
- dossier.WriteSummary(auction, detail, finalState);
- }
-
- private void WriteSummary(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
- {
- lock (_sync)
- {
- if (_closed) return;
- _closed = true;
- }
-
- var pings = PingStats();
-
- Write(new
- {
- type = "summary",
- closedAt = Now(),
- endedAt = detail.EndedAt.ToLocalTime().ToString("o"),
- outcome = detail.Outcome,
- wonByMe = detail.WonByMe,
- winner = detail.Winner,
- finalPrice = detail.FinalPrice,
-
- // Le due bandiere che dicono quanto vale questo dossier per l'analisi.
- coverage = new
- {
- observedFromStart = auction.ObservedFromStart,
- observedToEnd = auction.ObservedToEnd,
- complete = auction.ObservedFromStart && auction.ObservedToEnd,
- firstSeenAt = detail.FirstSeenAt.ToLocalTime().ToString("o"),
- observedMinutes = Math.Round(detail.ObservedMinutes, 2)
- },
-
- participation = new
- {
- myBids = detail.MyBids,
- totalObservedBids = detail.TotalObservedBids,
- distinctBidders = detail.DistinctBidders,
- resets = detail.Resets,
- topBidderShare = Math.Round(detail.TopBidderShare, 2),
- bidsByUser = detail.BidsByUser
- },
-
- value = new
- {
- buyNowPrice = detail.BuyNowPrice,
- shippingCost = detail.ShippingCost,
- bidCostEuro = auction.BidCost,
- totalCostIfWon = Math.Round(detail.TotalCostIfWon(auction.BidCost), 2),
- finalPriceRatio = detail.FinalPriceRatio.HasValue
- ? Math.Round(detail.FinalPriceRatio.Value, 4)
- : (double?)null
- },
-
- network = new
- {
- avgPingMs = pings.Avg,
- minPingMs = pings.Min,
- maxPingMs = pings.Max,
- medianPingMs = pings.Median,
- p95PingMs = pings.P95,
- samples = pings.Count,
- polls = _polls,
- pollErrors = detail.PollErrors
- },
-
- engine = new
- {
- configuredLeadMs = detail.ConfiguredLeadMs,
- timerExpiredCount = auction.TimerExpiredCount,
- successfulBids = auction.SuccessfulBidCount,
- failedBids = auction.FailedBidCount
- },
-
- priceSeries = detail.PriceSeries.Select(p => new { t = Math.Round(p.T, 2), price = p.Price }),
- priceVelocityPerMinute = Math.Round(detail.PriceVelocityPerMinute, 4),
- finalStatus = finalState?.Status.ToString(),
- eventsWritten = _events
- }, isSummary: true);
-
- FileLogWriter.FlushAll();
- }
-
- // ── Eventi ───────────────────────────────────────────────────────
-
- ///
- /// Una singola interrogazione a Bidoo. È l'evento più frequente — nella finestra
- /// critica quattro al secondo — ed è quello che permette di ricostruire a posteriori
- /// dove stava davvero il timer nell'istante in cui si è deciso di puntare.
- ///
- public void Poll(AuctionState state, bool includeRaw)
- {
- _polls++;
-
- lock (_sync)
- {
- if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs);
-
- // Un cambio di prezzo è un reset visto da qui: il ciclo che finisce va
- // consegnato a Reset() con il suo minimo e la sua durata.
- var now = Elapsed();
- if (!double.IsNaN(_lastPollPrice) && state.Price > _lastPollPrice)
- {
- _completedCycleMinTimer = _cycleMinTimer == double.MaxValue ? double.NaN : _cycleMinTimer;
- _completedCycleSeconds = _cycleStartedElapsed >= 0 ? now - _cycleStartedElapsed : double.NaN;
- _cycleMinTimer = double.MaxValue;
- _cycleStartedElapsed = now;
- }
- else if (_cycleStartedElapsed < 0)
- {
- _cycleStartedElapsed = now;
- }
- _lastPollPrice = state.Price;
-
- if (state.Timer > 0 && state.Timer < _cycleMinTimer) _cycleMinTimer = state.Timer;
- }
-
- if (!includeRaw) return;
-
- var signature = string.Concat(
- state.Price.ToString("F2"), '|', state.LastBidder, '|', state.Status, '|',
- (int)state.Timer, '|', state.IsMyBid ? '1' : '0');
-
- var wall = DateTime.Now;
- if (signature == _lastPollSignature && (wall - _lastPollWrittenAt).TotalMilliseconds < 1000)
- return;
-
- _lastPollSignature = signature;
- _lastPollWrittenAt = wall;
-
- Write(new
- {
- t = Elapsed(),
- type = "poll",
- at = Now(),
- price = state.Price,
- timer = Math.Round(state.Timer, 2),
- status = state.Status.ToString(),
- lastBidder = string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder,
- mine = state.IsMyBid,
- pingMs = state.PollingLatencyMs,
- expiryUnix = state.ExpiryUnixSeconds,
- serverUnix = state.ServerUnixSeconds
- });
- }
-
- /// Una puntata altrui, come l'ha riportata Bidoo.
- public void ForeignBid(BidHistoryEntry entry, double price)
- {
- Write(new
- {
- t = Elapsed(),
- type = "bid",
- at = Now(),
- user = entry.Username,
-
- // L'ora dichiarata da Bidoo, distinta da quella in cui l'abbiamo saputo:
- // fra le due può passare un poll intero, e la differenza conta quando si
- // ricostruisce chi ha puntato per primo.
- bidAt = entry.Timestamp > 0
- ? DateTimeOffset.FromUnixTimeSeconds(entry.Timestamp).ToLocalTime().ToString("o")
- : null,
- bidType = entry.BidType,
- mine = entry.IsMyBid,
- price
- });
- }
-
- ///
- /// Una mia puntata, con tutto ciò che serve a giudicarla: quanto prima della
- /// scadenza volevo essere, quanto prima ci sono arrivato davvero, e con che ping.
- /// È la coppia pianificato/effettivo il dato che permette di tarare l'anticipo.
- ///
- public void MyBid(
- double price,
- int plannedLeadMs,
- double actualLeadMs,
- int pingMs,
- bool success,
- string? error,
- int? bidsUsed,
- int? remainingBids)
- {
- Write(new
- {
- t = Elapsed(),
- type = "my_bid",
- at = Now(),
- price,
- plannedLeadMs,
- actualLeadMs = Math.Round(actualLeadMs, 1),
- leadErrorMs = Math.Round(actualLeadMs - plannedLeadMs, 1),
- pingMs,
- avgPingMs = PingStats().Avg,
- result = success ? "ok" : "fail",
- error,
- bidsUsed,
- remainingBids
- });
- }
-
- /// Il timer è stato azzerato da una puntata: l'asta continua.
- public void Reset(int resetCount, double price, string? bidder)
- {
- double? minTimer, cycleSeconds;
- lock (_sync)
- {
- minTimer = double.IsNaN(_completedCycleMinTimer) ? null : Math.Round(_completedCycleMinTimer, 2);
- cycleSeconds = double.IsNaN(_completedCycleSeconds) ? null : Math.Round(_completedCycleSeconds, 2);
- _completedCycleMinTimer = double.NaN;
- _completedCycleSeconds = double.NaN;
- }
-
- Write(new
- {
- t = Elapsed(),
- type = "reset",
- at = Now(),
- resetCount,
- price,
- bidder,
-
- // Del ciclo appena chiuso: fin dove è sceso il cronometro prima che
- // qualcuno puntasse, e quanto è durato. È il dato con cui si ricostruisce
- // a che punto del conto alla rovescia la gente punta davvero.
- minTimer,
- cycleSeconds
- });
- }
-
- /// Una riga del registro dell'asta (strategie, avvisi, errori).
- public void Log(string level, string category, string message)
- {
- // Le righe "[RESET #n] utente → prezzo" ripetono parola per parola l'evento
- // reset che sta già nel dossier: nel campione erano una riga di log ogni reset,
- // e nessuna diceva qualcosa in più. Restano nel registro a video.
- if (message != null && message.StartsWith("[RESET #", StringComparison.Ordinal)) return;
-
- Write(new
- {
- t = Elapsed(),
- type = "log",
- at = Now(),
- level,
- category,
- msg = message
- });
- }
-
- /// Cambio di modo: Ferma, Osserva, Attiva.
- public void StateChanged(string from, string to)
- {
- Write(new
- {
- t = Elapsed(),
- type = "state",
- at = Now(),
- from,
- to
- });
- }
-
- // ── Interno ──────────────────────────────────────────────────────
-
- private void Write(object payload, bool isSummary = false)
- {
- // Dopo il riepilogo il file è una storia chiusa: una riga in coda al riepilogo
- // farebbe dubitare di tutto ciò che c'è sopra.
- if (_closed && !isSummary) return;
-
- try
- {
- _writer.Write(JsonSerializer.Serialize(payload, Json));
- _events++;
- }
- catch { /* una riga persa non vale l'interruzione di un'asta */ }
- }
-
- private double Elapsed() => Math.Round((DateTime.Now - _startedAt).TotalSeconds, 3);
-
- private static string Now() => DateTime.Now.ToString("HH:mm:ss.fff");
-
- private (int Count, double Avg, int Min, int Max, int Median, int P95) PingStats()
- {
- int[] copy;
- lock (_sync) copy = _pings.Where(p => p > 0).ToArray();
-
- if (copy.Length == 0) return (0, 0, 0, 0, 0, 0);
-
- Array.Sort(copy);
-
- return (
- copy.Length,
- Math.Round(copy.Average(), 1),
- copy[0],
- copy[^1],
- copy[copy.Length / 2],
- copy[Math.Min(copy.Length - 1, (int)(copy.Length * 0.95))]);
- }
-
- /// Nome file leggibile: niente caratteri vietati, niente nomi chilometrici.
- private static string Slug(string? name)
- {
- if (string.IsNullOrWhiteSpace(name)) return "asta";
-
- var clean = Regex.Replace(name.Trim(), @"[^\p{L}\p{Nd}]+", "-").Trim('-');
-
- foreach (var invalid in System.IO.Path.GetInvalidFileNameChars())
- clean = clean.Replace(invalid, '-');
-
- if (clean.Length == 0) return "asta";
-
- return clean.Length <= 48 ? clean : clean[..48].TrimEnd('-');
- }
- }
-}
diff --git a/Mimante/Utilities/AuctionExporter.cs b/Mimante/Utilities/AuctionExporter.cs
index bd992a7..d53a248 100644
--- a/Mimante/Utilities/AuctionExporter.cs
+++ b/Mimante/Utilities/AuctionExporter.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
+using AutoBidder.Data;
using AutoBidder.Models;
namespace AutoBidder.Utilities
@@ -36,13 +37,13 @@ namespace AutoBidder.Utilities
/// Solo aste seguite dall'inizio alla fine: le più preziose per l'analisi.
public bool OnlyComplete { get; set; }
- /// Solo aste di cui esiste il dossier con tutti gli eventi.
+ /// Solo aste di cui esistono gli eventi registrati (interrogazioni e puntate).
public bool OnlyWithDossier { get; set; }
public double? MinFinalPrice { get; set; }
public double? MaxFinalPrice { get; set; }
- /// Includi nel file anche gli eventi del dossier, non solo il riepilogo.
+ /// Includi nel file anche gli eventi registrati, non solo il riepilogo.
public bool IncludeEvents { get; set; } = true;
/// Tetto di aste esportate. 0 = nessuno.
@@ -60,7 +61,8 @@ namespace AutoBidder.Utilities
///
/// Mette in un unico file le aste che rispondono ai criteri scelti, riepilogo e — dove
- /// c'è — tutti gli eventi registrati nel dossier.
+ /// ci sono — tutti gli eventi registrati: interrogazioni, puntate, reset, nostre
+ /// puntate, decisioni del motore, registro.
///
/// Il formato è JSON pensato per essere letto da un'AI: un blocco
/// meta che spiega cosa sono i campi e con che filtri è stato prodotto, e poi le
@@ -77,15 +79,17 @@ namespace AutoBidder.Utilities
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
+ private static AuctionDatabase Db => AuctionDatabase.Instance;
+
/// Le aste che passerebbero il filtro, per mostrarne il conto prima di esportare.
public static List Select(AuctionExportFilter filter)
{
filter ??= new AuctionExportFilter();
- var details = MergedRecords();
- var dossiers = DossierIndex();
+ var details = AuctionDetailStore.LoadAll();
+ var withEvents = filter.OnlyWithDossier ? AuctionDetailStore.WithEvents() : null;
- var selected = details.Where(r => Matches(r, filter, dossiers)).ToList();
+ var selected = details.Where(r => Matches(r, filter, withEvents)).ToList();
return filter.MaxAuctions > 0 ? selected.Take(filter.MaxAuctions).ToList() : selected;
}
@@ -95,10 +99,8 @@ namespace AutoBidder.Utilities
{
try
{
- // Ciò che è ancora in coda deve finire sul disco prima di rileggerlo:
- // esportare un'asta appena conclusa senza le sue ultime righe sarebbe il
- // modo più sottile per ritrovarsi con dati incompleti.
- FileLogWriter.FlushAll();
+ // Ciò che è ancora in coda deve essere sul disco prima di rileggerlo.
+ Db.Flush();
filter ??= new AuctionExportFilter();
@@ -107,9 +109,7 @@ namespace AutoBidder.Utilities
if (selected.Count == 0)
return new AuctionExportResult(false, "", 0, 0, 0, "nessuna asta corrisponde ai filtri");
- var dossiers = DossierIndex();
var withEvents = 0;
-
var auctions = new JsonArray();
foreach (var record in selected)
@@ -118,10 +118,9 @@ namespace AutoBidder.Utilities
node["totalCostIfWon"] = Math.Round(record.TotalCostIfWon(record.BidCostEuro), 2);
- if (filter.IncludeEvents &&
- dossiers.TryGetValue(record.AuctionId, out var dossierPath))
+ if (filter.IncludeEvents)
{
- var events = ReadEvents(dossierPath);
+ var events = ReadEvents(record.AuctionId);
if (events != null)
{
@@ -161,7 +160,7 @@ namespace AutoBidder.Utilities
/// Elenco dei prodotti presenti nello storico, per il menu dei filtri.
public static List KnownProducts() =>
- MergedRecords()
+ AuctionDetailStore.LoadAll()
.Select(r => r.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
@@ -170,10 +169,6 @@ namespace AutoBidder.Utilities
// ── Interno ──────────────────────────────────────────────────────
- ///
- /// Spiega dentro il file cosa contiene e con che filtri è stato prodotto. È la
- /// differenza fra un'esportazione riutilizzabile e un mucchio di numeri.
- ///
private static JsonObject BuildMeta(
AuctionExportFilter filter,
List selected,
@@ -183,7 +178,7 @@ namespace AutoBidder.Utilities
return new JsonObject
{
- ["schema"] = "autobidder.export.v1",
+ ["schema"] = "autobidder.export.v2",
["app"] = AppInfo.Version,
["generatedAt"] = DateTime.Now.ToString("o"),
["auctionCount"] = selected.Count,
@@ -201,7 +196,7 @@ namespace AutoBidder.Utilities
["includeClosed"] = filter.IncludeClosed,
["onlyWithMyBids"] = filter.OnlyWithMyBids,
["onlyComplete"] = filter.OnlyComplete,
- ["onlyWithDossier"] = filter.OnlyWithDossier,
+ ["onlyWithEvents"] = filter.OnlyWithDossier,
["minFinalPrice"] = filter.MinFinalPrice,
["maxFinalPrice"] = filter.MaxFinalPrice
},
@@ -213,105 +208,82 @@ namespace AutoBidder.Utilities
["isComplete"] = "storia completa: sono le aste su cui l'analisi vale davvero",
["priceSeries"] = "prezzo campionato: t = secondi dal primo avvistamento",
["bidsByUser"] = "puntate osservate per utente",
- ["topBidderShare"] = "quota percentuale del puntatore più attivo",
- ["configuredLeadMs"] = "anticipo impostato sulla scadenza",
+ ["topBidderShare"] = "quota del puntatore più attivo",
+ ["configuredLeadMs"] = "anticipo in uso sulla scadenza",
["averagePingMs"] = "latenza media verso Bidoo su questa asta",
["buyNowPrice"] = "valore del prodotto (Compra Subito) al momento dell'asta",
["totalCostIfWon"] = "prezzo finale + puntate spese + spedizione",
- ["events[].type"] = "poll = interrogazione, bid = puntata altrui, " +
- "my_bid = mia puntata (plannedLeadMs vs actualLeadMs), " +
- "reset = timer azzerato, log = registro, summary = riepilogo"
+ ["events[].type"] = "poll = interrogazione, bid = puntata di un utente (mine = nostra), " +
+ "my_bid = nostra puntata (plannedLeadMs vs actualLeadMs), " +
+ "reset = timer azzerato (minTimer = fin dove era sceso), " +
+ "decision = decisione del motore (action, pWin, ev, reason), log = registro"
}
};
}
- ///
- /// Schede dettagliate, completate con i riepiloghi delle aste che non ne hanno una
- /// (quelle concluse prima che il dossier esistesse). Una vista sola su tutto lo
- /// storico: filtrare su due archivi diversi darebbe risultati diversi a seconda
- /// del filtro, che è il modo più sicuro per non fidarsi più dei propri dati.
- ///
- private static List MergedRecords()
- {
- var byId = new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- foreach (var detail in AuctionDetailStore.LoadAll())
- byId[detail.AuctionId] = detail;
-
- foreach (var summary in CompletedAuctionsStore.LoadAll())
- {
- if (byId.ContainsKey(summary.AuctionId)) continue;
-
- byId[summary.AuctionId] = new AuctionDetailRecord
- {
- AuctionId = summary.AuctionId,
- Name = summary.Name,
- ProductKey = summary.ProductKey,
- Url = summary.OriginalUrl,
- Outcome = summary.Outcome,
- WonByMe = summary.WonByMe,
- Winner = summary.Winner,
- FinalPrice = summary.FinalPrice,
- BuyNowPrice = summary.BuyNowPrice,
- MyBids = summary.MyBidsUsed,
- Resets = summary.TotalResets,
- FirstSeenAt = summary.AddedAt,
- EndedAt = summary.EndedAt,
- ObservedMinutes = Math.Max(0, (summary.EndedAt - summary.AddedAt).TotalMinutes)
- };
- }
-
- return byId.Values.OrderByDescending(r => r.EndedAt).ToList();
- }
-
- /// Dossier presenti sul disco, per id asta.
- private static Dictionary DossierIndex()
- {
- var index = new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- try
- {
- if (!Directory.Exists(AppPaths.AuctionLogFolder)) return index;
-
- foreach (var file in Directory.GetFiles(AppPaths.AuctionLogFolder, "*.jsonl"))
- {
- // Il nome finisce con _.jsonl: è il legame con lo storico.
- var name = System.IO.Path.GetFileNameWithoutExtension(file);
- var separator = name.LastIndexOf('_');
-
- if (separator < 0 || separator == name.Length - 1) continue;
-
- index[name[(separator + 1)..]] = file;
- }
- }
- catch { }
-
- return index;
- }
-
- private static JsonArray? ReadEvents(string path)
+ /// Tutti gli eventi registrati di un'asta, in ordine di tempo.
+ private static JsonArray? ReadEvents(string auctionId)
{
try
{
- var events = new JsonArray();
+ var events = new List<(double T, JsonObject Node)>();
- foreach (var line in File.ReadLines(path))
- {
- if (string.IsNullOrWhiteSpace(line)) continue;
-
- try
+ foreach (var r in Db.Query("SELECT * FROM polls WHERE auction_id = ?1 ORDER BY t", auctionId))
+ events.Add((r.Dbl("t"), new JsonObject
{
- var node = JsonNode.Parse(line);
- if (node != null) events.Add(node);
- }
- catch
- {
- // Una riga illeggibile (interruzione a metà scrittura) non deve far
- // perdere l'intero dossier: è il motivo per cui è un record per riga.
- }
- }
+ ["t"] = r.Dbl("t"), ["type"] = "poll", ["at"] = r.StrOrNull("local_ts"),
+ ["price"] = r.Dbl("price"), ["timer"] = r.Dbl("timer"), ["status"] = r.StrOrNull("status"),
+ ["lastBidder"] = r.StrOrNull("last_bidder"), ["mine"] = r.Bool("is_mine"),
+ ["pingMs"] = r.Int("ping_ms"), ["expiryUnix"] = r.Long("expiry_unix"), ["serverUnix"] = r.Long("server_unix")
+ }));
- return events.Count > 0 ? events : null;
+ foreach (var r in Db.Query("SELECT * FROM bids WHERE auction_id = ?1 ORDER BY COALESCE(server_ts, 0), price_after", auctionId))
+ events.Add((r.Dbl("seen_t"), new JsonObject
+ {
+ ["t"] = r.Dbl("seen_t"), ["type"] = "bid", ["at"] = r.StrOrNull("local_ts"), ["user"] = r.Str("username"),
+ ["bidAt"] = r.LongOrNull("server_ts") is { } ts ? DateTimeOffset.FromUnixTimeSeconds(ts).ToLocalTime().ToString("o") : null,
+ ["bidType"] = r.Str("bid_type"), ["mine"] = r.Bool("is_mine"), ["price"] = r.Dbl("price_after"),
+ ["timerBefore"] = r.DblOrNull("timer_before")
+ }));
+
+ foreach (var r in Db.Query("SELECT * FROM resets WHERE auction_id = ?1 ORDER BY t", auctionId))
+ events.Add((r.Dbl("t"), new JsonObject
+ {
+ ["t"] = r.Dbl("t"), ["type"] = "reset", ["at"] = r.StrOrNull("local_ts"), ["resetCount"] = r.Int("reset_count"),
+ ["price"] = r.Dbl("price"), ["bidder"] = r.StrOrNull("bidder"),
+ ["minTimer"] = r.DblOrNull("min_timer"), ["cycleSeconds"] = r.DblOrNull("cycle_seconds")
+ }));
+
+ foreach (var r in Db.Query("SELECT * FROM my_bids WHERE auction_id = ?1 ORDER BY t", auctionId))
+ events.Add((r.Dbl("t"), new JsonObject
+ {
+ ["t"] = r.Dbl("t"), ["type"] = "my_bid", ["at"] = r.StrOrNull("local_ts"), ["price"] = r.Dbl("price"),
+ ["plannedLeadMs"] = r.Int("planned_lead_ms"), ["actualLeadMs"] = r.Dbl("actual_lead_ms"),
+ ["pingMs"] = r.Int("ping_ms"), ["rttMs"] = r.Int("rtt_ms"), ["result"] = r.Bool("success") ? "ok" : "fail",
+ ["error"] = r.StrOrNull("error"), ["bidsUsed"] = r.IntOrNull("bids_used"), ["remainingBids"] = r.IntOrNull("remaining_bids")
+ }));
+
+ foreach (var r in Db.Query("SELECT * FROM bot_decisions WHERE auction_id = ?1 ORDER BY ts", auctionId))
+ events.Add((r.Dbl("t"), new JsonObject
+ {
+ ["t"] = r.DblOrNull("t"), ["type"] = "decision", ["at"] = r.Str("ts"), ["price"] = r.DblOrNull("price"),
+ ["timer"] = r.DblOrNull("timer"), ["action"] = r.Str("action"), ["pWin"] = r.DblOrNull("p_win"),
+ ["ev"] = r.DblOrNull("ev_estimate"), ["mode"] = r.Str("mode"), ["regime"] = r.StrOrNull("regime"),
+ ["executed"] = r.Bool("executed"), ["outcome"] = r.StrOrNull("outcome"), ["reason"] = r.StrOrNull("reason_detail")
+ }));
+
+ foreach (var r in Db.Query("SELECT * FROM auction_log WHERE auction_id = ?1 ORDER BY t", auctionId))
+ events.Add((r.Dbl("t"), new JsonObject
+ {
+ ["t"] = r.Dbl("t"), ["type"] = "log", ["at"] = r.StrOrNull("local_ts"), ["level"] = r.StrOrNull("level"),
+ ["category"] = r.StrOrNull("category"), ["msg"] = r.StrOrNull("msg")
+ }));
+
+ if (events.Count == 0) return null;
+
+ var array = new JsonArray();
+ foreach (var (_, node) in events.OrderBy(e => e.T)) array.Add(node);
+ return array;
}
catch
{
@@ -322,10 +294,10 @@ namespace AutoBidder.Utilities
private static bool Matches(
AuctionDetailRecord record,
AuctionExportFilter filter,
- Dictionary dossiers)
+ HashSet? withEvents)
{
- if (filter.From.HasValue && record.EndedAt < filter.From.Value.Date) return false;
- if (filter.To.HasValue && record.EndedAt > filter.To.Value.Date.AddDays(1)) return false;
+ if (filter.From.HasValue && record.EndedAt.ToLocalTime() < filter.From.Value.Date) return false;
+ if (filter.To.HasValue && record.EndedAt.ToLocalTime() > filter.To.Value.Date.AddDays(1)) return false;
if (!string.IsNullOrWhiteSpace(filter.NameContains) &&
!record.Name.Contains(filter.NameContains.Trim(), StringComparison.OrdinalIgnoreCase))
@@ -345,7 +317,7 @@ namespace AutoBidder.Utilities
if (filter.OnlyWithMyBids && record.MyBids == 0) return false;
if (filter.OnlyComplete && !record.IsComplete) return false;
- if (filter.OnlyWithDossier && !dossiers.ContainsKey(record.AuctionId)) return false;
+ if (withEvents != null && !withEvents.Contains(record.AuctionId)) return false;
if (filter.MinFinalPrice.HasValue && record.FinalPrice < filter.MinFinalPrice.Value) return false;
if (filter.MaxFinalPrice.HasValue && record.FinalPrice > filter.MaxFinalPrice.Value) return false;
diff --git a/Mimante/Utilities/CompletedAuctionsStore.cs b/Mimante/Utilities/CompletedAuctionsStore.cs
index 6c1dc49..6395533 100644
--- a/Mimante/Utilities/CompletedAuctionsStore.cs
+++ b/Mimante/Utilities/CompletedAuctionsStore.cs
@@ -2,184 +2,206 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
-using System.Text.Json;
+using AutoBidder.Data;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
///
- /// Storico delle aste concluse su file JSON dedicato (completed-auctions.json),
- /// separato da impostazioni e aste attive. Fornisce salvataggio incrementale
- /// e aggregazioni per la sezione Statistiche. Nessun database.
+ /// Lo storico delle aste concluse, letto e scritto nel database.
+ ///
+ /// Tiene la superficie di sempre — aggiungi, leggi tutte, sostituisci, copia di
+ /// sicurezza, svuota — perché la usano in dieci punti diversi, dalla griglia Storico
+ /// al recupero delle puntate dei vincitori. Dietro non c'è più un file JSON riscritto
+ /// per intero a ogni asta: ogni record è una riga della tabella auctions, la
+ /// stessa che il registratore riempie mentre l'asta è in corso. Un'asta è una riga,
+ /// non due archivi da tenere allineati.
+ ///
+ /// I record restituiti sono copie: chi ne modifica uno deve salvarlo con
+ /// o .
///
public static class CompletedAuctionsStore
{
private static readonly object _lock = new();
- // Percorsi centralizzati in AppPaths: la cartella statistiche e' configurabile,
- // quindi si legge a ogni uso invece di fissarla all'avvio.
- private static string AppDataFolder => AppPaths.StatsFolder;
- private static string FilePath => AppPaths.CompletedAuctionsFile;
+ private static AuctionDatabase Db => AuctionDatabase.Instance;
- private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
-
- // ── Copia in memoria ─────────────────────────────────────────────
- //
- // Il file cresce di un record per asta e arriva in fretta a qualche megabyte:
- // a 5876 aste, leggerlo e deserializzarlo costa ~72 ms misurati. Senza copia
- // in memoria quel prezzo si paga a ogni apertura delle statistiche e a ogni
- // asta che finisce — e quella chiamata arriva da un gestore di eventi, cioe'
- // dal thread dell'interfaccia, che resta fermo per tutto il tempo.
- //
- // L'invalidazione guarda la data di modifica del file, non un tempo di
- // scadenza: cosi' la copia resta giusta anche se il file viene sostituito da
- // fuori — ripristino di un backup, modifica a mano — che qui capita davvero.
- //
- // Il prezzo da sapere: i record restituiti sono gli STESSI oggetti che stanno
- // nella copia. Chi ne modifica uno sta modificando anche la copia, prima e
- // indipendentemente dal salvataggio. Repair e il recupero puntate contano su
- // questo; chi non lo vuole si faccia una copia del record.
private static List? _cache;
- private static DateTime _cacheStamp;
+ private static long _cacheVersion = -1;
- /// Percorso del file storico (per mostrarlo/aprirlo nella UI).
- public static string StorePath => FilePath;
+ /// Il file del database (per mostrarlo o aprirne la cartella).
+ public static string StorePath => Db.Path;
- ///
- /// Aggiunge un'asta conclusa, oppure sostituisce quella con lo stesso id.
- /// Un'asta puo' essere registrata piu' volte (fine vista dal motore, poi rimozione
- /// dal monitor): l'ultima versione e' quella buona, non una riga in piu'.
- ///
+ private const string Upsert =
+ "INSERT INTO auctions(auction_id, product_key, name, url, status, added_at, first_seen_at, opened_at, " +
+ " end_time, closed_at, final_price, winner_user, won_by_me, outcome, my_bids, resets, buy_now_price, " +
+ " winner_bids_paid, winner_bids_free) " +
+ "VALUES(?1, ?2, ?3, ?4, 'closed', ?5, ?5, ?5, ?6, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) " +
+ "ON CONFLICT(auction_id) DO UPDATE SET " +
+ " product_key = CASE WHEN excluded.product_key != '' THEN excluded.product_key ELSE product_key END, " +
+ " name = CASE WHEN excluded.name != '' THEN excluded.name ELSE name END, " +
+ " url = CASE WHEN excluded.url != '' THEN excluded.url ELSE url END, " +
+ " status = 'closed', end_time = excluded.end_time, closed_at = COALESCE(closed_at, excluded.closed_at), " +
+ " final_price = excluded.final_price, winner_user = excluded.winner_user, won_by_me = excluded.won_by_me, " +
+ " outcome = excluded.outcome, my_bids = excluded.my_bids, resets = MAX(resets, excluded.resets), " +
+ " buy_now_price = COALESCE(excluded.buy_now_price, buy_now_price), " +
+ " winner_bids_paid = COALESCE(excluded.winner_bids_paid, winner_bids_paid), " +
+ " winner_bids_free = COALESCE(excluded.winner_bids_free, winner_bids_free)";
+
+ private const string Select =
+ "SELECT auction_id, name, product_key, url, final_price, winner_user, won_by_me, outcome, my_bids, resets, " +
+ " buy_now_price, winner_bids_paid, winner_bids_free, added_at, end_time, closed_at " +
+ "FROM auctions WHERE outcome IS NOT NULL ORDER BY end_time DESC";
+
+ /// Aggiunge un'asta conclusa, oppure aggiorna quella con lo stesso id.
public static void Append(CompletedAuctionRecord record)
{
if (record == null) return;
AppendMany(new[] { record });
}
- ///
- /// Aggiunge o sostituisce piu' aste con un solo salvataggio.
- ///
- /// Serve a chi ne tocca tante di fila, come il recupero delle puntate dei
- /// vincitori. Chiamare in un ciclo rilegge e riscrive tutto
- /// il file a ogni giro: sulle 4675 aste di un recupero completo erano minuti di
- /// solo rimescolamento JSON e svariati gigabyte scritti sul disco per nulla.
- ///
+ /// Aggiunge o aggiorna più aste in una transazione sola.
public static void AppendMany(IReadOnlyCollection records)
{
if (records == null || records.Count == 0) return;
- lock (_lock)
+ var ops = new List<(string, object?[])>(records.Count);
+ foreach (var r in records)
{
- var list = LoadInternal();
-
- // Indice per id: cercare ogni record con un FindIndex renderebbe
- // quadratico proprio il caso per cui questo metodo esiste.
- var byId = new Dictionary(list.Count, StringComparer.Ordinal);
- for (var i = 0; i < list.Count; i++)
- {
- var id = list[i].AuctionId;
- if (!string.IsNullOrEmpty(id)) byId[id] = i;
- }
-
- foreach (var record in records)
- {
- if (record == null) continue;
-
- var id = record.AuctionId;
-
- if (!string.IsNullOrEmpty(id) && byId.TryGetValue(id, out var at))
- {
- list[at] = record;
- }
- else
- {
- if (!string.IsNullOrEmpty(id)) byId[id] = list.Count;
- list.Add(record);
- }
- }
-
- SaveInternal(list);
+ if (r == null) continue;
+ ops.Add((Upsert, Args(r)));
}
+
+ if (ops.Count == 0) return;
+ Db.EnqueueBatch(ops);
}
- /// Tutte le aste concluse, dalla piu' recente.
+ private static object?[] Args(CompletedAuctionRecord r)
+ {
+ // Un record senza id non può essere una chiave primaria: gliene si dà uno
+ // proprio, così resta nello storico invece di sparire o di sovrascriverne un altro.
+ var id = string.IsNullOrEmpty(r.AuctionId) ? "senza-id-" + Guid.NewGuid().ToString("N") : r.AuctionId;
+ if (string.IsNullOrEmpty(r.AuctionId)) r.AuctionId = id;
+
+ return new object?[]
+ {
+ id, r.ProductKey ?? "", r.Name ?? "", r.OriginalUrl ?? "",
+ AuctionDatabase.Iso(r.AddedAt == default ? r.EndedAt : r.AddedAt),
+ AuctionDatabase.Iso(r.EndedAt),
+ r.FinalPrice, r.Winner ?? "", r.WonByMe, string.IsNullOrEmpty(r.Outcome) ? "Chiusa" : r.Outcome,
+ r.MyBidsUsed, r.TotalResets, r.BuyNowPrice, r.WinnerBidsPaid, r.WinnerBidsFree
+ };
+ }
+
+ /// Tutte le aste concluse, dalla più recente. Copie: modificarle non cambia l'archivio.
public static List LoadAll()
{
+ Db.Flush();
+
lock (_lock)
{
- return LoadInternal()
- .OrderByDescending(r => r.EndedAt)
- .ToList();
+ var version = Db.AuctionsVersion;
+ if (_cache != null && version == _cacheVersion) return _cache.Select(Clone).ToList();
+
+ var list = Db.Query(Select).Select(FromRow).ToList();
+ _cache = list;
+ _cacheVersion = version;
+ return list.Select(Clone).ToList();
}
}
+ internal static CompletedAuctionRecord FromRow(SqliteRow row) => new()
+ {
+ AuctionId = row.Str("auction_id"),
+ Name = row.Str("name"),
+ ProductKey = row.Str("product_key"),
+ OriginalUrl = row.Str("url"),
+ FinalPrice = row.Dbl("final_price"),
+ Winner = row.Str("winner_user"),
+ WonByMe = row.Bool("won_by_me"),
+ Outcome = row.Str("outcome"),
+ MyBidsUsed = row.Int("my_bids"),
+ TotalResets = row.Int("resets"),
+ BuyNowPrice = row.DblOrNull("buy_now_price"),
+ WinnerBidsPaid = row.IntOrNull("winner_bids_paid"),
+ WinnerBidsFree = row.IntOrNull("winner_bids_free"),
+ AddedAt = row.Date("added_at") ?? DateTime.MinValue,
+ EndedAt = row.Date("end_time") ?? row.Date("closed_at") ?? DateTime.MinValue
+ };
+
+ private static CompletedAuctionRecord Clone(CompletedAuctionRecord r) => new()
+ {
+ AuctionId = r.AuctionId, Name = r.Name, ProductKey = r.ProductKey, OriginalUrl = r.OriginalUrl,
+ FinalPrice = r.FinalPrice, Winner = r.Winner, WonByMe = r.WonByMe, Outcome = r.Outcome,
+ MyBidsUsed = r.MyBidsUsed, TotalResets = r.TotalResets, BuyNowPrice = r.BuyNowPrice,
+ WinnerBidsPaid = r.WinnerBidsPaid, WinnerBidsFree = r.WinnerBidsFree,
+ AddedAt = r.AddedAt, EndedAt = r.EndedAt
+ };
+
///
- /// Sostituisce l'intero storico. Usato dalla pulizia e dalla riparazione: sono le
- /// due sole operazioni che riscrivono tutto invece di aggiungere una riga.
+ /// Tiene soltanto le aste indicate (aggiornandole) e cancella le altre, con tutto
+ /// ciò che ne dipende. È l'operazione della pulizia: si passa dalla copia di
+ /// sicurezza prima.
///
public static void ReplaceAll(List records)
{
if (records == null) return;
- lock (_lock) SaveInternal(records);
+
+ var ops = new List<(string, object?[])>(records.Count * 2 + 4)
+ {
+ ("CREATE TEMP TABLE IF NOT EXISTS keep_ids(id TEXT PRIMARY KEY)", Array.Empty()),
+ ("DELETE FROM keep_ids", Array.Empty())
+ };
+
+ foreach (var r in records)
+ {
+ if (r == null) continue;
+ var args = Args(r);
+ ops.Add(("INSERT OR IGNORE INTO keep_ids(id) VALUES(?1)", new object?[] { args[0] }));
+ }
+
+ ops.Add(("DELETE FROM auctions WHERE outcome IS NOT NULL AND auction_id NOT IN (SELECT id FROM keep_ids)", Array.Empty()));
+
+ foreach (var r in records)
+ {
+ if (r == null) continue;
+ ops.Add((Upsert, Args(r)));
+ }
+
+ ops.Add(("DELETE FROM keep_ids", Array.Empty()));
+
+ Db.EnqueueBatch(ops);
+ Db.Flush();
}
- ///
- /// Ripara all'avvio i campi deducibili e salva solo se qualcosa e' cambiato davvero.
- /// Restituisce il resoconto per il registro.
- ///
+ /// Ripara all'avvio i campi deducibili e salva solo se qualcosa è cambiato.
public static StatsMaintenance.RepairReport RepairOnStartup()
{
- lock (_lock)
- {
- var list = LoadInternal();
- var report = StatsMaintenance.Repair(list);
-
- // Riscrivere un file da qualche megabyte a ogni avvio, per nulla, e' uno
- // spreco che si nota: si salva soltanto se c'e' stato un cambiamento.
- if (report.AnythingDone) SaveInternal(list);
-
- return report;
- }
+ var list = LoadAll();
+ var report = StatsMaintenance.Repair(list);
+ if (report.AnythingDone) AppendMany(list);
+ return report;
}
///
- /// Copia lo storico in Backups con la data nel nome, e restituisce il
- /// percorso. Serve prima di ogni operazione che riscrive tutto: la pulizia non e'
- /// reversibile, e mesi di raccolta non possono dipendere da un clic sicuro di se'.
+ /// Copia l'intero database in Backups con la data nel nome, e restituisce
+ /// il percorso. Prima di ogni operazione che cancella.
///
public static string BackupNow()
{
- lock (_lock)
- {
- var cartella = AppPaths.BackupsFolder;
- Directory.CreateDirectory(cartella);
+ var cartella = AppPaths.BackupsFolder;
+ Directory.CreateDirectory(cartella);
- var nome = $"completed-auctions_{DateTime.Now:yyyyMMdd_HHmmss}.json";
- var destinazione = Path.Combine(cartella, nome);
-
- if (File.Exists(FilePath)) File.Copy(FilePath, destinazione, overwrite: true);
- else File.WriteAllText(destinazione, "[]");
-
- return destinazione;
- }
+ var destinazione = Path.Combine(cartella, $"autobidder_{DateTime.Now:yyyyMMdd_HHmmss}.sqlite");
+ Db.BackupTo(destinazione);
+ return destinazione;
}
- /// Svuota lo storico cancellando il file.
+ /// Svuota tutte le osservazioni (aste, puntate, decisioni, misure). I prodotti restano.
public static void Clear()
{
- lock (_lock)
- {
- try
- {
- if (File.Exists(FilePath)) File.Delete(FilePath);
- _cache = null;
- }
- catch (Exception ex)
- {
- Console.WriteLine($"[STATS] Errore pulizia storico: {ex.Message}");
- _cache = null;
- }
- }
+ Db.WipeObservations();
+ lock (_lock) { _cache = null; _cacheVersion = -1; }
}
///
@@ -214,16 +236,13 @@ namespace AutoBidder.Utilities
AverageMyBids = items.Count > 0 ? items.Average(x => x.MyBidsUsed) : 0,
// Le chiusure per intero, non solo min/media/max: la forma della
- // distribuzione e' cio' su cui PriceAdvisor decide, e riassumerla
- // in tre numeri butterebbe via proprio l'informazione che serve.
+ // distribuzione e' cio' su cui PriceAdvisor decide.
FinalPrices = withPrice.Select(x => x.FinalPrice).OrderBy(v => v).ToList(),
BuyNowPrice = withPrice
.Select(x => x.BuyNowPrice)
.LastOrDefault(v => v is > 0),
- // Solo i conteggi che superano il controllo di coerenza: un dato
- // storto nelle medie sposterebbe i limiti di prezzo senza che
- // nessuno se ne accorga. Vedi AuctionIntegrity.
+ // Solo i conteggi che superano il controllo di coerenza. Vedi AuctionIntegrity.
WinnerBids = items
.Select(AuctionIntegrity.TrustedWinnerBids)
.Where(b => b.HasValue)
@@ -231,10 +250,6 @@ namespace AutoBidder.Utilities
.OrderBy(b => b)
.ToList(),
- // Pagate e gratis separate. Serve perche' cambia la sostanza del
- // conto: sui dati raccolti l'84% delle puntate dei vincitori sono
- // gratuite, quindi il costo in denaro di una vittoria e' molto piu'
- // basso di quanto direbbe il solo numero di puntate.
WinnerBidsPaidTotal = items
.Where(x => AuctionIntegrity.CheckWinnerBids(x).IsTrustworthy)
.Sum(x => x.WinnerBidsPaid ?? 0),
@@ -242,9 +257,6 @@ namespace AutoBidder.Utilities
.Where(x => AuctionIntegrity.CheckWinnerBids(x).IsTrustworthy)
.Sum(x => x.WinnerBidsFree ?? 0),
- // Risparmio asta per asta: si puo' calcolare solo dove si conoscono
- // sia il valore sia le puntate del vincitore, quindi la lista e'
- // piu' corta del totale. E' voluto: meglio poche misure vere.
SavingsPercents = items
.Where(x => x.BuyNowPrice is > 0)
.Select(x => new { x, bids = AuctionIntegrity.TrustedWinnerBids(x) })
@@ -255,8 +267,6 @@ namespace AutoBidder.Utilities
.OrderBy(v => v)
.ToList(),
- // Ogni quanto passa: serve a sapere se conviene aspettare la
- // prossima invece di inseguire questa.
HoursBetweenAuctions = items.Count >= 2
? Math.Round((items.Max(x => x.EndedAt) - items.Min(x => x.EndedAt))
.TotalHours / (items.Count - 1), 2)
@@ -270,64 +280,6 @@ namespace AutoBidder.Utilities
.OrderByDescending(s => s.Count)
.ToList();
}
-
- private static List LoadInternal()
- {
- try
- {
- if (!File.Exists(FilePath))
- {
- _cache = null;
- return new List();
- }
-
- // Una lettura di data di modifica costa microsecondi; la lettura piu'
- // la deserializzazione che evita ne costa decine di millisecondi.
- var stamp = File.GetLastWriteTimeUtc(FilePath);
- if (_cache != null && stamp == _cacheStamp) return _cache;
-
- var json = File.ReadAllText(FilePath);
-
- var list = string.IsNullOrWhiteSpace(json)
- ? new List()
- : JsonSerializer.Deserialize>(json)
- ?? new List();
-
- _cache = list;
- _cacheStamp = stamp;
- return list;
- }
- catch (Exception ex)
- {
- Console.WriteLine($"[STATS] Errore lettura storico: {ex.Message}");
-
- // Non si sa cosa ci sia sul disco: la prossima volta si rilegge.
- _cache = null;
- return new List();
- }
- }
-
- private static void SaveInternal(List list)
- {
- try
- {
- if (!Directory.Exists(AppDataFolder))
- Directory.CreateDirectory(AppDataFolder);
-
- var json = JsonSerializer.Serialize(list, JsonOptions);
- File.WriteAllText(FilePath, json);
-
- // La copia in memoria e' esattamente quello che si e' appena scritto:
- // tenerla evita di rileggere il file al primo uso successivo.
- _cache = list;
- _cacheStamp = File.GetLastWriteTimeUtc(FilePath);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"[STATS] Errore salvataggio storico: {ex.Message}");
- _cache = null;
- }
- }
}
/// Riepilogo per prodotto, ricavato dallo storico delle aste concluse.
@@ -356,21 +308,13 @@ namespace AutoBidder.Utilities
///
/// Puntate spese dai vincitori, in ordine crescente. Ci sono solo le aste il cui
- /// conteggio ha superato il controllo di coerenza, quindi la lista puo' essere piu'
- /// corta di : e' voluto, meglio poche misure buone che molte
- /// dubbie.
+ /// conteggio ha superato il controllo di coerenza.
///
public List WinnerBids { get; set; } = new();
/// Su quante aste il conteggio delle puntate e' utilizzabile.
public int WinnerBidsSample => WinnerBids.Count;
- // ── Le puntate che servono per vincere: tutta la distribuzione ────
- //
- // Non solo la mediana: minimo e massimo dicono se il prodotto e' regolare o se
- // ogni tanto degenera, e i quartili dicono quanto e' larga la forbice. Su un
- // articolo dove si vince con 12 puntate o con 900 la mediana da sola inganna.
-
public int MinWinnerBids => WinnerBids.Count == 0 ? 0 : WinnerBids[0];
public int MaxWinnerBids => WinnerBids.Count == 0 ? 0 : WinnerBids[^1];
@@ -385,8 +329,6 @@ namespace AutoBidder.Utilities
public string MaxWinnerBidsDisplay => WinnerBids.Count == 0 ? "—" : MaxWinnerBids.ToString();
public string P75WinnerBidsDisplay => WinnerBids.Count == 0 ? "—" : P75WinnerBids.ToString("F0");
- // ── Prezzi: quartili oltre a min/media/max ───────────────────────
-
public double MedianFinalPrice => Pct(FinalPrices, 0.50);
public double P25FinalPrice => Pct(FinalPrices, 0.25);
public double P75FinalPrice => Pct(FinalPrices, 0.75);
@@ -395,12 +337,6 @@ namespace AutoBidder.Utilities
public string MedianFinalPriceDisplay => FinalPrices.Count == 0 ? "—" : $"{MedianFinalPrice:F2} €";
public string P75FinalPriceDisplay => FinalPrices.Count == 0 ? "—" : $"{P75FinalPrice:F2} €";
- // ── Risparmio: non solo il tipico, ma il migliore e il peggiore ──
- //
- // Si calcola asta per asta, non sui riassunti: il risparmio della vittoria
- // piu' economica non e' il valore meno il prezzo minimo, perche' l'asta piu'
- // economica non e' detto sia anche quella con meno puntate.
-
/// Risparmio percentuale di ogni asta di cui si conosce tutto, in ordine crescente.
public List SavingsPercents { get; set; } = new();
@@ -440,12 +376,7 @@ namespace AutoBidder.Utilities
///
/// Quota di puntate gratuite fra quelle spese dai vincitori, in percentuale.
- ///
- /// Non e' un dettaglio: sui dati raccolti e' l'84%. Vuol dire che chi vince
- /// queste aste lo fa in gran parte con puntate regalate, e che il costo in denaro di
- /// una vittoria e' molto piu' basso del numero di puntate moltiplicato per il loro
- /// prezzo. Il costo mostrato resta quello prudente - tutte pagate - ma questa quota
- /// dice di quanto si puo' abbassare accumulando puntate gratis.
+ /// Sui dati raccolti e' l'84%: chi vince lo fa in gran parte con puntate regalate.
///
public double? FreeBidShare
{
@@ -466,9 +397,7 @@ namespace AutoBidder.Utilities
///
/// Quanto costa davvero vincere: il prezzo tipico di aggiudicazione piu' le puntate
- /// che sono servite a portarlo li'. Il prezzo da solo racconta meta' della storia -
- /// un buono da 50 EUR aggiudicato a 4 EUR con 200 puntate e' costato 44 EUR.
- /// null se le puntate non sono note: meglio nessun numero che uno finto.
+ /// che sono servite a portarlo li'. null se le puntate non sono note.
///
public double? TypicalWinCost
{
@@ -485,8 +414,7 @@ namespace AutoBidder.Utilities
///
/// Risparmio percentuale sul valore del prodotto, calcolato sul costo vero di una
- /// vittoria tipica. null se manca il valore o il conteggio delle puntate.
- /// Puo' essere negativo: vincere costa piu' che comprare, e va detto.
+ /// vittoria tipica. Puo' essere negativo: vincere costa piu' che comprare, e va detto.
///
public double? TypicalSavingsPercent
{
@@ -501,8 +429,6 @@ namespace AutoBidder.Utilities
}
}
- // ── Colonne della griglia ────────────────────────────────────────
-
public double WinRate => Count > 0 ? WonCount * 100.0 / Count : 0;
public string WinRateDisplay => Count > 0 ? $"{WinRate:F0}%" : "—";
@@ -515,10 +441,6 @@ namespace AutoBidder.Utilities
/// Tetto di prezzo suggerito. Delega a : il criterio
/// dev'essere uno solo, altrimenti la scheda Storico e la scheda Prodotti
/// mostrerebbero due numeri diversi per lo stesso articolo.
- ///
- /// Qui i limiti propri del prodotto non si conoscono, quindi valgono i
- /// predefiniti generali. Il numero completo, che tiene conto delle puntate massime
- /// impostate su quell'articolo, sta nella scheda Prodotti.
///
public double SuggestedMaxPrice
{
diff --git a/Mimante/Utilities/DecisionExporter.cs b/Mimante/Utilities/DecisionExporter.cs
new file mode 100644
index 0000000..aea8b21
--- /dev/null
+++ b/Mimante/Utilities/DecisionExporter.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using AutoBidder.Data;
+
+namespace AutoBidder.Utilities
+{
+ ///
+ /// Esporta le decisioni del motore in CSV con delimitatore punto e virgola e la
+ /// colonna reason_detail con la motivazione per esteso — come chiede il
+ /// documento di progetto (§9). Una riga per decisione, in ordine di tempo, con
+ /// l'esito noto alla chiusura dell'asta.
+ ///
+ public static class DecisionExporter
+ {
+ public sealed record Result(bool Success, string Path, int Rows, string Message);
+
+ public static Result ExportCsv(string? destination = null, int maxRows = 0)
+ {
+ try
+ {
+ var db = AuctionDatabase.Instance;
+ db.Flush();
+
+ var sql =
+ "SELECT d.decision_id, d.ts, d.auction_id, a.name, a.product_key, d.mode, d.action, d.price, d.timer, " +
+ " d.p_win, d.ev_estimate, d.policy, d.regime, d.executed, d.outcome, d.model_version, d.reason_detail, " +
+ " a.final_price, a.outcome AS auction_outcome, a.won_by_me " +
+ "FROM bot_decisions d LEFT JOIN auctions a ON a.auction_id = d.auction_id " +
+ "ORDER BY d.decision_id" + (maxRows > 0 ? $" LIMIT {maxRows}" : "");
+
+ var rows = db.Query(sql);
+ if (rows.Count == 0) return new Result(false, "", 0, "Nessuna decisione registrata finora.");
+
+ var path = destination ?? Path.Combine(AppPaths.ExportFolder, $"decisioni-{DateTime.Now:yyyyMMdd-HHmmss}.csv");
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+
+ var sb = new StringBuilder();
+ sb.AppendLine(string.Join(';',
+ "decision_id", "ts", "auction_id", "asta", "prodotto", "modalita", "azione", "prezzo", "timer",
+ "p_win", "ev_stimato", "policy", "regime", "eseguita", "esito", "versione_modello",
+ "prezzo_finale", "esito_asta", "vinta_da_me", "reason_detail"));
+
+ foreach (var r in rows)
+ {
+ sb.AppendLine(string.Join(';',
+ r.Long("decision_id"),
+ r.Str("ts"),
+ Csv(r.Str("auction_id")),
+ Csv(r.Str("name")),
+ Csv(r.Str("product_key")),
+ r.Str("mode"),
+ r.Str("action"),
+ Num(r.DblOrNull("price")),
+ Num(r.DblOrNull("timer")),
+ Num(r.DblOrNull("p_win"), 5),
+ Num(r.DblOrNull("ev_estimate"), 4),
+ Csv(r.Str("policy")),
+ Csv(r.Str("regime")),
+ r.Bool("executed") ? "1" : "0",
+ Csv(r.Str("outcome")),
+ Csv(r.Str("model_version")),
+ Num(r.DblOrNull("final_price")),
+ Csv(r.Str("auction_outcome")),
+ r.Bool("won_by_me") ? "1" : "0",
+ Csv(r.Str("reason_detail"))));
+ }
+
+ File.WriteAllText(path, sb.ToString(), new UTF8Encoding(true));
+ return new Result(true, path, rows.Count, $"{rows.Count} decisioni esportate");
+ }
+ catch (Exception ex)
+ {
+ return new Result(false, "", 0, $"esportazione non riuscita: {ex.Message}");
+ }
+ }
+
+ /// Virgola decimale, come si aspetta Excel in italiano; vuoto se manca.
+ private static string Num(double? v, int decimals = 2) =>
+ v is { } x && double.IsFinite(x) ? x.ToString("F" + decimals, CultureInfo.InvariantCulture).Replace('.', ',') : "";
+
+ private static string Csv(string? s)
+ {
+ if (string.IsNullOrEmpty(s)) return "";
+ var clean = s.Replace("\r", " ").Replace("\n", " ");
+ return clean.Contains(';') || clean.Contains('"')
+ ? "\"" + clean.Replace("\"", "\"\"") + "\""
+ : clean;
+ }
+ }
+}
diff --git a/Mimante/Utilities/ProductStatsStore.cs b/Mimante/Utilities/ProductStatsStore.cs
index 95d9a97..c7d5104 100644
--- a/Mimante/Utilities/ProductStatsStore.cs
+++ b/Mimante/Utilities/ProductStatsStore.cs
@@ -57,6 +57,9 @@ namespace AutoBidder.Utilities
public double? MinPrice { get; set; }
public double? MaxPrice { get; set; }
public int? MaxBids { get; set; }
+
+ /// Valore reale V scelto dall'utente; null = Compralo Ora.
+ public double? MarketValue { get; set; }
public string? AutoAddState { get; set; }
/// Orizzonte proprio per l'aggiunta automatica, in minuti.
@@ -242,8 +245,23 @@ namespace AutoBidder.Utilities
card.MinPrice = product.MinPrice;
card.MaxPrice = product.MaxPrice;
card.MaxBids = product.MaxClicks;
+ card.MarketValue = product.MarketValue;
card.MaxStartMinutes = product.MaxStartMinutes;
card.AutoAddState = product.AutoAddState;
+
+ // Il valore reale finisce anche sul prodotto nel database: è da lì
+ // che le analisi lo leggono, e da lì che le aste nuove lo ereditano.
+ try
+ {
+ var now = Data.AuctionDatabase.Now();
+ Data.AuctionDatabase.Instance.Enqueue(
+ "INSERT INTO items(product_key, title, market_price_est, market_price_source, first_seen_at, updated_at) " +
+ "VALUES(?1, ?2, ?3, CASE WHEN ?3 IS NULL THEN NULL ELSE 'utente' END, ?4, ?4) " +
+ "ON CONFLICT(product_key) DO UPDATE SET market_price_est = excluded.market_price_est, " +
+ " market_price_source = excluded.market_price_source, updated_at = excluded.updated_at",
+ key, product.DisplayName ?? "", product.MarketValue, now);
+ }
+ catch { /* il database non deve fermare il salvataggio delle opzioni */ }
}
Save(data);
diff --git a/Mimante/Utilities/SettingsManager.cs b/Mimante/Utilities/SettingsManager.cs
index 1e9b7c2..62ea27a 100644
--- a/Mimante/Utilities/SettingsManager.cs
+++ b/Mimante/Utilities/SettingsManager.cs
@@ -159,6 +159,12 @@ namespace AutoBidder.Utilities
///
public string LogFolder { get; set; } = "";
+ ///
+ /// File del database SQLite. Vuoto = %LocalAppData%\AutoBidder\Database\autobidder.sqlite.
+ /// Non va messo in una cartella sincronizzata.
+ ///
+ public string DatabasePath { get; set; } = "";
+
// ═══════════════════════════════════════════════════════════════════
// REGISTRI SU FILE
// ═══════════════════════════════════════════════════════════════════
@@ -593,6 +599,29 @@ namespace AutoBidder.Utilities
/// Default: 0
///
public double DailyBudgetEuro { get; set; } = 0;
+
+ // ── Gestore del rischio (vedi Services/RiskManager) ──────────────
+ // Tutti in euro o in numero, tutti dell'utente, tutti con 0 = nessun limite.
+ // Lo stop-loss e il drawdown portano in HALT, che resta finché non lo si toglie.
+
+ /// Perdita netta del giorno oltre la quale si entra in HALT (0 = mai).
+ public double DailyStopLossEuro { get; set; } = 0;
+
+ /// Discesa massima del saldo cumulato dal suo picco oltre la quale si entra in HALT (0 = mai).
+ public double MaxDrawdownEuro { get; set; } = 0;
+
+ /// Quante aste al massimo su cui spendere insieme (0 = nessun limite).
+ public int MaxConcurrentActiveAuctions { get; set; } = 0;
+
+ /// Fee di transazione sulla vincita: circa 1 € su Bidoo. Entra nel valore atteso.
+ public double TransactionFeeEuro { get; set; } = 1.0;
+
+ ///
+ /// Considerare il «Compralo Ora» come copertura: chi perde compra al listino
+ /// recuperando le puntate spese, e la perdita si limita a listino − valore reale.
+ /// Vedi Ml/Theory. È un'ipotesi forte, e per questo è una scelta dell'utente.
+ ///
+ public bool BuyNowHedgeEnabled { get; set; } = true;
///
/// Costo medio di una puntata, in euro. Entra nel budget e nel tetto di prezzo
diff --git a/Mimante/Utilities/StatsWipe.cs b/Mimante/Utilities/StatsWipe.cs
index e7808b0..80e256a 100644
--- a/Mimante/Utilities/StatsWipe.cs
+++ b/Mimante/Utilities/StatsWipe.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using AutoBidder.Data;
namespace AutoBidder.Utilities
{
@@ -9,11 +10,10 @@ namespace AutoBidder.Utilities
/// Azzera le statistiche registrate, una voce alla volta e solo quelle scelte.
///
/// Ogni voce è un archivio con un padrone diverso, e ognuno si svuota nel modo
- /// suo: lo storico passa dalla copia di sicurezza, le statistiche per prodotto tengono
- /// le opzioni e azzerano i numeri, l'apprendimento dimentica anche in memoria — se no
- /// il modello ancora vivo riscriverebbe il file al primo salvataggio. I dossier delle
- /// aste non si toccano da qui: sono i dati grezzi da cui tutto il resto si
- /// ricostruisce.
+ /// suo: le osservazioni del database passano dalla copia di sicurezza, le statistiche
+ /// per prodotto tengono le opzioni e azzerano i numeri, l'apprendimento dimentica
+ /// anche in memoria — se no il modello ancora vivo riscriverebbe il file al primo
+ /// salvataggio.
///
/// pesa ogni voce prima: cancellare è irreversibile, e
/// dire quanto sparisce è il minimo per una scelta informata.
@@ -22,10 +22,13 @@ namespace AutoBidder.Utilities
{
public sealed class Options
{
+ /// Le osservazioni del database: aste, puntate, interrogazioni, decisioni, misure.
public bool History { get; set; } = true;
public bool ProductStats { get; set; } = true;
public bool BidLeadMeasures { get; set; } = true;
public bool Exports { get; set; } = true;
+
+ /// Gli archivi JSON delle versioni precedenti (storico compatto, archivi mensili).
public bool LegacyArchives { get; set; } = true;
public bool Learning { get; set; }
@@ -60,7 +63,8 @@ namespace AutoBidder.Utilities
public static Sizes Measure()
{
int aste = 0, misure = 0;
- try { aste = CompletedAuctionsStore.LoadAll().Count; } catch { }
+ long dbBytes = 0;
+ try { aste = (int)AuctionDatabase.Instance.Count("auctions"); dbBytes = AuctionDatabase.Instance.SizeBytes; } catch { }
try { misure = BidLeadStats.All().Count; } catch { }
var export = ListFiles(AppPaths.ExportFolder, "*");
@@ -68,7 +72,7 @@ namespace AutoBidder.Utilities
var learning = ListFiles(LearningFolder, "*");
return new Sizes(
- aste, SizeOf(CompletedAuctionsStore.StorePath),
+ aste, dbBytes,
SizeOf(AppPaths.ProductsFile),
misure, SizeOf(AppPaths.BidLeadStatsFile),
export.Count, export.Sum(SizeOf),
@@ -92,11 +96,12 @@ namespace AutoBidder.Utilities
if (o.History)
{
- Try(report, "storico", () =>
+ Try(report, "database", () =>
{
- report.BytesFreed += SizeOf(CompletedAuctionsStore.StorePath);
+ var prima = AuctionDatabase.Instance.SizeBytes;
CompletedAuctionsStore.Clear();
- report.Steps.Add($"storico svuotato (copia in {backup})");
+ report.BytesFreed += Math.Max(0, prima - AuctionDatabase.Instance.SizeBytes);
+ report.Steps.Add($"osservazioni del database azzerate (copia in {backup})");
});
}
@@ -128,10 +133,10 @@ namespace AutoBidder.Utilities
if (o.LegacyArchives)
{
- var (n, bytes) = DeleteAll(report, "archivi mensili", LegacyFiles());
+ var (n, bytes) = DeleteAll(report, "archivi precedenti", LegacyFiles());
report.FilesDeleted += n;
report.BytesFreed += bytes;
- report.Steps.Add($"archivi mensili: {n} file tolti");
+ report.Steps.Add($"archivi delle versioni precedenti: {n} file tolti");
}
if (o.Learning)
@@ -144,7 +149,7 @@ namespace AutoBidder.Utilities
var (n, bytes) = DeleteAll(report, "apprendimento", ListFiles(LearningFolder, "*"));
report.FilesDeleted += n;
report.BytesFreed += bytes;
- report.Steps.Add("apprendimento azzerato: ristudia i dossier al prossimo avvio");
+ report.Steps.Add("apprendimento azzerato: ristudia le aste al prossimo avvio");
}
return report;
@@ -159,21 +164,31 @@ namespace AutoBidder.Utilities
// ── Attrezzi ─────────────────────────────────────────────────────
- /// Gli archivi mensili delle versioni precedenti: aste-AAAA-MM.jsonl e .json.
+ ///
+ /// Gli archivi JSON delle versioni precedenti: lo storico compatto, gli archivi
+ /// mensili delle schede, il vecchio storico dei prodotti. Il database li ha
+ /// sostituiti tutti.
+ ///
private static List LegacyFiles()
{
+ var result = new List();
var folder = AppPaths.StatsFolder;
- if (!Directory.Exists(folder)) return new List();
+ if (!Directory.Exists(folder)) return result;
- return Directory.EnumerateFiles(folder, "aste-*.json*")
- .Where(f =>
+ try
+ {
+ foreach (var f in Directory.EnumerateFiles(folder, "aste-*.json*"))
{
var name = Path.GetFileName(f);
- return name.Length >= 12 && char.IsDigit(name[5]) &&
- (name.EndsWith(".jsonl", StringComparison.OrdinalIgnoreCase) ||
- name.EndsWith(".json", StringComparison.OrdinalIgnoreCase));
- })
- .ToList();
+ if (name.Length >= 12 && char.IsDigit(name[5])) result.Add(f);
+ }
+
+ var compact = Path.Combine(folder, "completed-auctions.json");
+ if (File.Exists(compact)) result.Add(compact);
+ }
+ catch { }
+
+ return result;
}
private static List ListFiles(string folder, string pattern)
diff --git a/Mimante/Utilities/TextLogService.cs b/Mimante/Utilities/TextLogService.cs
index c5d139f..e2bff03 100644
--- a/Mimante/Utilities/TextLogService.cs
+++ b/Mimante/Utilities/TextLogService.cs
@@ -94,8 +94,7 @@ namespace AutoBidder.Utilities
var days = settings.LogRetentionDays;
var removed = FileLogWriter.PurgeOlderThan(AppPaths.LogFolder, "app-*.log", days)
- + FileLogWriter.PurgeOlderThan(AppPaths.LogFolder, "puntate-*.log", days)
- + FileLogWriter.PurgeOlderThan(AppPaths.AuctionLogFolder, "*.jsonl", days);
+ + FileLogWriter.PurgeOlderThan(AppPaths.LogFolder, "puntate-*.log", days);
if (removed > 0) App("INFO", $"Registri scaduti rimossi: {removed} (oltre {days} giorni)");
}
diff --git a/Mimante/ViewModels/ProductViewModel.cs b/Mimante/ViewModels/ProductViewModel.cs
index 4523bca..f049e9a 100644
--- a/Mimante/ViewModels/ProductViewModel.cs
+++ b/Mimante/ViewModels/ProductViewModel.cs
@@ -100,6 +100,18 @@ namespace AutoBidder.ViewModels
}
}
+ /// Valore reale del bene: vuoto = il «Compralo Ora» di Bidoo.
+ public string MarketValueText
+ {
+ get => Format(Rule.MarketValue);
+ set
+ {
+ Rule.MarketValue = ParseDouble(value);
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(HasCustomLimits));
+ }
+ }
+
///
/// Orizzonte proprio: oltre quanti minuti dall'inizio non aggiungere l'asta da
/// sola. Vuoto = vale il predefinito generale, 0 = nessun limite qui.
@@ -410,6 +422,7 @@ namespace AutoBidder.ViewModels
OnPropertyChanged(nameof(MinPriceText));
OnPropertyChanged(nameof(MaxPriceText));
OnPropertyChanged(nameof(MaxClicksText));
+ OnPropertyChanged(nameof(MarketValueText));
OnPropertyChanged(nameof(MaxStartMinutesText));
OnPropertyChanged(nameof(StateChoice));
OnPropertyChanged(nameof(HasCustomLimits));
diff --git a/Mimante/build/Release.proj b/Mimante/build/Release.proj
index f9162f5..5750a9a 100644
--- a/Mimante/build/Release.proj
+++ b/Mimante/build/Release.proj
@@ -403,20 +403,20 @@
- $(LOCALAPPDATA)\AutoBidder\Dati\Registri\Aste
+ $(LOCALAPPDATA)\AutoBidder\Database\autobidder.sqlite0$(Radice)\bin\backtest-report.txt
-
+
-
+
+ EnvironmentVariables="$(AmbientePulito);AUTOBIDDER_BACKTEST_DIR=$(PercorsoDb);AUTOBIDDER_BACKTEST_MAX=$(MaxDossier);AUTOBIDDER_BACKTEST_OUT=$(Riepilogo)" />
@@ -431,7 +431,7 @@
-->
- $(LOCALAPPDATA)\AutoBidder\Dati\Registri\Aste
+ $(LOCALAPPDATA)\AutoBidder\Database\autobidder.sqlite0$(Radice)\bin\apprendimento-report.txt