diff --git a/Mimante/Controls/ExportControl.xaml b/Mimante/Controls/ExportControl.xaml
index e1c67a1..297bd18 100644
--- a/Mimante/Controls/ExportControl.xaml
+++ b/Mimante/Controls/ExportControl.xaml
@@ -77,6 +77,13 @@
Content="Apri la cartella" Margin="0,0,8,0"
Click="OpenFolderButton_Click"/>
+
+
-
-
diff --git a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs
index a560b48..54da7d9 100644
--- a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs
+++ b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs
@@ -21,6 +21,9 @@ namespace AutoBidder
// Carica impostazioni predefinite aste
DefaultBidBeforeDeadlineMs.Text = settings.DefaultBidBeforeDeadlineMs.ToString();
+ Settings.AdaptiveLeadCheckBox.IsChecked = settings.AdaptiveLeadEnabled;
+ Settings.LeadMinMsTextBox.Text = settings.LeadMinMs.ToString();
+ Settings.LeadMaxMsTextBox.Text = settings.LeadMaxMs.ToString();
DefaultMinPrice.Text = settings.DefaultMinPrice.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
DefaultMaxPrice.Text = settings.DefaultMaxPrice.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
DefaultMaxClicks.Text = settings.DefaultMaxClicks.ToString();
@@ -63,11 +66,6 @@ namespace AutoBidder
}
// Motore di precisione
- Settings.PollIntervalFarMsTextBox.Text = settings.PollIntervalFarMs.ToString();
- Settings.PollIntervalMidMsTextBox.Text = settings.PollIntervalMidMs.ToString();
- Settings.PollIntervalNearMsTextBox.Text = settings.PollIntervalNearMs.ToString();
- Settings.PollIntervalCriticalMsTextBox.Text = settings.PollIntervalCriticalMs.ToString();
- Settings.CriticalWindowMsTextBox.Text = settings.CriticalWindowMs.ToString();
Settings.MaxRequestsPerSecondTextBox.Text = settings.MaxRequestsPerSecond.ToString("F0", System.Globalization.CultureInfo.InvariantCulture);
Settings.PrecisionTimerCheckBox.IsChecked = settings.PrecisionTimerEnabled;
@@ -88,7 +86,6 @@ namespace AutoBidder
// Anticipo, aste programmate, notifiche, cartelle
Settings.BidLeadTrackingCheckBox.IsChecked = settings.BidLeadTrackingEnabled;
- Settings.BidLeadSuggestionsCheckBox.IsChecked = settings.BidLeadSuggestionsEnabled;
Settings.BidLeadMinSamplesTextBox.Text = settings.BidLeadMinSamples.ToString();
Settings.ScheduledBackoffCheckBox.IsChecked = settings.ScheduledAuctionBackoffEnabled;
@@ -104,7 +101,6 @@ namespace AutoBidder
// ed è quello che serve sapere.
RefreshDataFolderFields();
- Settings.DetailedStatsCheckBox.IsChecked = settings.DetailedStatsEnabled;
Settings.CatalogCacheSecondsTextBox.Text = settings.CatalogCacheSeconds.ToString();
Settings.QuietHoursCheckBox.IsChecked = settings.QuietHoursEnabled;
@@ -190,6 +186,16 @@ namespace AutoBidder
Log("[ERRORE] Valore anticipo puntata non valido (deve essere 0-5000ms)", LogLevel.Error);
return;
}
+
+ // Paletti dell'anticipo adattivo: minimo sotto il massimo, entrambi sensati.
+ settings.AdaptiveLeadEnabled = Settings.AdaptiveLeadCheckBox.IsChecked == true;
+ settings.LeadMinMs = ReadBounded(Settings.LeadMinMsTextBox.Text, settings.LeadMinMs, 100, 5000, "anticipo minimo", " ms");
+ settings.LeadMaxMs = ReadBounded(Settings.LeadMaxMsTextBox.Text, settings.LeadMaxMs, 100, 5000, "anticipo massimo", " ms");
+ if (settings.LeadMaxMs < settings.LeadMinMs)
+ {
+ Log($"[ERRORE] Anticipo massimo ({settings.LeadMaxMs} ms) sotto il minimo ({settings.LeadMinMs} ms): riportato al minimo", LogLevel.Error);
+ settings.LeadMaxMs = settings.LeadMinMs;
+ }
if (double.TryParse(DefaultMinPrice.Text.Replace(',', '.'), System.Globalization.NumberStyles.Any,
@@ -320,11 +326,6 @@ namespace AutoBidder
// === SEZIONE: Motore di precisione ===
// Ogni cadenza ha un minimo sensato: sotto i 100 ms si spreca banda senza
// guadagnare precisione, perché la puntata la decide il cecchino, non il poll.
- settings.PollIntervalFarMs = ReadBounded(Settings.PollIntervalFarMsTextBox.Text, settings.PollIntervalFarMs, 200, 30000, "polling lontano");
- settings.PollIntervalMidMs = ReadBounded(Settings.PollIntervalMidMsTextBox.Text, settings.PollIntervalMidMs, 150, 10000, "polling medio");
- settings.PollIntervalNearMs = ReadBounded(Settings.PollIntervalNearMsTextBox.Text, settings.PollIntervalNearMs, 100, 5000, "polling vicino");
- settings.PollIntervalCriticalMs = ReadBounded(Settings.PollIntervalCriticalMsTextBox.Text, settings.PollIntervalCriticalMs, 100, 3000, "polling critico");
- settings.CriticalWindowMs = ReadBounded(Settings.CriticalWindowMsTextBox.Text, settings.CriticalWindowMs, 1000, 60000, "finestra critica");
if (double.TryParse(Settings.MaxRequestsPerSecondTextBox.Text.Replace(',', '.'),
System.Globalization.NumberStyles.Any,
@@ -392,7 +393,6 @@ namespace AutoBidder
// === SEZIONE: Anticipo puntata ===
settings.BidLeadTrackingEnabled = Settings.BidLeadTrackingCheckBox.IsChecked ?? true;
- settings.BidLeadSuggestionsEnabled = Settings.BidLeadSuggestionsCheckBox.IsChecked ?? true;
settings.BidLeadMinSamples = ReadBounded(Settings.BidLeadMinSamplesTextBox.Text,
settings.BidLeadMinSamples, 5, 500, "puntate minime per il consiglio", "");
@@ -420,7 +420,6 @@ namespace AutoBidder
settings.StatsFolder = NormalizeFolderChoice(Settings.StatsFolderTextBox.Text, AppPaths.StatsFolder, settings.StatsFolder);
settings.LogFolder = NormalizeFolderChoice(Settings.LogFolderTextBox.Text, AppPaths.LogFolder, settings.LogFolder);
- settings.DetailedStatsEnabled = Settings.DetailedStatsCheckBox.IsChecked ?? true;
settings.QuietHoursEnabled = Settings.QuietHoursCheckBox.IsChecked ?? true;
if (int.TryParse(Settings.QuietHoursStartTextBox.Text?.Trim(), out var qStart) && qStart is >= 0 and <= 23)
diff --git a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs
index 34c5bc6..de6661a 100644
--- a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs
+++ b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs
@@ -68,13 +68,10 @@ namespace AutoBidder
$"(prezzo {record.FinalPrice:F2} EUR)", LogLevel.Info);
}
+ // La scheda dettagliata vive solo nel riepilogo del dossier: l'archivio
+ // mensile che la duplicava non c'è più (vedi AuctionDetailStore).
var detail = BuildDetail(auction, state, record);
- if (settings.DetailedStatsEnabled)
- {
- AuctionDetailStore.Append(detail);
- }
-
// 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.
diff --git a/Mimante/Core/MainWindow.ControlEvents.cs b/Mimante/Core/MainWindow.ControlEvents.cs
index e67466e..5c2a9bb 100644
--- a/Mimante/Core/MainWindow.ControlEvents.cs
+++ b/Mimante/Core/MainWindow.ControlEvents.cs
@@ -254,6 +254,7 @@ namespace AutoBidder
if (_selectedAuction != null && int.TryParse(AuctionMonitor.SelectedBidBeforeDeadlineMs.Text, out int ms))
{
_selectedAuction.AuctionInfo.BidBeforeDeadlineMs = ms;
+ _selectedAuction.AuctionInfo.BidLeadIsManual = true;
SaveAuctions();
}
}
diff --git a/Mimante/Core/MainWindow.Export.cs b/Mimante/Core/MainWindow.Export.cs
index 7f97370..0a3e5d6 100644
--- a/Mimante/Core/MainWindow.Export.cs
+++ b/Mimante/Core/MainWindow.Export.cs
@@ -54,6 +54,25 @@ namespace AutoBidder
}
}
+ private void Export_ClearExportsClicked(object sender, RoutedEventArgs e)
+ {
+ var answer = MessageBox.Show(this,
+ "Cancello tutti i file nella cartella delle esportazioni? Sono copie: si rigenerano in qualunque momento.",
+ "Svuota le esportazioni", MessageBoxButton.YesNo, MessageBoxImage.Question);
+
+ if (answer != MessageBoxResult.Yes) return;
+
+ try
+ {
+ var (files, bytes) = StatsWipe.ClearExports();
+ Log($"[ESPORTA] Cartella svuotata: {files} file, {bytes / 1024.0:F0} KB liberati", LogLevel.Success);
+ }
+ catch (Exception ex)
+ {
+ Log($"[ESPORTA] Svuotamento non riuscito: {ex.Message}", LogLevel.Error);
+ }
+ }
+
private void Export_OpenLastFileClicked(object sender, RoutedEventArgs e)
{
var path = Export.LastExportPath;
diff --git a/Mimante/Core/MainWindow.Statistics.cs b/Mimante/Core/MainWindow.Statistics.cs
index d1c4c71..c1220b4 100644
--- a/Mimante/Core/MainWindow.Statistics.cs
+++ b/Mimante/Core/MainWindow.Statistics.cs
@@ -69,21 +69,22 @@ namespace AutoBidder
}
}
- /// Svuota lo storico delle aste concluse (con conferma).
+ ///
+ /// Azzera le statistiche registrate: il dialogo dice cosa c'è e quanto pesa, e
+ /// l'utente sceglie voce per voce. Vedi .
+ ///
private void ClearStatsButton_Click(object sender, RoutedEventArgs e)
{
- var res = MessageBox.Show(this,
- "Vuoi eliminare tutto lo storico delle aste concluse? L'operazione non è reversibile.",
- "Svuota storico",
- MessageBoxButton.YesNo,
- MessageBoxImage.Warning);
+ var dialog = new Dialogs.WipeStatsDialog { Owner = this };
+ if (dialog.ShowDialog() != true || dialog.Result is not { } report) return;
- if (res == MessageBoxResult.Yes)
- {
- CompletedAuctionsStore.Clear();
- LoadStatistics();
- Log("[STATISTICHE] Storico svuotato", LogLevel.Info);
- }
+ LoadStatistics();
+ LoadProducts();
+ if (Learning.IsVisible) Learning.Refresh();
+
+ Log($"[STATISTICHE] Azzeramento: {report.Summary}. " +
+ $"{report.FilesDeleted} file tolti, {report.BytesFreed / (1024.0 * 1024.0):F1} MB liberati",
+ report.Errors.Count == 0 ? LogLevel.Success : LogLevel.Warning);
}
///
diff --git a/Mimante/Core/MainWindow.UIUpdates.cs b/Mimante/Core/MainWindow.UIUpdates.cs
index ea69f8c..6061ed5 100644
--- a/Mimante/Core/MainWindow.UIUpdates.cs
+++ b/Mimante/Core/MainWindow.UIUpdates.cs
@@ -272,6 +272,7 @@ namespace AutoBidder
// Resetta ai valori predefiniti dalle impostazioni
_selectedAuction.AuctionInfo.BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs;
+ _selectedAuction.AuctionInfo.BidLeadIsManual = false; // torna all'anticipo adattivo
_selectedAuction.MinPrice = settings.DefaultMinPrice;
_selectedAuction.MaxPrice = settings.DefaultMaxPrice;
_selectedAuction.MaxClicks = settings.DefaultMaxClicks;
diff --git a/Mimante/Dialogs/WipeStatsDialog.xaml b/Mimante/Dialogs/WipeStatsDialog.xaml
new file mode 100644
index 0000000..79780f3
--- /dev/null
+++ b/Mimante/Dialogs/WipeStatsDialog.xaml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Mimante/Dialogs/WipeStatsDialog.xaml.cs b/Mimante/Dialogs/WipeStatsDialog.xaml.cs
new file mode 100644
index 0000000..2d96fbc
--- /dev/null
+++ b/Mimante/Dialogs/WipeStatsDialog.xaml.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Windows;
+using AutoBidder.Utilities;
+
+namespace AutoBidder.Dialogs
+{
+ ///
+ /// Sceglie quali statistiche azzerare e le azzera.
+ ///
+ /// Accanto a ogni voce c'è quanto pesa adesso: cancellare è irreversibile, e
+ /// l'unico modo di renderlo una decisione informata è dire cosa sparisce e quanto è.
+ /// Lo storico delle aste concluse è l'unica voce con copia di sicurezza automatica:
+ /// è il solo archivio che non si ricostruisce da nient'altro.
+ ///
+ public partial class WipeStatsDialog : Window
+ {
+ private StatsWipe.Sizes _sizes;
+
+ public WipeStatsDialog()
+ {
+ InitializeComponent();
+ Loaded += (_, _) => { _sizes = StatsWipe.Measure(); Refresh(); };
+ }
+
+ /// Il rapporto dell'azzeramento, dopo la conferma. Null se annullato.
+ public StatsWipe.Report? Result { get; private set; }
+
+ private StatsWipe.Options ReadOptions() => new()
+ {
+ History = ChkHistory.IsChecked == true,
+ ProductStats = ChkProducts.IsChecked == true,
+ BidLeadMeasures = ChkBidLead.IsChecked == true,
+ Exports = ChkExports.IsChecked == true,
+ LegacyArchives = ChkLegacy.IsChecked == true,
+ Learning = ChkLearning.IsChecked == true
+ };
+
+ private void Option_Changed(object sender, RoutedEventArgs e) => Refresh();
+
+ private void Refresh()
+ {
+ if (!IsLoaded) return;
+
+ ChkHistory.Content = $"Storico delle aste concluse ({_sizes.HistoryAuctions} aste, {Fmt(_sizes.HistoryBytes)})";
+ ChkProducts.Content = $"Statistiche per prodotto ({Fmt(_sizes.ProductsBytes)})";
+ ChkBidLead.Content = $"Misure dell'anticipo effettivo ({_sizes.BidLeadSamples} misure, {Fmt(_sizes.BidLeadBytes)})";
+ ChkExports.Content = $"Esportazioni ({_sizes.ExportFiles} file, {Fmt(_sizes.ExportBytes)})";
+ ChkLegacy.Content = $"Archivi mensili delle versioni precedenti ({_sizes.LegacyFiles} file, {Fmt(_sizes.LegacyBytes)})";
+ ChkLearning.Content = $"Apprendimento: modello, profilo, latenza ({Fmt(_sizes.LearningBytes)})";
+
+ var o = ReadOptions();
+ long totale = 0;
+ if (o.History) totale += _sizes.HistoryBytes;
+ if (o.ProductStats) totale += _sizes.ProductsBytes;
+ if (o.BidLeadMeasures) totale += _sizes.BidLeadBytes;
+ if (o.Exports) totale += _sizes.ExportBytes;
+ if (o.LegacyArchives) totale += _sizes.LegacyBytes;
+ if (o.Learning) totale += _sizes.LearningBytes;
+
+ SummaryText.Text = o.Nothing
+ ? "Nessuna voce scelta."
+ : $"Si liberano circa {Fmt(totale)}." +
+ (o.History ? " Lo storico viene prima copiato nei backup." : "") +
+ (o.Learning ? " L'apprendimento ripartirà da zero e ristudierà i dossier al prossimo avvio." : "");
+
+ WipeButton.IsEnabled = !o.Nothing;
+ }
+
+ private void Wipe_Click(object sender, RoutedEventArgs e)
+ {
+ var o = ReadOptions();
+ if (o.Nothing) return;
+
+ var answer = MessageBox.Show(this,
+ "Le voci scelte vengono cancellate. L'operazione non è reversibile" +
+ (o.History ? ", salvo la copia dello storico nei backup" : "") + ".\n\nProcedo?",
+ "Azzera le statistiche", MessageBoxButton.YesNo, MessageBoxImage.Warning);
+
+ if (answer != MessageBoxResult.Yes) return;
+
+ try
+ {
+ Result = StatsWipe.Run(o);
+ DialogResult = true;
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, $"Azzeramento non riuscito: {ex.Message}",
+ "Azzera le statistiche", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ private static string Fmt(long bytes) =>
+ bytes >= 1L << 30 ? $"{bytes / (double)(1L << 30):F2} GB"
+ : bytes >= 1L << 20 ? $"{bytes / (double)(1L << 20):F1} MB"
+ : bytes >= 1L << 10 ? $"{bytes / (double)(1L << 10):F0} KB"
+ : $"{bytes} B";
+ }
+}
diff --git a/Mimante/Engine/AuctionRunner.cs b/Mimante/Engine/AuctionRunner.cs
index 9395021..5078167 100644
--- a/Mimante/Engine/AuctionRunner.cs
+++ b/Mimante/Engine/AuctionRunner.cs
@@ -228,6 +228,33 @@ namespace AutoBidder.Engine
return (int)Math.Clamp(Math.Min(desired, untilWake), 1000, 1_800_000);
}
+ ///
+ /// L'anticipo con cui parte la puntata su quest'asta.
+ ///
+ /// Un anticipo scritto a mano sull'asta vince sempre. Altrimenti decide il
+ /// modello di latenza — margine più coda del ping di questa sessione, dentro i
+ /// paletti delle impostazioni — e il valore scelto viene scritto sull'asta, così
+ /// la griglia e il dossier mostrano l'anticipo davvero in uso.
+ ///
+ private int EffectiveLeadMs(AppSettings settings)
+ {
+ if (_auction.BidLeadIsManual && _auction.BidBeforeDeadlineMs > 0)
+ return _auction.BidBeforeDeadlineMs;
+
+ var lead = settings.AdaptiveLeadEnabled
+ ? Ml.LatencyModel.RecommendedLeadMs(settings)
+ : settings.DefaultBidBeforeDeadlineMs;
+
+ if (lead != _auction.BidBeforeDeadlineMs)
+ {
+ _auction.AddLog($"Anticipo adattivo: {lead} ms (era {_auction.BidBeforeDeadlineMs} ms)",
+ AuctionLogLevel.Debug, AuctionLogCategory.Ticker);
+ _auction.BidBeforeDeadlineMs = lead;
+ }
+
+ return lead;
+ }
+
// ── Anello del cecchino ──────────────────────────────────────────────────
private async Task SniperLoopAsync(CancellationToken ct)
@@ -265,9 +292,7 @@ namespace AutoBidder.Engine
}
var settings = _host.Settings;
- var leadMs = _auction.BidBeforeDeadlineMs > 0
- ? _auction.BidBeforeDeadlineMs
- : settings.DefaultBidBeforeDeadlineMs;
+ var leadMs = EffectiveLeadMs(settings);
var fireTicks = deadline - PrecisionWait.MsToTicks(leadMs);
var msToDeadline = PrecisionWait.MsUntil(deadline);
diff --git a/Mimante/Engine/Backtest/BacktestRunner.cs b/Mimante/Engine/Backtest/BacktestRunner.cs
index 9255ca1..d3f2c48 100644
--- a/Mimante/Engine/Backtest/BacktestRunner.cs
+++ b/Mimante/Engine/Backtest/BacktestRunner.cs
@@ -96,12 +96,11 @@ namespace AutoBidder.Engine.Backtest
{
if (string.IsNullOrEmpty(reason)) return "altro";
- if (reason.StartsWith("Anti-bot", StringComparison.OrdinalIgnoreCase)) return "anti-bot";
- if (reason.StartsWith("Soft retreat", StringComparison.OrdinalIgnoreCase)) return "soft-retreat";
- if (reason.StartsWith("Asta troppo calda", StringComparison.OrdinalIgnoreCase)) return "asta-calda";
- if (reason.StartsWith("Bidder aggressivi", StringComparison.OrdinalIgnoreCase)) return "avversari-aggressivi";
- if (reason.StartsWith("Skip probabilistico", StringComparison.OrdinalIgnoreCase)) return "probabilistico";
- if (reason.StartsWith("Prezzo sale", StringComparison.OrdinalIgnoreCase)) return "velocita-prezzo";
+ if (reason.StartsWith("autopuntata avversaria", StringComparison.OrdinalIgnoreCase)) return "duello";
+ if (reason.StartsWith("puntata saltata: fascia", StringComparison.OrdinalIgnoreCase)) return "fascia-oraria";
+ if (reason.StartsWith("valore atteso", StringComparison.OrdinalIgnoreCase)) return "valore-atteso";
+ if (reason.StartsWith("lascio sfogare", StringComparison.OrdinalIgnoreCase)) return "sfogo";
+ if (reason.StartsWith("puntata di prova", StringComparison.OrdinalIgnoreCase)) return "sondaggio";
if (reason.StartsWith("Limite puntate", StringComparison.OrdinalIgnoreCase)) return "limite-puntate";
if (reason.StartsWith("Budget", StringComparison.OrdinalIgnoreCase)) return "budget";
@@ -181,7 +180,6 @@ namespace AutoBidder.Engine.Backtest
continue;
}
- strategy.UpdateHeatMetric(auction, options.EffectiveSettings, options.Username);
var decision = strategy.ShouldPlaceBid(auction, state, options.EffectiveSettings, options.Username);
if (!decision.ShouldBid)
@@ -312,6 +310,7 @@ namespace AutoBidder.Engine.Backtest
Name = header?.Name ?? "",
BuyNowPrice = header?.BuyNowPrice,
BidBeforeDeadlineMs = options.LeadMs,
+ BidLeadIsManual = true, // la rigiocata prova un anticipo preciso, non quello adattivo
BidsUsedOnThisAuction = 0
};
diff --git a/Mimante/MainWindow.xaml b/Mimante/MainWindow.xaml
index 85c9b44..a84d07f 100644
--- a/Mimante/MainWindow.xaml
+++ b/Mimante/MainWindow.xaml
@@ -220,7 +220,8 @@
Visibility="Collapsed"
ExportRequested="Export_ExportRequested"
OpenExportFolderClicked="Export_OpenFolderClicked"
- OpenLastFileClicked="Export_OpenLastFileClicked"/>
+ OpenLastFileClicked="Export_OpenLastFileClicked"
+ ClearExportsClicked="Export_ClearExportsClicked"/>
@@ -300,7 +301,8 @@
diff --git a/Mimante/MainWindow.xaml.cs b/Mimante/MainWindow.xaml.cs
index c5b3ed4..dcfcafe 100644
--- a/Mimante/MainWindow.xaml.cs
+++ b/Mimante/MainWindow.xaml.cs
@@ -101,6 +101,7 @@ namespace AutoBidder
// Inizializza servizi
_auctionMonitor = new AuctionMonitor();
+ Learning.AuctionsProvider = () => _auctionMonitor.GetAuctions();
// ? NUOVO: Inizializza SessionService
InitializeSessionService();
diff --git a/Mimante/Ml/CompetitionRegime.cs b/Mimante/Ml/CompetitionRegime.cs
new file mode 100644
index 0000000..abc91e6
--- /dev/null
+++ b/Mimante/Ml/CompetitionRegime.cs
@@ -0,0 +1,168 @@
+using System;
+
+namespace AutoBidder.Ml
+{
+ ///
+ /// Il regime di concorrenza di un'asta: quando lasciar sfogare gli altri, e quando
+ /// tornare a puntare. Sostituisce «calore», «ritiro morbido», «avversari aggressivi» e
+ /// il vecchio rilevamento del duello con una sola macchina a stati, guidata da ciò
+ /// che il modello ha imparato invece che da soglie scritte a mano.
+ ///
+ /// Gli stati.
+ ///
+ /// Calmo: si punta quando il valore atteso è positivo. È il regime
+ /// normale.
+ /// Sfogo: gli altri si stanno battendo e ogni nostra puntata verrebbe
+ /// coperta. Non si punta. Ci si entra quando il valore atteso resta negativo per
+ /// qualche decisione di fila, o quando si riconosce un'autopuntata armata.
+ /// Sondaggio: le condizioni sono tornate buone da abbastanza cicli. Si
+ /// concede una puntata di prova. Se resta senza risposta si torna Calmo; se
+ /// viene coperta subito si torna a Sfogo, e la pazienza richiesta per il prossimo
+ /// sondaggio raddoppia.
+ ///
+ ///
+ /// Perché l'isteresi. Il valore atteso oscilla da un ciclo all'altro: un
+ /// cancello che punta al primo segno positivo e si ferma al primo negativo farebbe
+ /// avanti e indietro, e ogni «avanti» è una puntata pagata. Servono più segnali
+ /// buoni di fila per rientrare che segnali cattivi per uscire: sbagliare a rientrare
+ /// costa soldi, sbagliare a uscire costa al massimo un'attesa.
+ ///
+ /// Perché la pazienza raddoppia. Un sondaggio coperto subito dice che
+ /// dall'altra parte c'è ancora qualcuno che risponde: insistere con la stessa
+ /// pazienza sarebbe rifare lo stesso errore allo stesso prezzo. Raddoppiare fino a un
+ /// tetto è il modo più semplice di «capire da soli» che quell'asta, oggi, non si
+ /// lascia prendere — senza deciderlo una volta per tutte.
+ ///
+ /// Classe pura con stato proprio: riceve i segnali, non legge niente da fuori.
+ /// Così si prova a tavolino e si rigioca sui dossier con la stessa logica del motore.
+ ///
+ public sealed class CompetitionRegime
+ {
+ public enum Stato { Calmo, Sfogo, Sondaggio }
+
+ /// Decisioni negative di fila prima di andare in Sfogo.
+ public const int NegativiPerSfogo = 3;
+
+ /// Cicli buoni di fila richiesti al primo rientro; raddoppia a ogni sondaggio coperto.
+ public const int PazienzaIniziale = 3;
+
+ public const int PazienzaMassima = 24;
+
+ /// Secondi entro cui una risposta alla puntata di prova conta come «coperta subito».
+ public const double RispostaRapidaSecondi = 9.0;
+
+ public Stato StatoAttuale { get; private set; } = Stato.Calmo;
+
+ /// Quanti cicli buoni di fila servono adesso per rientrare.
+ public int Pazienza { get; private set; } = PazienzaIniziale;
+
+ /// Cicli buoni di fila visti finora durante lo Sfogo.
+ public int CicliBuoni { get; private set; }
+
+ /// Decisioni negative di fila viste finora in Calmo.
+ public int Negativi { get; private set; }
+
+ /// Sondaggi coperti subito, in quest'asta.
+ public int SondaggiFalliti { get; private set; }
+
+ private bool _sondaggioInCorso;
+
+ ///
+ /// Una decisione del motore: il valore atteso di puntare adesso era positivo?
+ /// Restituisce true se in questo regime si può puntare.
+ ///
+ public bool Osserva(bool valoreAttesoPositivo)
+ {
+ switch (StatoAttuale)
+ {
+ case Stato.Calmo:
+ if (valoreAttesoPositivo) { Negativi = 0; return true; }
+ if (++Negativi >= NegativiPerSfogo) EntraInSfogo();
+ return false;
+
+ case Stato.Sfogo:
+ if (!valoreAttesoPositivo) { CicliBuoni = 0; return false; }
+ if (++CicliBuoni < Pazienza) return false;
+ // Abbastanza cicli buoni di fila: una puntata di prova.
+ StatoAttuale = Stato.Sondaggio;
+ _sondaggioInCorso = false;
+ return true;
+
+ case Stato.Sondaggio:
+ // Finché la prova non è stata piazzata si può ancora puntare (una volta);
+ // dopo, si aspetta l'esito.
+ return !_sondaggioInCorso && valoreAttesoPositivo;
+ }
+ return true;
+ }
+
+ /// La nostra puntata è partita.
+ public void PuntataPiazzata()
+ {
+ if (StatoAttuale == Stato.Sondaggio) _sondaggioInCorso = true;
+ }
+
+ ///
+ /// Qualcuno ha risposto alla nostra ultima puntata dopo tanti secondi. Se eravamo
+ /// in Sondaggio e la risposta è rapida, il sondaggio è fallito.
+ ///
+ public void RispostaAvversaria(double secondiDopoLaMiaPuntata)
+ {
+ if (StatoAttuale != Stato.Sondaggio || !_sondaggioInCorso) return;
+
+ if (secondiDopoLaMiaPuntata <= RispostaRapidaSecondi)
+ {
+ SondaggiFalliti++;
+ Pazienza = Math.Min(PazienzaMassima, Pazienza * 2);
+ EntraInSfogo();
+ }
+ else
+ {
+ // Risposta lenta: non era una macchina, si torna a giocare.
+ Riprendi();
+ }
+ }
+
+ /// La nostra ultima puntata è rimasta senza risposta: siamo in testa da un pezzo.
+ public void NessunaRisposta()
+ {
+ if (StatoAttuale == Stato.Sondaggio) Riprendi();
+ }
+
+ /// Riconosciuta un'autopuntata armata: non c'è niente da sondare per ora.
+ public void AutopuntataRiconosciuta()
+ {
+ if (StatoAttuale != Stato.Sfogo)
+ {
+ Pazienza = Math.Max(Pazienza, PazienzaIniziale * 2);
+ EntraInSfogo();
+ }
+ }
+
+ private void EntraInSfogo()
+ {
+ StatoAttuale = Stato.Sfogo;
+ CicliBuoni = 0;
+ Negativi = 0;
+ _sondaggioInCorso = false;
+ }
+
+ private void Riprendi()
+ {
+ StatoAttuale = Stato.Calmo;
+ Negativi = 0;
+ CicliBuoni = 0;
+ _sondaggioInCorso = false;
+ // La pazienza non si azzera del tutto: chi ci ha coperto una volta può tornare.
+ Pazienza = Math.Max(PazienzaIniziale, Pazienza / 2);
+ }
+
+ /// Una riga per il registro.
+ public string Spiegazione() => StatoAttuale switch
+ {
+ Stato.Sfogo => $"lascio sfogare gli altri: rientro dopo {Pazienza} cicli buoni di fila (visti {CicliBuoni})",
+ Stato.Sondaggio => "puntata di prova: se viene coperta subito torno ad aspettare",
+ _ => "regime calmo"
+ };
+ }
+}
diff --git a/Mimante/Ml/LEGGIMI.md b/Mimante/Ml/LEGGIMI.md
new file mode 100644
index 0000000..d25e2ee
--- /dev/null
+++ b/Mimante/Ml/LEGGIMI.md
@@ -0,0 +1,154 @@
+# Come decide il motore
+
+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.
+
+## 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).
+
+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.
+
+## La pipeline: quattro passi, sempre nello stesso ordine
+
+```
+ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
+ │ 1. PALETTI │──▶│ 2. DUELLO │──▶│ 3. VALORE │──▶│ 4. REGIME │──▶ punta?
+ │ (utente) │ │ (sessione) │ │ ATTESO │ │ (sessione) │
+ └──────────────┘ └──────────────┘ │ (storico+ora)│ └──────────────┘
+ └──────────────┘
+ │
+ ┌──────────▼──────────┐
+ │ QUANDO: anticipo │
+ │ adattivo (sessione) │
+ └─────────────────────┘
+```
+
+### 1. Paletti — `BidStrategyService`, `BiddingHours`
+
+Regole dell'utente che non si discutono e non imparano niente:
+
+- 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.
+
+### 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. 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 è
+
+```
+EV = p × (Valore − Prezzo − 0,01) − CostoPuntata
+```
+
+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).
+
+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.
+
+### 4. Regime di concorrenza — `CompetitionRegime`
+
+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 |
+
+**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.
+
+**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.
+
+È 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».
+
+### Quando puntare: l'anticipo adattivo — `LatencyModel`
+
+L'anticipo giusto dipende dalla latenza di *questa* rete in *questo* momento. Un numero
+fisso è sbagliato quasi sempre. La regola:
+
+```
+anticipo = clamp( margine + p99(latenza) , LeadMinMs , LeadMaxMs )
+```
+
+- 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`).
+
+Lo stato si salva fra le sessioni (`Statistiche/Apprendimento/latenza.json`).
+
+## Cosa impara, e da dove
+
+| 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 |
+
+## Dove si vede
+
+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.
+
+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 €`.
+
+## Come si prova
+
+- `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.
+
+## 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.
diff --git a/Mimante/Ml/LatencyModel.cs b/Mimante/Ml/LatencyModel.cs
new file mode 100644
index 0000000..535d360
--- /dev/null
+++ b/Mimante/Ml/LatencyModel.cs
@@ -0,0 +1,221 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using AutoBidder.Utilities;
+
+namespace AutoBidder.Ml
+{
+ ///
+ /// Decide da solo l'anticipo con cui parte la puntata, a partire dalla rete di questa
+ /// sessione — entro i paletti dell'utente.
+ ///
+ /// Il problema. L'anticipo giusto dipende da quanto ci mette la puntata ad
+ /// arrivare al server, e questo cambia da macchina a macchina, da rete a rete, da ora a
+ /// ora. Un numero fisso è sbagliato quasi sempre: troppo largo regala secondi agli
+ /// avversari, troppo stretto fa arrivare la puntata a giochi chiusi. Nei registri erano
+ /// entrambe le cose, in giorni diversi.
+ ///
+ /// La regola. L'anticipo è un margine di sicurezza più la coda alta della
+ /// latenza misurata: margine + p99(andata e ritorno). La coda, non la media —
+ /// una puntata su cento che arriva tardi costa un'asta intera, e la media non la vede.
+ /// Il margine copre l'elaborazione del server e l'arrotondamento al secondo con cui
+ /// Bidoo dichiara la scadenza.
+ ///
+ /// Come impara. Da tre cose che passano di qui comunque: il ping di ogni
+ /// interrogazione, l'andata e ritorno di ogni puntata, e l'esito «arrivata troppo
+ /// tardi». Una puntata tardiva alza il margine subito e di molto; una lunga serie di
+ /// puntate in tempo lo abbassa piano. Asimmetrico apposta: sbagliare per eccesso costa
+ /// qualche puntata in più, sbagliare per difetto costa l'asta.
+ ///
+ /// I paletti. L'utente fissa minimo e massimo; il modello si muove solo lì
+ /// dentro. Un anticipo fissato a mano su una singola asta vince sempre.
+ ///
+ /// Lo stato si salva fra una sessione e l'altra: la rete di casa è quasi sempre
+ /// la stessa, e ripartire da zero ogni volta significherebbe pagare la prima puntata
+ /// tardiva ogni giorno.
+ ///
+ public static class LatencyModel
+ {
+ private const int Window = 300;
+ private static readonly object Sync = new();
+
+ private static readonly Queue _pings = new();
+ private static readonly Queue _roundTrips = new();
+
+ /// Margine oltre la coda della latenza. Parte da 300 ms: elaborazione del server più arrotondamento al secondo.
+ private static int _marginMs = 300;
+
+ private static int _bidsSinceLastLate;
+ private static int _lateBids;
+ private static int _bidsTotal;
+ private static bool _loaded;
+
+ private static string FilePath => Path.Combine(AppPaths.StatsFolder, "Apprendimento", "latenza.json");
+
+ // ── Alimentazione ────────────────────────────────────────────────
+
+ public static void NotePing(int ms)
+ {
+ if (ms <= 0 || ms > 10000) return;
+ lock (Sync)
+ {
+ _pings.Enqueue(ms);
+ while (_pings.Count > Window) _pings.Dequeue();
+ }
+ }
+
+ /// Una puntata è stata inviata: quanto ci ha messo, e se è arrivata tardi.
+ public static void NoteBid(int roundTripMs, bool late)
+ {
+ lock (Sync)
+ {
+ if (roundTripMs > 0 && roundTripMs < 10000)
+ {
+ _roundTrips.Enqueue(roundTripMs);
+ while (_roundTrips.Count > Window) _roundTrips.Dequeue();
+ }
+
+ _bidsTotal++;
+
+ if (late)
+ {
+ // Una sola puntata tardiva vale un'asta persa: si sale subito e di
+ // molto, poi si riscende piano se non ricapita.
+ _lateBids++;
+ _bidsSinceLastLate = 0;
+ _marginMs = Math.Min(2000, _marginMs + 150);
+ }
+ else
+ {
+ _bidsSinceLastLate++;
+ // Venti puntate in tempo di fila: si prova a stringere di un pelo.
+ if (_bidsSinceLastLate % 20 == 0) _marginMs = Math.Max(150, _marginMs - 25);
+ }
+ }
+
+ Save();
+ }
+
+ // ── Risposta ─────────────────────────────────────────────────────
+
+ /// L'anticipo consigliato adesso, dentro i paletti delle impostazioni.
+ public static int RecommendedLeadMs(AppSettings settings)
+ {
+ Load();
+
+ var min = Math.Max(100, settings.LeadMinMs);
+ var max = Math.Max(min, settings.LeadMaxMs);
+
+ lock (Sync)
+ {
+ // La coda della latenza: l'andata e ritorno delle puntate se ne abbiamo
+ // abbastanza, altrimenti il ping delle interrogazioni, che è la stessa
+ // strada. Senza campioni si usa il predefinito dell'utente.
+ var coda = _roundTrips.Count >= 10 ? Percentile(_roundTrips, 0.99)
+ : _pings.Count >= 10 ? Percentile(_pings, 0.99)
+ : -1;
+
+ if (coda < 0) return Math.Clamp(settings.DefaultBidBeforeDeadlineMs, min, max);
+
+ return Math.Clamp(_marginMs + coda, min, max);
+ }
+ }
+
+ /// Numeri per la scheda e il registro.
+ public static (int Samples, int P50, int P99, int MarginMs, int LateBids, int Bids) Snapshot()
+ {
+ Load();
+ lock (Sync)
+ {
+ var src = _roundTrips.Count >= 10 ? _roundTrips : _pings;
+ return (src.Count,
+ src.Count > 0 ? Percentile(src, 0.50) : 0,
+ src.Count > 0 ? Percentile(src, 0.99) : 0,
+ _marginMs, _lateBids, _bidsTotal);
+ }
+ }
+
+ private static int Percentile(Queue q, double p)
+ {
+ var a = q.ToArray();
+ Array.Sort(a);
+ var i = (int)Math.Ceiling(p * a.Length) - 1;
+ return a[Math.Clamp(i, 0, a.Length - 1)];
+ }
+
+ // ── Persistenza ──────────────────────────────────────────────────
+
+ private sealed class Snap
+ {
+ public int MarginMs { get; set; }
+ public int LateBids { get; set; }
+ public int Bids { get; set; }
+ public int[] RoundTrips { get; set; } = Array.Empty();
+ }
+
+ private static void Load()
+ {
+ if (_loaded) return;
+ lock (Sync)
+ {
+ if (_loaded) return;
+ _loaded = true;
+ try
+ {
+ if (!File.Exists(FilePath)) return;
+ var s = JsonSerializer.Deserialize(File.ReadAllText(FilePath));
+ if (s == null) return;
+ _marginMs = Math.Clamp(s.MarginMs, 150, 2000);
+ _lateBids = s.LateBids;
+ _bidsTotal = s.Bids;
+ foreach (var r in s.RoundTrips) _roundTrips.Enqueue(r);
+ }
+ catch { /* si riparte dai predefiniti */ }
+ }
+ }
+
+ private static void Save()
+ {
+ try
+ {
+ string json;
+ lock (Sync)
+ {
+ json = JsonSerializer.Serialize(new Snap
+ {
+ MarginMs = _marginMs,
+ LateBids = _lateBids,
+ Bids = _bidsTotal,
+ RoundTrips = _roundTrips.ToArray()
+ });
+ }
+ Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
+ File.WriteAllText(FilePath, json);
+ }
+ catch { /* la prossima puntata riproverà */ }
+ }
+
+ /// Dimentica le misure, in memoria e su disco: si riparte dai predefiniti.
+ public static void Forget()
+ {
+ ResetForTests();
+ try { if (File.Exists(FilePath)) File.Delete(FilePath); } catch { }
+ }
+
+ /// Solo per i test.
+ public static void ResetForTests(int marginMs = 300)
+ {
+ lock (Sync)
+ {
+ _pings.Clear();
+ _roundTrips.Clear();
+ _marginMs = marginMs;
+ _bidsSinceLastLate = 0;
+ _lateBids = 0;
+ _bidsTotal = 0;
+ _loaded = true;
+ }
+ }
+ }
+}
diff --git a/Mimante/Ml/LearningService.cs b/Mimante/Ml/LearningService.cs
index 84ca909..e0c14fd 100644
--- a/Mimante/Ml/LearningService.cs
+++ b/Mimante/Ml/LearningService.cs
@@ -175,6 +175,23 @@ namespace AutoBidder.Ml
/// modello abbia imparato da dati sbagliati. Non tocca i dossier.
///
public static Task RetrainFromScratchAsync(AppSettings settings)
+ {
+ Forget();
+
+ OnLog?.Invoke("[APPRENDIMENTO] Ricomincio da capo: profilo dallo storico, poi tutti i dossier.");
+ BootstrapProfileFromHistory();
+
+ // Un riaddestramento voluto ha diritto a tutto il tempo che serve.
+ var generose = new AppSettings { LearningBootstrapSecondsPerStart = Math.Max(600, settings.LearningBootstrapSecondsPerStart) };
+ return Task.Run(() => BootstrapAsync(generose));
+ }
+
+ ///
+ /// 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.
+ ///
+ public static void Forget()
{
lock (Sync)
{
@@ -190,7 +207,7 @@ namespace AutoBidder.Ml
{
lock (SaveSync)
{
- foreach (var f in new[] { ModelFile, ProfileFile, ManifestFile })
+ foreach (var f in new[] { ModelFile, ProfileFile, ManifestFile, EvaluationFile })
if (File.Exists(f)) File.Delete(f);
}
}
@@ -198,13 +215,6 @@ namespace AutoBidder.Ml
{
OnLog?.Invoke($"[APPRENDIMENTO] Archivio non cancellato del tutto: {ex.Message}");
}
-
- OnLog?.Invoke("[APPRENDIMENTO] Ricomincio da capo: profilo dallo storico, poi tutti i dossier.");
- BootstrapProfileFromHistory();
-
- // Un riaddestramento voluto ha diritto a tutto il tempo che serve.
- var generose = new AppSettings { LearningBootstrapSecondsPerStart = Math.Max(600, settings.LearningBootstrapSecondsPerStart) };
- return Task.Run(() => BootstrapAsync(generose));
}
// ── Avvio ────────────────────────────────────────────────────────
diff --git a/Mimante/Models/AuctionInfo.cs b/Mimante/Models/AuctionInfo.cs
index 000215c..8053a1d 100644
--- a/Mimante/Models/AuctionInfo.cs
+++ b/Mimante/Models/AuctionInfo.cs
@@ -42,6 +42,14 @@ namespace AutoBidder.Models
/// Es: 200ms = punta 200ms prima che il timer raggiunga 0.
///
public int BidBeforeDeadlineMs { get; set; } = 200;
+
+ ///
+ /// L'anticipo di quest'asta è stato scritto a mano dall'utente. Solo allora
+ /// comanda: altrimenti il cecchino lo lascia
+ /// decidere al modello di latenza (vedi Ml/LatencyModel) e scrive qui il valore
+ /// scelto, così la griglia mostra l'anticipo davvero in uso.
+ ///
+ public bool BidLeadIsManual { get; set; }
public double MinPrice { get; set; } = 0;
public double MaxPrice { get; set; } = 0;
@@ -528,43 +536,6 @@ namespace AutoBidder.Models
public double AverageLatencyMs => LatencyHistory.Count > 0
? LatencyHistory.Average()
: PollingLatencyMs > 0 ? PollingLatencyMs : 60;
-
- ///
- /// Heat metric (0-100) che indica quanto � "calda" l'asta
- /// Calcolato in base a: bidder attivi, frequenza puntate, collisioni
- ///
- [JsonIgnore]
- public int HeatMetric { get; set; } = 0;
-
- ///
- /// Numero di bidder unici attivi negli ultimi N secondi
- ///
- [JsonIgnore]
- public int ActiveBiddersCount { get; set; } = 0;
-
- ///
- /// Numero di collisioni rilevate (puntate nello stesso secondo)
- ///
- [JsonIgnore]
- public int CollisionCount { get; set; } = 0;
-
- ///
- /// Collisioni consecutive senza puntata vincente
- ///
- [JsonIgnore]
- public int ConsecutiveCollisions { get; set; } = 0;
-
- ///
- /// Timestamp dell'ultimo soft retreat
- ///
- [JsonIgnore]
- public DateTime? LastSoftRetreatAt { get; set; }
-
- ///
- /// Se true, l'asta � in soft retreat temporaneo
- ///
- [JsonIgnore]
- public bool IsInSoftRetreat { get; set; } = false;
// ── Duello con l'autopuntata avversaria ──────────────────────────
// Vedi Utilities/AutoBidDuel per il perche' e per i numeri misurati.
@@ -584,6 +555,14 @@ namespace AutoBidder.Models
[JsonIgnore]
public bool AutoBidDuelDetected { get; set; }
+ ///
+ /// Il regime di concorrenza di quest'asta: quando lasciar sfogare gli altri e
+ /// quando tornare a puntare. Vedi .
+ /// Stato di sessione: al riavvio riparte Calmo, e va bene così.
+ ///
+ [JsonIgnore]
+ public Ml.CompetitionRegime Regime { get; } = new();
+
// ── Apprendimento: l'ultima risposta del modello per quest'asta ──
// 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.
@@ -612,6 +591,9 @@ namespace AutoBidder.Models
AutoResponsesInARow = Utilities.AutoBidDuel.Aggiorna(AutoResponsesInARow, ritardo);
+ // Il regime vuole sapere se una puntata di prova è stata coperta subito.
+ Regime.RispostaAvversaria(ritardo);
+
if (AutoBidDuelDetected) return false;
if (!Utilities.AutoBidDuel.DuelloRiconosciuto(AutoResponsesInARow, soglia)) return false;
@@ -643,12 +625,6 @@ namespace AutoBidder.Models
[JsonIgnore]
public int FailedBidCount { get; set; } = 0;
- ///
- /// Lista utenti identificati come aggressivi in questa asta
- ///
- [JsonIgnore]
- public HashSet AggressiveBidders { get; set; } = new(StringComparer.OrdinalIgnoreCase);
-
///
/// Offset dinamico calcolato per questa asta (ms)
///
@@ -700,21 +676,10 @@ namespace AutoBidder.Models
// IMPOSTAZIONI PER-ASTA (override globali)
// ???????????????????????????????????????????????????????????????
- ///
- /// Override: abilita/disabilita strategie avanzate per questa asta
- /// null = usa impostazione globale
- ///
- public bool? AdvancedStrategiesEnabled { get; set; }
-
///
/// Override: abilita/disabilita jitter per questa asta
///
public bool? JitterEnabledOverride { get; set; }
-
- ///
- /// Override: abilita/disabilita soft retreat per questa asta
- ///
- public bool? SoftRetreatEnabledOverride { get; set; }
///
/// Override per questa asta della sospensione a fascia oraria.
@@ -730,25 +695,6 @@ namespace AutoBidder.Models
// ?? NUOVO: Rilevamento situazione di duello
- ///
- /// True se rilevata situazione di duello (solo 2 bidder dominanti)
- ///
- [JsonIgnore]
- public bool IsDuelSituation { get; set; } = false;
-
- ///
- /// Username dell'avversario in caso di duello
- ///
- [JsonIgnore]
- public string? DuelOpponent { get; set; }
-
- ///
- /// Vantaggio/svantaggio nel duello (% puntate mie - % puntate avversario)
- /// Positivo = sto dominando, Negativo = sto perdendo
- ///
- [JsonIgnore]
- public double DuelAdvantage { get; set; } = 0;
-
// ???????????????????????????????????????????????????????????????????
// GESTIONE MEMORIA
// ???????????????????????????????????????????????????????????????????
@@ -775,13 +721,10 @@ namespace AutoBidder.Models
LatencyHistory?.Clear();
LatencyHistory = null!;
- AggressiveBidders?.Clear();
- AggressiveBidders = null!;
// Pulisci oggetti complessi
LastState = null;
CalculatedValue = null;
- DuelOpponent = null;
WinLimitDescription = null;
// Reset flag
diff --git a/Mimante/Services/AuctionMonitor.cs b/Mimante/Services/AuctionMonitor.cs
index 09c233f..c8ee8b9 100644
--- a/Mimante/Services/AuctionMonitor.cs
+++ b/Mimante/Services/AuctionMonitor.cs
@@ -282,7 +282,9 @@ namespace AutoBidder.Services
_monitoringCts = new CancellationTokenSource();
_supervisorTask = Task.Run(() => SupervisorLoop(_monitoringCts.Token));
- OnLog?.Invoke($"[START] Monitoraggio avviato (poll {settings.PollIntervalCriticalMs}-{settings.PollIntervalFarMs}ms, max {settings.MaxRequestsPerSecond:F0} req/s)");
+ OnLog?.Invoke(settings.MaxRequestsPerSecond > 0
+ ? $"[START] Monitoraggio avviato (max {settings.MaxRequestsPerSecond:F0} richieste/s)"
+ : "[START] Monitoraggio avviato (nessun tetto alle richieste: frena solo se il server lo chiede)");
}
public void Stop()
@@ -430,6 +432,7 @@ namespace AutoBidder.Services
}
auction.AddLatencyMeasurement(state.PollingLatencyMs);
+ Ml.LatencyModel.NotePing(state.PollingLatencyMs);
// Serie prezzi: va raccolta ora, a posteriori Bidoo non la espone.
auction.TrackPrice(state.Price);
@@ -641,30 +644,7 @@ namespace AutoBidder.Services
auction.AddLog($"[ASTA TERMINATA] {statusMsg}");
OnLog?.Invoke($"[FINE] [{auction.AuctionId}] Asta {statusMsg}");
- // Feedback per aste perse
var settings = SettingsManager.Load();
- if (!won && settings.ShowLateBidWarning)
- {
- // Se abbiamo provato a puntare ma fallito con errore timer
- var lastBidAttempt = auction.BidHistory
- .Where(b => b.EventType == BidEventType.MyBid && !b.Success)
- .OrderByDescending(b => b.Timestamp)
- .FirstOrDefault();
-
- if (lastBidAttempt != null &&
- (lastBidAttempt.Notes?.Contains("timer") == true ||
- lastBidAttempt.Notes?.Contains("scaduto") == true))
- {
- int currentOffset = auction.BidBeforeDeadlineMs > 0
- ? auction.BidBeforeDeadlineMs
- : settings.DefaultBidBeforeDeadlineMs;
-
- auction.AddLog($"[⚠️ SUGGERIMENTO] Puntata arrivata troppo tardi! " +
- $"Tempo attuale: {currentOffset}ms. " +
- $"Prova ad aumentarlo a {currentOffset + 500}ms o più.");
- OnLog?.Invoke($"[LATE] {auction.Name}: aumenta il tempo di puntata (attuale: {currentOffset}ms)");
- }
- }
auction.BidHistory.Add(new BidHistory
{
@@ -759,10 +739,15 @@ namespace AutoBidder.Services
auction.LastClickAt = DateTime.UtcNow;
// Registra metriche
- bool isCollision = result.Error?.Contains("timer") == true || result.Error?.Contains("scaduto") == true;
- _bidStrategy.RecordBidAttempt(auction, result.Success, collision: isCollision);
-
- if (!result.Success && isCollision)
+ bool isCollision = !result.Success && IsLateBidError(result.Error);
+ _bidStrategy.RecordBidAttempt(auction, result.Success);
+
+ // 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
+ // abbassano piano.
+ Ml.LatencyModel.NoteBid(result.LatencyMs, late: isCollision);
+
+ if (isCollision)
{
_bidStrategy.RecordTimerExpired(auction);
}
@@ -809,19 +794,18 @@ namespace AutoBidder.Services
auction.AddLog($"[BID FAIL] {result.Error} | Ping: {pollingPing}ms");
OnLog?.Invoke($"[FAIL] Puntata fallita su {auction.Name} ({auction.AuctionId}): {result.Error}");
- // Feedback per puntata tardiva
+ // Puntata tardiva: l'anticipo lo corregge da solo il modello di
+ // latenza (è già stato avvisato sopra). Qui si dice solo cosa ha deciso.
if (isLateBid && settings.ShowLateBidWarning)
{
- int currentOffset = auction.BidBeforeDeadlineMs > 0
- ? auction.BidBeforeDeadlineMs
- : settings.DefaultBidBeforeDeadlineMs;
-
- int suggestedOffset = currentOffset + 300 + pollingPing;
-
- auction.AddLog($"[⚠️ TIMING] Puntata arrivata troppo tardi! " +
- $"Offset attuale: {currentOffset}ms. Latenza totale: ~{pollingPing + result.LatencyMs}ms. " +
- $"Suggerimento: aumenta a {suggestedOffset}ms");
- OnLog?.Invoke($"[LATE] {auction.Name}: puntata tardiva, aumenta offset a {suggestedOffset}ms");
+ var adattivo = Ml.LatencyModel.RecommendedLeadMs(settings);
+ var manuale = auction.BidLeadIsManual && auction.BidBeforeDeadlineMs > 0;
+
+ auction.AddLog($"[TIMING] Puntata arrivata troppo tardi (latenza ~{pollingPing + result.LatencyMs}ms). " +
+ (manuale
+ ? $"L'anticipo di quest'asta è fisso a {auction.BidBeforeDeadlineMs}ms: il modello consiglierebbe {adattivo}ms."
+ : $"Anticipo adattivo portato a {adattivo}ms."));
+ OnLog?.Invoke($"[LATE] {auction.Name}: puntata tardiva, anticipo adattivo ora {adattivo}ms");
}
}
@@ -874,44 +858,6 @@ namespace AutoBidder.Services
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Value);
}
- // CONTROLLO ANTI-COLLISIONE (OPZIONALE)
- if (settings.HardcodedAntiCollisionEnabled)
- {
- var recentBidsThreshold = 10;
- var maxActiveBidders = 3;
-
- try
- {
- var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
- var recentBids = auction.RecentBids
- .Where(b => now - b.Timestamp <= recentBidsThreshold)
- .ToList();
-
- var activeBidders = recentBids
- .Select(b => b.Username)
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .Count();
-
- auction.AddLog($"Competizione: {activeBidders} bidder attivi (soglia={maxActiveBidders})",
- Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Competition);
-
- if (activeBidders >= maxActiveBidders)
- {
- var session = _apiClient.GetSession();
- var lastBid = recentBids.OrderByDescending(b => b.Timestamp).FirstOrDefault();
-
- if (lastBid != null &&
- !lastBid.Username.Equals(session?.Username, StringComparison.OrdinalIgnoreCase))
- {
- auction.AddLog($"⛔ Asta affollata: {activeBidders} bidder attivi",
- Models.AuctionLogLevel.Strategy, Models.AuctionLogCategory.Competition);
- return false;
- }
- }
- }
- catch { }
- }
-
// CONTROLLO 1: Limite minimo puntate residue
if (settings.MinimumRemainingBids > 0)
{
@@ -1209,11 +1155,12 @@ namespace AutoBidder.Services
});
}
- // Una puntata avversaria: se arriva col ritardo tipico dell'autopuntata
- // (~6 s dalla nostra) alimenta il conteggio del duello.
+ // Una puntata avversaria: quanto ha tardato rispetto alla nostra alimenta
+ // il riconoscimento del duello (~6 s = autopuntata) e il regime di
+ // concorrenza (una puntata di prova coperta subito). Si misura sempre;
+ // l'impostazione decide solo se il duello riconosciuto ferma le puntate.
var impostazioni = SettingsManager.Load();
- if (impostazioni.AutoBidDuelWithdrawEnabled &&
- auction.NoteOpponentResponse(DateTime.UtcNow, impostazioni.AutoBidDuelResponses))
+ if (auction.NoteOpponentResponse(DateTime.UtcNow, impostazioni.AutoBidDuelResponses))
{
auction.AddLog(AutoBidDuel.Spiegazione(auction.AutoResponsesInARow),
AuctionLogLevel.Warning, AuctionLogCategory.Strategy);
diff --git a/Mimante/Services/BidStrategyService.cs b/Mimante/Services/BidStrategyService.cs
index 1e03b33..816ec4e 100644
--- a/Mimante/Services/BidStrategyService.cs
+++ b/Mimante/Services/BidStrategyService.cs
@@ -1,359 +1,62 @@
using System;
-using System.Collections.Generic;
-using System.Linq;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
///
- /// Servizio per strategie avanzate di puntata.
- /// Implementa: adaptive latency, jitter, dynamic offset, heat metric,
- /// competition detection, soft retreat, probabilistic bidding, opponent profiling.
+ /// Decide se puntare. Quattro passi, sempre nello stesso ordine — vedi
+ /// Ml/LEGGIMI.md per il disegno completo e i numeri che lo giustificano.
+ ///
+ ///
+ /// 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.
+ /// 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.
+ /// 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.
+ /// 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.
+ ///
+ ///
+ /// 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.
///
public class BidStrategyService
{
- private readonly Random _random = new();
private int _sessionTotalBids = 0;
private DateTime _sessionStartedAt = DateTime.UtcNow;
-
- ///
- /// Aggiorna heat metric per un'asta
- ///
- public void UpdateHeatMetric(AuctionInfo auction, AppSettings settings, string currentUsername = "")
- {
- if (!settings.CompetitionDetectionEnabled) return;
-
- var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
- var windowStart = now - settings.CompetitionWindowSeconds;
-
- // Conta bidder unici nella finestra temporale (escludo me stesso)
- var recentBids = auction.RecentBids
- .Where(b => b.Timestamp >= windowStart)
- .Where(b => !b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
- .ToList();
-
- auction.ActiveBiddersCount = recentBids
- .Select(b => b.Username)
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .Count();
-
- // Conta collisioni (puntate nello stesso secondo)
- var bidsBySecond = recentBids
- .GroupBy(b => b.Timestamp)
- .Where(g => g.Count() > 1)
- .Count();
-
- auction.CollisionCount = bidsBySecond;
-
- // Calcola heat metric (0-100)
- // Fattori: bidder attivi (40%), frequenza puntate (30%), collisioni (30%)
-
- int bidderScore = Math.Min(auction.ActiveBiddersCount * 15, 40); // Max 40 punti
- int frequencyScore = Math.Min(recentBids.Count * 3, 30); // Max 30 punti
- int collisionScore = Math.Min(auction.CollisionCount * 10, 30); // Max 30 punti
-
- auction.HeatMetric = bidderScore + frequencyScore + collisionScore;
-
- // Identifica bidder aggressivi e situazioni di duello
- if (settings.OpponentProfilingEnabled)
- {
- UpdateAggressiveBidders(auction, settings, currentUsername);
- DetectDuelSituation(auction, settings, currentUsername);
- }
- }
-
- ///
- /// Identifica e tracca bidder aggressivi (basato su ultime N puntate, esclude utente corrente)
- ///
- private void UpdateAggressiveBidders(AuctionInfo auction, AppSettings settings, string currentUsername)
- {
- // ?? FIX: Usa finestra scorrevole di ultime N puntate
- var windowSize = settings.AggressiveBidderWindowSize > 0 ? settings.AggressiveBidderWindowSize : 30;
- var recentWindow = auction.RecentBids
- .Take(windowSize)
- .ToList();
-
- var bidCounts = recentWindow
- .GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
- .Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
- .ToList();
-
- auction.AggressiveBidders.Clear();
-
- foreach (var bidder in bidCounts)
- {
- // ?? FIX: NON aggiungere l'utente corrente come aggressivo!
- if (bidder.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
- continue;
-
- // ?? FIX: Soglia pi� permissiva - usa percentuale invece di conteggio assoluto
- // Un bidder � "aggressivo" se ha pi� del 40% delle puntate nella finestra (configurabile)
- var percentageThreshold = settings.AggressiveBidderPercentageThreshold > 0 ? settings.AggressiveBidderPercentageThreshold : 40.0;
-
- if (bidder.Percentage >= percentageThreshold || bidder.Count >= settings.AggressiveBidderThreshold)
- {
- auction.AggressiveBidders.Add(bidder.Username);
- }
- }
- }
-
- ///
- /// Rileva situazione di "duello" (solo 2 bidder attivi che si contendono l'asta)
- /// In questa situazione bisogna essere pronti perch� se uno si ritira l'altro vince
- ///
- private void DetectDuelSituation(AuctionInfo auction, AppSettings settings, string currentUsername)
- {
- var windowSize = settings.DuelDetectionWindowSize > 0 ? settings.DuelDetectionWindowSize : 20;
- var recentWindow = auction.RecentBids.Take(windowSize).ToList();
-
- if (recentWindow.Count < 6) // Serve un minimo di puntate per rilevare un pattern
- {
- auction.IsDuelSituation = false;
- auction.DuelOpponent = null;
- return;
- }
-
- var bidders = recentWindow
- .GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
- .Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
- .OrderByDescending(b => b.Count)
- .ToList();
-
- // Duello: esattamente 2 bidder dominanti che coprono almeno l'80% delle puntate
- if (bidders.Count >= 2)
- {
- var top2Percentage = bidders.Take(2).Sum(b => b.Percentage);
-
- if (top2Percentage >= 80 && bidders.Count <= 3)
- {
- auction.IsDuelSituation = true;
-
- // Trova l'avversario (chi NON sono io)
- var opponent = bidders.FirstOrDefault(b =>
- !b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
-
- auction.DuelOpponent = opponent?.Username;
-
- // Calcola chi sta dominando
- var myStats = bidders.FirstOrDefault(b =>
- b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
-
- auction.DuelAdvantage = myStats != null && opponent != null
- ? myStats.Percentage - opponent.Percentage
- : 0;
- }
- else
- {
- auction.IsDuelSituation = false;
- auction.DuelOpponent = null;
- auction.DuelAdvantage = 0;
- }
- }
- else
- {
- auction.IsDuelSituation = false;
- auction.DuelOpponent = null;
- }
- }
-
- ///
- /// Verifica se � il caso di puntare considerando tutte le strategie
- ///
+
public BidDecision ShouldPlaceBid(AuctionInfo auction, AuctionState state, AppSettings settings, string currentUsername)
{
var decision = new BidDecision { ShouldBid = true };
-
- // Se le strategie avanzate sono disabilitate per questa asta, salta tutto
- if (auction.AdvancedStrategiesEnabled == false)
- {
- return decision;
- }
- // 0. DUELLO CON L'AUTOPUNTATA — il primo controllo, perché è quello che
- // costa di più sbagliare.
- //
- // L'autopuntata del sito scatta a 2 secondi e si riarma ogni volta che il suo
- // padrone perde la testa: risponde sempre, risponde in ~6 secondi, e non si
- // stanca. Contro di lei non esiste attrito che si possa vincere — si
- // scambiano puntate una a una finché uno dei due esaurisce il credito.
- //
- // Nelle prove reali il motore ci ha lasciato 191 puntate su tre aste per zero
- // vittorie, con un ciclo regolare da 13,0 secondi (7 di attesa nostra più 6
- // della sua risposta). Rigiocando i dossier con questa regola, quelle aste
- // sarebbero costate una manciata di puntate senza perdere nessuna vittoria,
- // perché vittorie non ce n'erano.
- if (settings.AutoBidDuelWithdrawEnabled && auction.AutoBidDuelDetected)
- {
- decision.ShouldBid = false;
- decision.Reason = Utilities.AutoBidDuel.Spiegazione(auction.AutoResponsesInARow);
- return decision;
- }
+ // ── 1. Paletti ──────────────────────────────────────────────────
- // 0-bis. FASCIA ORARIA SOSPESA.
- //
- // L'asta NON viene fermata: resta Attiva, continua a essere seguita e
- // riprende a puntare da sola alla fine della fascia. È voluto — le aste
- // lunghe attraversano la fascia, e fermarle davvero significherebbe
- // perderle invece che risparmiare.
- //
- // Su 6060 aste concluse: chiusura mediana al 4,6% del valore alle 12 e al
- // 5,3% fra le 10 e le 13, contro l'8,6% a mezzanotte e il 9,7% alle 9, con
- // 26-29 puntate per vincere invece di 21-24. Quasi il doppio del costo per
- // lo stesso oggetto.
+ // 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.
if (QuietHoursApply(auction, settings) &&
- Utilities.BiddingHours.IsQuiet(DateTime.Now, settings.QuietHoursStart, settings.QuietHoursEnd))
+ BiddingHours.IsQuiet(DateTime.Now, settings.QuietHoursStart, settings.QuietHoursEnd))
{
decision.ShouldBid = false;
- decision.Reason = Utilities.BiddingHours.Spiegazione(
- settings.QuietHoursStart, settings.QuietHoursEnd);
+ decision.Reason = BiddingHours.Spiegazione(settings.QuietHoursStart, settings.QuietHoursEnd);
return decision;
}
- // 0-ter. VALORE ATTESO APPRESO.
- //
- // Il modello dice con che probabilità una puntata fatta adesso resterebbe
- // senza risposta; il valore atteso è quella probabilità per il margine che
- // resta (valore meno prezzo meno il centesimo), meno il costo della puntata.
- // Sotto zero, in media, puntare perde soldi.
- //
- // Il modello parla sempre, ma decide solo quando ha appreso abbastanza aste:
- // un modello appena nato direbbe cose a caso, e a caso fermerebbe le puntate.
- // La probabilità e il valore atteso finiscono comunque sull'asta, per il
- // registro e per l'interfaccia.
- if (settings.LearningGateEnabled && auction.BuyNowPrice is > 0)
- {
- var prob = Ml.LearningService.PredictUnanswered(auction, state, DateTime.Now, currentUsername);
- if (prob is { } p)
- {
- var costo = settings.AverageBidCostEuro * Math.Max(0.1, settings.LearningEvMultiplier);
- var margine = auction.BuyNowPrice.Value - state.Price - 0.01;
- var ev = p * margine - costo;
-
- auction.LearnedUnansweredProbability = p;
- auction.LearnedExpectedValue = ev;
-
- var pronto = Ml.LearningService.IsReady(settings);
- Ml.LearningService.RecordDecision(auction.Name, state.Price, p, ev, blocked: pronto && ev < 0, ready: pronto);
-
- if (pronto && ev < 0)
- {
- decision.ShouldBid = false;
- decision.Reason =
- $"valore atteso negativo: P(senza risposta) {p:P2} × margine {margine:F2} € " +
- $"− puntata {costo:F2} € = {ev:+0.000;-0.000} €";
- return decision;
- }
- }
- }
-
- // ? RIMOSSO: Entry Point - Era sbagliato!
- // I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
- // Se l'utente imposta MaxPrice=2�, vuole puntare FINO A 2�, non fino al 70%!
- // I controlli MinPrice/MaxPrice sono gi� gestiti in AuctionMonitor.ShouldBid()
- // L'Entry Point pu� essere usato SOLO per calcolare limiti CONSIGLIATI, non per bloccare.
-
- // 1. ANTI-BOT — riconoscimento del puntatore a cadenza fissa.
- //
- // Spento di proposito (vedi AppSettings.AntiBotDetectionEnabled): rigiocando i
- // dossier raccolti la regola rifiutava fra il 4% e il 9% delle puntate, a seconda
- // dell'anticipo. Chi lo accende lo fa sapendo che e' una scelta di
- // prudenza, non una difesa: un avversario a cadenza fissa è il più facile da
- // battere, perché punta sempre con secondi di anticipo.
- if (settings.AntiBotDetectionEnabled && !string.IsNullOrEmpty(state.LastBidder))
- {
- var botCheck = DetectBotPattern(auction, state.LastBidder, currentUsername);
- if (botCheck.IsBot)
- {
- decision.ShouldBid = false;
- decision.Reason = $"Anti-bot: {state.LastBidder} punta a cadenza fissa ({botCheck.GapSeconds:F0}s)";
- return decision;
- }
- }
-
- // ?? 2. USER EXHAUSTION - Sfrutta utenti stanchi (info solo, non blocca)
- if (settings.UserExhaustionEnabled && !string.IsNullOrEmpty(state.LastBidder))
- {
- var exhaustionCheck = CheckUserExhaustion(auction, state.LastBidder, currentUsername);
- // Non blocchiamo, ma potremmo loggare per info
- }
-
- // 3. Verifica soft retreat
- if (settings.SoftRetreatEnabled || (auction.SoftRetreatEnabledOverride ?? settings.SoftRetreatEnabled))
- {
- if (auction.IsInSoftRetreat)
- {
- var retreatEnd = auction.LastSoftRetreatAt?.AddSeconds(settings.SoftRetreatDurationSeconds);
- if (retreatEnd > DateTime.UtcNow)
- {
- decision.ShouldBid = false;
- decision.Reason = $"Soft retreat attivo (termina tra {(retreatEnd.Value - DateTime.UtcNow).TotalSeconds:F0}s)";
- return decision;
- }
- else
- {
- // Fine soft retreat
- auction.IsInSoftRetreat = false;
- auction.ConsecutiveCollisions = 0;
- }
- }
-
- // Verifica se attivare soft retreat
- if (auction.ConsecutiveCollisions >= settings.SoftRetreatAfterCollisions)
- {
- auction.IsInSoftRetreat = true;
- auction.LastSoftRetreatAt = DateTime.UtcNow;
- decision.ShouldBid = false;
- decision.Reason = $"Soft retreat attivato dopo {auction.ConsecutiveCollisions} collisioni";
- return decision;
- }
- }
-
- // 2. Verifica competition threshold
- if (settings.CompetitionDetectionEnabled)
- {
- if (auction.ActiveBiddersCount >= settings.CompetitionThreshold)
- {
- // Controlla se l'ultimo bidder sono io - se s�, posso continuare
- var lastBid = auction.RecentBids.OrderByDescending(b => b.Timestamp).FirstOrDefault();
- if (lastBid != null && !lastBid.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
- {
- if (settings.AutoPauseHotAuctions && auction.HeatMetric >= settings.HeatThresholdForPause)
- {
- decision.ShouldBid = false;
- decision.Reason = $"Asta troppo calda (heat={auction.HeatMetric}%, bidder={auction.ActiveBiddersCount})";
- return decision;
- }
- }
- }
- }
-
- // 3. Verifica opponent profiling
- if (settings.OpponentProfilingEnabled && auction.AggressiveBidders.Count > 0)
- {
- if (settings.AggressiveBidderAction == "Avoid")
- {
- decision.ShouldBid = false;
- decision.Reason = $"Bidder aggressivi rilevati: {string.Join(", ", auction.AggressiveBidders.Take(3))}";
- return decision;
- }
- }
-
- // 4. Probabilistic bidding
- if (settings.ProbabilisticBiddingEnabled)
- {
- var probability = CalculateBidProbability(auction, settings);
- var roll = _random.NextDouble();
-
- if (roll > probability)
- {
- decision.ShouldBid = false;
- decision.Reason = $"Skip probabilistico (p={probability:P0}, roll={roll:P0})";
- return decision;
- }
- }
-
- // 5. Bankroll manager
if (settings.BankrollManagerEnabled)
{
var bankrollCheck = CheckBankrollLimits(auction, settings);
@@ -364,150 +67,90 @@ namespace AutoBidder.Services
return decision;
}
}
-
- // ? RIMOSSO: DetectLastSecondSniper - causava falsi positivi
- // In un duello, TUTTI i bidder hanno pattern regolari (ogni reset del timer)
- // Questa strategia bloccava puntate legittime e faceva perdere aste
-
- // 7. VELOCITA' DEL PREZZO - l'asta sta salendo troppo in fretta.
+
+ // ── 2. Duello con l'autopuntata ─────────────────────────────────
//
- // La soglia era fissa a 0,10 EUR/s, cioe' dieci puntate al secondo: su 40.000
- // valutazioni riprese dai dossier non e' scattata mai una volta, e il massimo
- // mai osservato e' 0,016 EUR/s. Ora e' un'impostazione, spenta di predefinito:
- // un controllo che non puo' scattare da' una falsa sensazione di protezione.
- if (settings.PriceVelocityBlockPerSecond > 0)
+ // 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.
+ if (auction.AutoBidDuelDetected)
{
- var priceVelocity = CalculatePriceVelocity(auction);
- if (priceVelocity > settings.PriceVelocityBlockPerSecond)
+ auction.Regime.AutopuntataRiconosciuta();
+
+ if (settings.AutoBidDuelWithdrawEnabled)
{
decision.ShouldBid = false;
- decision.Reason = $"Prezzo sale troppo in fretta ({priceVelocity:F3} EUR/s, soglia {settings.PriceVelocityBlockPerSecond:F3})";
+ decision.Reason = AutoBidDuel.Spiegazione(auction.AutoResponsesInARow);
return decision;
}
}
-
- return decision;
- }
-
- ///
- /// Calcola la velocit� di crescita del prezzo (�/secondo)
- ///
- private double CalculatePriceVelocity(AuctionInfo auction)
- {
- if (auction.RecentBids.Count < 5) return 0;
-
- var recentBids = auction.RecentBids.Take(10).ToList();
- if (recentBids.Count < 2) return 0;
-
- var first = recentBids.Last();
- var last = recentBids.First();
-
- var timeDiffSeconds = last.Timestamp - first.Timestamp;
- if (timeDiffSeconds <= 0) return 0;
-
- var priceDiff = last.Price - first.Price;
- return (double)priceDiff / timeDiffSeconds;
- }
-
- ///
- /// Riconosce un avversario che punta a cadenza fissa.
- ///
- /// Limite di misura, da tenere presente. Le marche temporali dello
- /// storico di Bidoo sono in secondi interi: le pause fra due puntate sono
- /// quindi numeri interi, e la loro deviazione standard vale 0 ms (pause tutte
- /// uguali) oppure almeno 500 ms. La vecchia soglia "deviazione < 50 ms" si
- /// riduceva percio' a "le ultime pause sono identiche al secondo" - condizione
- /// comunissima fra utenti normali: rigiocando i dossier raccolti rifiutava fra il
- /// 4% e il 9% delle puntate, proprio negli istanti in cui il motore avrebbe
- /// sparato.
- ///
- /// Ora servono quattro pause tutte uguali e brevi (sotto i 15 s):
- /// resta un indizio, non una prova, ed e' il motivo per cui l'impostazione che
- /// la usa nasce spenta.
- ///
- private (bool IsBot, double GapSeconds) DetectBotPattern(AuctionInfo auction, string? lastBidder, string currentUsername)
- {
- if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
- return (false, 0);
- var userBids = auction.RecentBids
- .Where(b => b.Username.Equals(lastBidder, StringComparison.OrdinalIgnoreCase))
- .OrderByDescending(b => b.Timestamp)
- .Take(5)
- .ToList();
+ // ── 3. Valore atteso appreso ────────────────────────────────────
+ //
+ // 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.
+ double? ev = null;
+ string? spiegazioneEv = null;
+ var pronto = false;
- if (userBids.Count < 5) return (false, 0);
-
- var gaps = new List();
- for (var i = 0; i < userBids.Count - 1; i++)
- gaps.Add(userBids[i].Timestamp - userBids[i + 1].Timestamp);
-
- // Una pausa nulla vuol dire due puntate nello stesso secondo: e' rumore
- // dello storico, non una cadenza.
- if (gaps.Count < 4 || gaps.Any(g => g <= 0 || g > 15)) return (false, 0);
-
- var isBot = gaps.All(g => g == gaps[0]);
- return (isBot, gaps[0]);
- }
-
- ///
- /// Verifica se un utente � esausto (molte puntate, pu� mollare)
- ///
- private (bool ShouldExploit, string Reason) CheckUserExhaustion(AuctionInfo auction, string? lastBidder, string currentUsername)
- {
- if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
- return (false, "");
-
- // Verifica se l'utente � un "heavy user" (>50 puntate totali)
- if (auction.BidderStats.TryGetValue(lastBidder, out var stats))
+ if (settings.LearningGateEnabled && auction.BuyNowPrice is > 0)
{
- if (stats.BidCount > 50)
+ var prob = Ml.LearningService.PredictUnanswered(auction, state, DateTime.Now, currentUsername);
+ if (prob is { } p)
{
- // Se ci sono pochi altri bidder attivi, pu� essere un buon momento
- var activeBidders = auction.BidderStats.Values.Count(b => b.BidCount > 5);
- if (activeBidders <= 3)
- {
- return (true, $"{lastBidder} ha {stats.BidCount} puntate, potrebbe mollare");
- }
+ var costo = settings.AverageBidCostEuro * Math.Max(0.1, settings.LearningEvMultiplier);
+ var margine = auction.BuyNowPrice.Value - state.Price - 0.01;
+ var valore = p * margine - costo;
+
+ auction.LearnedUnansweredProbability = p;
+ auction.LearnedExpectedValue = valore;
+
+ pronto = Ml.LearningService.IsReady(settings);
+ ev = valore;
+ spiegazioneEv =
+ $"valore atteso negativo: P(senza risposta) {p:P2} × margine {margine:F2} € " +
+ $"− puntata {costo:F2} € = {valore:+0.000;-0.000} €";
+
+ Ml.LearningService.RecordDecision(auction.Name, state.Price, p, valore,
+ blocked: pronto && valore < 0, ready: pronto);
}
}
-
- return (false, "");
- }
-
- ///
- /// Calcola probabilit� di puntata basata su competizione e ROI
- ///
- private double CalculateBidProbability(AuctionInfo auction, AppSettings settings)
- {
- var probability = settings.BaseBidProbability;
-
- // Riduci probabilit� per ogni bidder attivo oltre la soglia
- var extraBidders = Math.Max(0, auction.ActiveBiddersCount - settings.CompetitionThreshold);
- probability -= extraBidders * settings.ProbabilityReductionPerBidder;
-
- // Riduci per heat metric alto
- if (auction.HeatMetric > 70)
+
+ // ── 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.
+ if (pronto && ev is { } valoreAtteso)
{
- probability -= 0.1;
+ var statoPrima = auction.Regime.StatoAttuale;
+ var consentito = auction.Regime.Osserva(valoreAtteso >= 0);
+
+ if (auction.Regime.StatoAttuale != statoPrima)
+ {
+ auction.AddLog($"[REGIME] {statoPrima} → {auction.Regime.StatoAttuale}: {auction.Regime.Spiegazione()}",
+ AuctionLogLevel.Strategy, AuctionLogCategory.Strategy);
+ }
+
+ if (!consentito)
+ {
+ decision.ShouldBid = false;
+ decision.Reason = auction.Regime.StatoAttuale == Ml.CompetitionRegime.Stato.Calmo
+ ? spiegazioneEv
+ : auction.Regime.Spiegazione();
+ return decision;
+ }
}
-
- // Aumenta se abbiamo un buon ROI potenziale
- if (auction.CalculatedValue?.Savings > 0)
- {
- probability += 0.1;
- }
-
- return Math.Clamp(probability, 0.1, 1.0);
+
+ return decision;
}
-
- ///
- /// Verifica limiti bankroll
- ///
+
private BankrollCheckResult CheckBankrollLimits(AuctionInfo auction, AppSettings settings)
{
var result = new BankrollCheckResult { CanBid = true };
-
+
// Limite puntate per asta
var maxPerAuction = auction.MaxBidsOverride ?? settings.MaxBidsPerAuction;
if (maxPerAuction > 0 && auction.SessionBidCount >= maxPerAuction)
@@ -516,7 +159,7 @@ namespace AutoBidder.Services
result.Reason = $"Limite puntate per asta raggiunto ({auction.SessionBidCount}/{maxPerAuction})";
return result;
}
-
+
// Limite puntate per sessione
if (settings.MaxBidsPerSession > 0 && _sessionTotalBids >= settings.MaxBidsPerSession)
{
@@ -524,7 +167,7 @@ namespace AutoBidder.Services
result.Reason = $"Limite puntate per sessione raggiunto ({_sessionTotalBids}/{settings.MaxBidsPerSession})";
return result;
}
-
+
// Budget giornaliero
if (settings.DailyBudgetEuro > 0)
{
@@ -532,17 +175,14 @@ namespace AutoBidder.Services
if (spent >= settings.DailyBudgetEuro)
{
result.CanBid = false;
- result.Reason = $"Budget giornaliero esaurito (�{spent:F2}/�{settings.DailyBudgetEuro:F2})";
+ result.Reason = $"Budget giornaliero esaurito (€{spent:F2}/€{settings.DailyBudgetEuro:F2})";
return result;
}
}
-
+
return result;
}
-
- ///
- /// Registra una puntata effettuata (per tracking)
- ///
+
///
/// La sospensione a fascia oraria vale per quest'asta?
///
@@ -563,60 +203,40 @@ namespace AutoBidder.Services
return settings.QuietHoursEnabled;
}
- public void RecordBidAttempt(AuctionInfo auction, bool success, bool collision = false)
+ /// Registra una puntata inviata, riuscita o no.
+ public void RecordBidAttempt(AuctionInfo auction, bool success)
{
auction.SessionBidCount++;
_sessionTotalBids++;
-
+
if (success)
{
auction.SuccessfulBidCount++;
- auction.ConsecutiveCollisions = 0;
// 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 la regola del duello vede le stesse cose in entrambi.
+ // quindi duello e regime vedono le stesse cose in entrambi.
auction.LastMyBidAtUtc = DateTime.UtcNow;
+ auction.Regime.PuntataPiazzata();
}
else
{
auction.FailedBidCount++;
}
-
- if (collision)
- {
- auction.CollisionCount++;
- auction.ConsecutiveCollisions++;
- }
}
-
- ///
- /// Registra un ciclo perso perche' la puntata e' arrivata a giochi chiusi.
- ///
- /// Qui non si tocca ConsecutiveCollisions: chi chiama questo
- /// metodo ha gia' chiamato con collision: true
- /// sulla stessa puntata, e il contatore veniva percio' incrementato due volte.
- /// Con la soglia predefinita di tre collisioni bastavano due puntate tardive
- /// per far scattare il ritiro - e un ritiro di trenta secondi, su cicli da otto o
- /// dieci, significa perdere l'asta.
- ///
+
+ /// Un ciclo perso perche' la puntata e' arrivata a giochi chiusi.
public void RecordTimerExpired(AuctionInfo auction)
{
auction.TimerExpiredCount++;
}
-
- ///
- /// Reset contatori sessione
- ///
+
public void ResetSession()
{
_sessionTotalBids = 0;
_sessionStartedAt = DateTime.UtcNow;
}
-
- ///
- /// Ottiene statistiche sessione corrente
- ///
+
public SessionStats GetSessionStats()
{
return new SessionStats
@@ -626,41 +246,19 @@ namespace AutoBidder.Services
};
}
}
-
- ///
- /// Risultato calcolo timing puntata
- ///
- public class BidTimingResult
- {
- public int BaseOffsetMs { get; set; }
- public int LatencyCompensationMs { get; set; }
- public int DynamicAdjustmentMs { get; set; }
- public int JitterMs { get; set; }
- public int FinalOffsetMs { get; set; }
- public bool ShouldBid { get; set; }
- }
-
- ///
- /// Decisione se puntare
- ///
+
public class BidDecision
{
public bool ShouldBid { get; set; }
public string? Reason { get; set; }
}
-
- ///
- /// Risultato verifica bankroll
- ///
+
public class BankrollCheckResult
{
public bool CanBid { get; set; }
public string? Reason { get; set; }
}
-
- ///
- /// Statistiche sessione
- ///
+
public class SessionStats
{
public int TotalBids { get; set; }
diff --git a/Mimante/Tests/AutonomyTests.cs b/Mimante/Tests/AutonomyTests.cs
new file mode 100644
index 0000000..594bb84
--- /dev/null
+++ b/Mimante/Tests/AutonomyTests.cs
@@ -0,0 +1,217 @@
+using AutoBidder.Ml;
+using AutoBidder.Utilities;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+///
+/// Il regime di concorrenza deve avere isteresi nel verso giusto: uscire e' facile, rientrare
+/// e' difficile, e ogni sondaggio coperto rende il rientro successivo piu' difficile.
+/// Sbagliare a rientrare costa una puntata; sbagliare a uscire costa un'attesa.
+///
+public class CompetitionRegimeTests
+{
+ [Fact]
+ public void In_calmo_si_punta_quando_il_valore_atteso_e_positivo()
+ {
+ var r = new CompetitionRegime();
+ Assert.True(r.Osserva(true));
+ Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
+ }
+
+ [Fact]
+ public void Tre_negativi_di_fila_portano_allo_sfogo_ma_uno_solo_no()
+ {
+ var r = new CompetitionRegime();
+ Assert.False(r.Osserva(false));
+ Assert.False(r.Osserva(false));
+ Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
+
+ Assert.False(r.Osserva(false));
+ Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
+ }
+
+ [Fact]
+ public void Un_positivo_in_mezzo_azzera_il_conto_dei_negativi()
+ {
+ var r = new CompetitionRegime();
+ r.Osserva(false); r.Osserva(false);
+ r.Osserva(true);
+ r.Osserva(false); r.Osserva(false);
+ Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
+ }
+
+ private static CompetitionRegime InSfogo()
+ {
+ var r = new CompetitionRegime();
+ for (var i = 0; i < CompetitionRegime.NegativiPerSfogo; i++) r.Osserva(false);
+ Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
+ return r;
+ }
+
+ [Fact]
+ public void Dallo_sfogo_si_rientra_solo_dopo_abbastanza_cicli_buoni_di_fila()
+ {
+ var r = InSfogo();
+
+ Assert.False(r.Osserva(true));
+ Assert.False(r.Osserva(true));
+ Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
+
+ Assert.True(r.Osserva(true)); // terzo: puntata di prova concessa
+ Assert.Equal(CompetitionRegime.Stato.Sondaggio, r.StatoAttuale);
+ }
+
+ [Fact]
+ public void Un_ciclo_cattivo_durante_lo_sfogo_azzera_la_serie()
+ {
+ var r = InSfogo();
+ r.Osserva(true); r.Osserva(true);
+ r.Osserva(false);
+ r.Osserva(true); r.Osserva(true);
+ Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
+ }
+
+ [Fact]
+ public void Un_sondaggio_coperto_subito_raddoppia_la_pazienza()
+ {
+ var r = InSfogo();
+ for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
+ r.PuntataPiazzata();
+
+ r.RispostaAvversaria(6.0); // la firma dell'autopuntata
+
+ Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
+ Assert.Equal(CompetitionRegime.PazienzaIniziale * 2, r.Pazienza);
+ Assert.Equal(1, r.SondaggiFalliti);
+ }
+
+ [Fact]
+ public void Un_sondaggio_senza_risposta_riporta_al_calmo()
+ {
+ var r = InSfogo();
+ for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
+ r.PuntataPiazzata();
+
+ r.NessunaRisposta();
+
+ Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
+ }
+
+ [Fact]
+ public void Una_risposta_lenta_al_sondaggio_non_e_una_macchina()
+ {
+ var r = InSfogo();
+ for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
+ r.PuntataPiazzata();
+
+ r.RispostaAvversaria(25.0);
+
+ Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
+ Assert.Equal(0, r.SondaggiFalliti);
+ }
+
+ [Fact]
+ public void Durante_il_sondaggio_si_punta_una_volta_sola()
+ {
+ var r = InSfogo();
+ for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
+ r.PuntataPiazzata();
+
+ Assert.False(r.Osserva(true)); // la prova e' in corso: si aspetta l'esito
+ }
+
+ [Fact]
+ public void La_pazienza_ha_un_tetto()
+ {
+ var r = new CompetitionRegime();
+ for (var k = 0; k < 10; k++)
+ {
+ if (r.StatoAttuale == CompetitionRegime.Stato.Calmo)
+ for (var i = 0; i < CompetitionRegime.NegativiPerSfogo; i++) r.Osserva(false);
+ while (r.StatoAttuale == CompetitionRegime.Stato.Sfogo) r.Osserva(true);
+ r.PuntataPiazzata();
+ r.RispostaAvversaria(6.0);
+ }
+ Assert.Equal(CompetitionRegime.PazienzaMassima, r.Pazienza);
+ }
+
+ [Fact]
+ public void L_autopuntata_riconosciuta_manda_in_sfogo_con_pazienza_doppia()
+ {
+ var r = new CompetitionRegime();
+ r.AutopuntataRiconosciuta();
+ Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
+ Assert.True(r.Pazienza >= CompetitionRegime.PazienzaIniziale * 2);
+ }
+}
+
+///
+/// L'anticipo adattivo si muove solo dentro i paletti, sale subito su una puntata
+/// tardiva e scende piano, e senza campioni usa il predefinito.
+///
+public class LatencyModelTests
+{
+ private static AppSettings Paletti(int min = 300, int max = 1500, int predefinito = 500) =>
+ new() { LeadMinMs = min, LeadMaxMs = max, DefaultBidBeforeDeadlineMs = predefinito };
+
+ [Fact]
+ public void Senza_campioni_vale_il_predefinito_entro_i_paletti()
+ {
+ LatencyModel.ResetForTests();
+ Assert.Equal(500, LatencyModel.RecommendedLeadMs(Paletti()));
+ Assert.Equal(600, LatencyModel.RecommendedLeadMs(Paletti(min: 600)));
+ }
+
+ [Fact]
+ public void L_anticipo_e_margine_piu_coda_della_latenza()
+ {
+ LatencyModel.ResetForTests(marginMs: 300);
+ for (var i = 0; i < 98; i++) LatencyModel.NotePing(50);
+ LatencyModel.NotePing(400); // la coda: due campioni su cento
+ LatencyModel.NotePing(400);
+
+ // p99 su 100 campioni = il 99-esimo ordinato = 400 -> 300 + 400 = 700
+ Assert.Equal(700, LatencyModel.RecommendedLeadMs(Paletti()));
+ }
+
+ [Fact]
+ public void Una_puntata_tardiva_alza_il_margine_subito()
+ {
+ LatencyModel.ResetForTests(marginMs: 300);
+ for (var i = 0; i < 20; i++) LatencyModel.NotePing(50);
+
+ var prima = LatencyModel.RecommendedLeadMs(Paletti());
+ LatencyModel.NoteBid(60, late: true);
+ var dopo = LatencyModel.RecommendedLeadMs(Paletti());
+
+ Assert.Equal(prima + 150, dopo);
+ }
+
+ [Fact]
+ public void Molte_puntate_in_tempo_lo_abbassano_piano()
+ {
+ LatencyModel.ResetForTests(marginMs: 600);
+ for (var i = 0; i < 20; i++) LatencyModel.NotePing(50);
+
+ var prima = LatencyModel.RecommendedLeadMs(Paletti());
+ // Stessa latenza dei ping, cosi' cambia solo il margine e non la coda.
+ for (var i = 0; i < 20; i++) LatencyModel.NoteBid(50, late: false);
+ var dopo = LatencyModel.RecommendedLeadMs(Paletti());
+
+ Assert.Equal(prima - 25, dopo);
+ }
+
+ [Fact]
+ public void Non_esce_mai_dai_paletti()
+ {
+ LatencyModel.ResetForTests(marginMs: 2000);
+ for (var i = 0; i < 20; i++) LatencyModel.NotePing(900);
+
+ Assert.Equal(1500, LatencyModel.RecommendedLeadMs(Paletti(max: 1500)));
+
+ LatencyModel.ResetForTests(marginMs: 150);
+ for (var i = 0; i < 20; i++) LatencyModel.NotePing(10);
+ Assert.Equal(300, LatencyModel.RecommendedLeadMs(Paletti(min: 300)));
+ }
+}
diff --git a/Mimante/Tests/BacktestTests.cs b/Mimante/Tests/BacktestTests.cs
index e61e8b1..cc2fa89 100644
--- a/Mimante/Tests/BacktestTests.cs
+++ b/Mimante/Tests/BacktestTests.cs
@@ -35,13 +35,9 @@ public class BacktestTests
/// Impostazioni con tutte le strategie che potrebbero bloccare messe a tacere.
private static AppSettings Neutral() => new()
{
- AntiBotDetectionEnabled = false,
- SoftRetreatEnabled = false,
- ProbabilisticBiddingEnabled = false,
- CompetitionDetectionEnabled = false,
- OpponentProfilingEnabled = false,
BankrollManagerEnabled = false,
- PriceVelocityBlockPerSecond = 0
+ AutoBidDuelWithdrawEnabled = false,
+ LearningGateEnabled = false
};
// ── Lettura ──────────────────────────────────────────────────────────
@@ -195,37 +191,6 @@ public class BacktestTests
// ── Le strategie che bloccano ────────────────────────────────────────
- [Fact]
- public void Lanti_bot_acceso_blocca_su_cadenza_fissa_e_spento_no()
- {
- // Un avversario che punta esattamente ogni 4 secondi: e' il caso che la vecchia
- // regola scambiava per automatismo, e sui dossier veri costava fra il 4% e il 9%
- // delle puntate.
- var lines = new List { Header };
- long unix = 1000;
- for (var i = 0; i < 8; i++)
- {
- lines.Add(Bid(i * 0.1, "tizio", unix + i * 4, 1.0 + i * 0.01));
- }
- lines.Add(Poll(1.0, 1100, 1100, bidder: "tizio"));
- lines.Add(SummaryLine);
-
- var session = DossierReader.Read(lines);
-
- var acceso = Neutral();
- acceso.AntiBotDetectionEnabled = true;
- var bloccato = BacktestRunner.Run(session, new BacktestRunner.Options(1000, acceso));
-
- Assert.Equal(1, bloccato.Reached);
- Assert.Equal(0, bloccato.Bids);
- Assert.Equal(1, bloccato.Blocks["anti-bot"]);
-
- // Con il predefinito nuovo (spento) la puntata parte.
- var spento = BacktestRunner.Run(session, new BacktestRunner.Options(1000, Neutral()));
- Assert.Equal(1, spento.Bids);
- Assert.Empty(spento.Blocks);
- }
-
[Fact]
public void Il_predefinito_dellapplicazione_non_blocca_le_puntate()
{
@@ -253,7 +218,7 @@ public class BacktestTests
{
new() { Cycles = 10, Reached = 1, Bids = 1, FinalPrice = 1 },
new() { Cycles = 20, Reached = 5, Bids = 3, FinalPrice = 2,
- Blocks = new Dictionary(StringComparer.Ordinal) { ["anti-bot"] = 2 } },
+ Blocks = new Dictionary(StringComparer.Ordinal) { ["sfogo"] = 2 } },
new() { Cycles = 30, Reached = 1, Bids = 1, FinalPrice = 3 }
};
@@ -269,7 +234,7 @@ public class BacktestTests
Assert.Equal(2, a.AuctionsWithOneBid);
var text = BacktestReport.Format(new[] { a });
- Assert.Contains("anti-bot=2", text);
+ Assert.Contains("sfogo=2", text);
}
[Fact]
diff --git a/Mimante/Tests/DossierSummaryTests.cs b/Mimante/Tests/DossierSummaryTests.cs
new file mode 100644
index 0000000..2402275
--- /dev/null
+++ b/Mimante/Tests/DossierSummaryTests.cs
@@ -0,0 +1,102 @@
+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/RealDossierBacktest.cs b/Mimante/Tests/RealDossierBacktest.cs
index 0ecb0ec..b3a6fce 100644
--- a/Mimante/Tests/RealDossierBacktest.cs
+++ b/Mimante/Tests/RealDossierBacktest.cs
@@ -53,19 +53,6 @@ public class RealDossierBacktest
// vuole mettere alla prova, non una configurazione inventata per l'occasione.
var settings = new AppSettings();
- // Con AUTOBIDDER_BACKTEST_LEGACY=1 si rimettono i vecchi predefiniti, per vedere
- // quanto costavano davvero. Serve a rendere visibile la regressione da cui questa
- // prova difende: senza un confronto, "0 puntate bloccate" non dice se il controllo
- // funziona o se semplicemente non c'era nulla da bloccare.
- var legacy = Environment.GetEnvironmentVariable("AUTOBIDDER_BACKTEST_LEGACY") == "1";
- if (legacy)
- {
- settings.AntiBotDetectionEnabled = true;
- settings.SoftRetreatDurationSeconds = 30;
- settings.PriceVelocityBlockPerSecond = 0.10;
- _output.WriteLine("*** vecchi predefiniti (anti-bot acceso, ritiro 30 s, velocita' 0,10 EUR/s) ***");
- }
-
var aggregates = BacktestReport.Run(folder, Leads, settings, username: "", maxFiles: max);
var text = BacktestReport.Format(aggregates);
@@ -85,25 +72,21 @@ public class RealDossierBacktest
return;
}
- // Le due condizioni che la rigiocata deve garantire sui dati veri:
- // il motore non si inceppa, e coi predefiniti nessuna strategia rifiuta puntate.
+ // Le due condizioni che la rigiocata deve garantire sui dati veri: il motore non
+ // si inceppa, e ogni blocco ha un motivo riconosciuto. I motivi leciti sono i
+ // paletti (budget, pareggio, tetti) e le tre cose apprese o misurate sul momento
+ // (duello con l'autopuntata, valore atteso, regime). Quello che NON deve esserci
+ // e' un motivo che nessuno riconosce: sarebbe una regola entrata di nascosto.
foreach (var a in aggregates)
{
Assert.True(a.Reached >= 0 && a.Bids <= a.Reached,
$"conteggi incoerenti con anticipo {a.LeadMs} ms");
- if (legacy) continue; // coi vecchi predefiniti i blocchi sono il punto
+ var ignoti = a.Blocks.Where(kv => kv.Key == "altro").ToList();
- // I blocchi di budget e pareggio sono il comportamento voluto: fermano le
- // puntate che farebbero perdere soldi, e su un archivio vero devono esserci.
- // Quello che NON deve esserci e' un blocco da strategia "difensiva".
- var difensivi = a.Blocks
- .Where(kv => kv.Key is not ("pareggio" or "tetto-spesa" or "tetto-puntate"))
- .ToList();
-
- Assert.True(difensivi.Count == 0,
- $"con i predefiniti, anticipo {a.LeadMs} ms: puntate bloccate da strategie " +
- $"difensive ({string.Join(", ", difensivi.Select(kv => $"{kv.Key}={kv.Value}"))})");
+ Assert.True(ignoti.Count == 0,
+ $"anticipo {a.LeadMs} ms: puntate bloccate per un motivo non riconosciuto " +
+ $"({string.Join(", ", ignoti.Select(kv => $"{kv.Key}={kv.Value}"))})");
}
}
}
diff --git a/Mimante/Tests/RealDossierSummaries.cs b/Mimante/Tests/RealDossierSummaries.cs
new file mode 100644
index 0000000..5cf1e37
--- /dev/null
+++ b/Mimante/Tests/RealDossierSummaries.cs
@@ -0,0 +1,53 @@
+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/StatsWipeTests.cs b/Mimante/Tests/StatsWipeTests.cs
new file mode 100644
index 0000000..ead2916
--- /dev/null
+++ b/Mimante/Tests/StatsWipeTests.cs
@@ -0,0 +1,80 @@
+using System;
+using System.IO;
+using AutoBidder.Utilities;
+using Xunit;
+
+namespace AutoBidder.Tests;
+
+///
+/// L'azzeramento tocca solo le voci scelte, e riconosce gli archivi mensili delle
+/// versioni precedenti dal nome senza confonderli con il resto della cartella.
+///
+public class StatsWipeTests
+{
+ [Fact]
+ public void Toglie_gli_archivi_mensili_e_lascia_il_resto()
+ {
+ var stats = AppPaths.StatsFolder;
+ Directory.CreateDirectory(stats);
+
+ var legacy = Path.Combine(stats, "aste-2020-01.jsonl");
+ var legacyJson = Path.Combine(stats, "aste-2020-02.json");
+ var altro = Path.Combine(stats, $"altro-{Guid.NewGuid():N}.json");
+
+ File.WriteAllText(legacy, "{}\n");
+ File.WriteAllText(legacyJson, "[]");
+ File.WriteAllText(altro, "[]");
+
+ try
+ {
+ var prima = StatsWipe.Measure();
+ Assert.True(prima.LegacyFiles >= 2);
+
+ var report = StatsWipe.Run(new StatsWipe.Options
+ {
+ History = false, ProductStats = false, BidLeadMeasures = false,
+ Exports = false, LegacyArchives = true, Learning = false
+ });
+
+ Assert.False(File.Exists(legacy));
+ Assert.False(File.Exists(legacyJson));
+ Assert.True(File.Exists(altro));
+ Assert.Null(report.BackupPath);
+ Assert.Empty(report.Errors);
+ Assert.Contains(report.Steps, s => s.StartsWith("archivi mensili"));
+ }
+ finally
+ {
+ foreach (var f in new[] { legacy, legacyJson, altro }) if (File.Exists(f)) File.Delete(f);
+ }
+ }
+
+ [Fact]
+ public void Svuota_le_esportazioni_senza_toccare_altro()
+ {
+ Directory.CreateDirectory(AppPaths.ExportFolder);
+ var export = Path.Combine(AppPaths.ExportFolder, $"storico-{Guid.NewGuid():N}.csv");
+ File.WriteAllText(export, "a;b\n");
+
+ var (files, bytes) = StatsWipe.ClearExports();
+
+ Assert.False(File.Exists(export));
+ Assert.True(files >= 1);
+ Assert.True(bytes >= 4);
+ }
+
+ [Fact]
+ public void Senza_voci_scelte_non_fa_nulla()
+ {
+ var o = new StatsWipe.Options
+ {
+ History = false, ProductStats = false, BidLeadMeasures = false,
+ Exports = false, LegacyArchives = false, Learning = false
+ };
+ Assert.True(o.Nothing);
+
+ var report = StatsWipe.Run(o);
+ Assert.Empty(report.Steps);
+ Assert.Null(report.BackupPath);
+ }
+}
diff --git a/Mimante/Utilities/AuctionDetailStore.cs b/Mimante/Utilities/AuctionDetailStore.cs
index 9f5775b..3e79668 100644
--- a/Mimante/Utilities/AuctionDetailStore.cs
+++ b/Mimante/Utilities/AuctionDetailStore.cs
@@ -2,81 +2,74 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text;
using System.Text.Json;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
///
- /// Schede dettagliate delle aste concluse, una riga per asta.
+ /// Le schede dettagliate delle aste concluse, lette dai dossier.
///
- /// Formato JSON Lines: un oggetto per riga, file separato per mese. La
- /// scelta è deliberata — un unico documento JSON andrebbe riletto e riscritto per
- /// intero a ogni asta conclusa, mentre qui si accoda e basta; e un file che cresce per
- /// mesi diventerebbe scomodo da aprire e impossibile da leggere a pezzi. Ogni riga è
- /// indipendente: strumenti di analisi e fogli di calcolo li leggono in streaming, e un
- /// file troncato perde al più l'ultima riga invece di diventare illeggibile.
+ /// 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.
///
public static class AuctionDetailStore
{
private static readonly object Sync = new();
- private static readonly JsonSerializerOptions Options = new()
- {
- WriteIndented = false // una riga per record: il formato lo richiede
- };
+ /// 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;
- /// File del mese indicato (predefinito: quello corrente).
- public static string FileFor(DateTime when) =>
- Path.Combine(AppPaths.StatsFolder, $"aste-{when:yyyy-MM}.jsonl");
+ private sealed record CacheEntry(long Length, DateTime LastWrite, AuctionDetailRecord? Record);
- public static void Append(AuctionDetailRecord record)
- {
- if (record == null) return;
+ private static readonly Dictionary Cache = new(StringComparer.OrdinalIgnoreCase);
- try
- {
- AppPaths.EnsureFolders();
-
- var line = JsonSerializer.Serialize(record, Options);
-
- lock (Sync)
- {
- File.AppendAllText(FileFor(record.EndedAt), line + Environment.NewLine);
- }
- }
- catch { /* la raccolta dati non deve mai interrompere il monitoraggio */ }
- }
-
- /// Tutte le schede registrate, dalla più recente.
+ /// Tutte le schede delle aste con dossier concluso, dalla più recente.
public static List LoadAll()
{
var result = new List();
try
{
- if (!Directory.Exists(AppPaths.StatsFolder)) return result;
+ var folder = AppPaths.AuctionLogFolder;
+ if (!Directory.Exists(folder)) return result;
lock (Sync)
{
- foreach (var file in Directory.GetFiles(AppPaths.StatsFolder, "aste-*.jsonl"))
- {
- foreach (var line in File.ReadLines(file))
- {
- if (string.IsNullOrWhiteSpace(line)) continue;
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
- try
- {
- var record = JsonSerializer.Deserialize(line);
- if (record != null) result.Add(record);
- }
- catch
- {
- // Una riga corrotta non deve far perdere tutte le altre:
- // è il vantaggio principale di un record per riga.
- }
+ 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 { }
@@ -84,19 +77,181 @@ namespace AutoBidder.Utilities
return result.OrderByDescending(r => r.EndedAt).ToList();
}
- public static int Count()
+ 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)
{
try
{
- if (!Directory.Exists(AppPaths.StatsFolder)) return 0;
+ string? headerLine, lastLine;
- lock (Sync)
+ using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
- return Directory.GetFiles(AppPaths.StatsFolder, "aste-*.jsonl")
- .Sum(f => File.ReadLines(f).Count(l => !string.IsNullOrWhiteSpace(l)));
+ 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;
+ }
+ }
+
+ // ── Lettura di testa e coda ──────────────────────────────────────
+
+ private static string? ReadFirstLine(FileStream fs)
+ {
+ 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;
+ }
+
+ 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)
+ {
+ var r = new AuctionDetailRecord
+ {
+ AuctionId = Str(h, "auctionId"),
+ Name = Str(h, "name"),
+ ProductKey = Str(h, "productKey"),
+ Url = Str(h, "url"),
+ DossierPath = path,
+
+ 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")
+ };
+
+ if (h.TryGetProperty("product", out var product))
+ {
+ r.BuyNowPrice = NullableNum(product, "buyNowPrice");
+ r.BidCostEuro = Num(product, "bidCostEuro", 0.20);
+ }
+
+ if (s.TryGetProperty("coverage", out var cov))
+ {
+ 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)
+ {
+ foreach (var p in byUser.EnumerateObject())
+ if (p.Value.TryGetInt32(out var n)) r.BidsByUser[p.Name] = n;
}
}
- catch { return 0; }
+
+ 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") });
+ }
+
+ 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
index a2deb92..dbbf7a5 100644
--- a/Mimante/Utilities/AuctionDossier.cs
+++ b/Mimante/Utilities/AuctionDossier.cs
@@ -233,12 +233,12 @@ namespace AutoBidder.Utilities
maxBids = auction.MaxClicks,
minResets = auction.MinResets,
maxResets = auction.MaxResets,
- pollCriticalMs = settings.PollIntervalCriticalMs,
- criticalWindowMs = settings.CriticalWindowMs,
+ bidLeadIsManual = auction.BidLeadIsManual,
+ adaptiveLead = settings.AdaptiveLeadEnabled,
+ leadMinMs = settings.LeadMinMs,
+ leadMaxMs = settings.LeadMaxMs,
valueCheckEnabled = settings.ValueCheckEnabled,
minSavingsPercentage = settings.MinSavingsPercentage,
- antiBotEnabled = settings.AntiBotDetectionEnabled,
- competitionEnabled = settings.CompetitionDetectionEnabled,
rawPollsIncluded = settings.DossierIncludeRawPolls
}
});
@@ -324,8 +324,7 @@ namespace AutoBidder.Utilities
configuredLeadMs = detail.ConfiguredLeadMs,
timerExpiredCount = auction.TimerExpiredCount,
successfulBids = auction.SuccessfulBidCount,
- failedBids = auction.FailedBidCount,
- collisions = auction.CollisionCount
+ failedBids = auction.FailedBidCount
},
priceSeries = detail.PriceSeries.Select(p => new { t = Math.Round(p.T, 2), price = p.Price }),
diff --git a/Mimante/Utilities/ProductStatsStore.cs b/Mimante/Utilities/ProductStatsStore.cs
index 7318eca..95d9a97 100644
--- a/Mimante/Utilities/ProductStatsStore.cs
+++ b/Mimante/Utilities/ProductStatsStore.cs
@@ -279,6 +279,21 @@ namespace AutoBidder.Utilities
return summaries.Count;
}
+ ///
+ /// Azzera i numeri di tutte le schede tenendo le opzioni scelte nella scheda
+ /// Prodotti: si perde la statistica, non la configurazione.
+ ///
+ public static void ClearStatistics()
+ {
+ lock (Sync)
+ {
+ _cache = new StoreData();
+ Save(_cache);
+ }
+
+ SyncOptionsFromWatchList();
+ }
+
private static void SyncOptions(ProductCard card)
{
var product = WatchedProductsStore.GetAll()
diff --git a/Mimante/Utilities/SettingsManager.cs b/Mimante/Utilities/SettingsManager.cs
index 5548fee..1e9b7c2 100644
--- a/Mimante/Utilities/SettingsManager.cs
+++ b/Mimante/Utilities/SettingsManager.cs
@@ -56,8 +56,6 @@ namespace AutoBidder.Utilities
public double DefaultMinPrice { get; set; } = 0;
public double DefaultMaxPrice { get; set; } = 0;
public int DefaultMaxClicks { get; set; } = 0;
- public int DefaultMinResets { get; set; } = 0;
- public int DefaultMaxResets { get; set; } = 0;
///
/// Mostra avviso quando una puntata arriva troppo tardi (timer scaduto).
@@ -73,25 +71,6 @@ namespace AutoBidder.Utilities
// la scadenza si avvicina: un'asta a otto minuti non ha bisogno di quattro
// chiamate al secondo, una a tre secondi sì.
- /// Cadenza polling oltre i 60 s dalla scadenza. Default: 2000 ms.
- public int PollIntervalFarMs { get; set; } = 2000;
-
- /// Cadenza polling tra 10 e 60 s dalla scadenza. Default: 900 ms.
- public int PollIntervalMidMs { get; set; } = 900;
-
- /// Cadenza polling sotto i 10 s dalla scadenza. Default: 400 ms.
- public int PollIntervalNearMs { get; set; } = 400;
-
- ///
- /// Cadenza polling nella finestra critica, solo per le aste in stato Attiva.
- /// In sola osservazione non c'è puntata da azzeccare e si risparmiano chiamate.
- /// Default: 220 ms.
- ///
- public int PollIntervalCriticalMs { get; set; } = 220;
-
- /// Ampiezza della finestra critica prima della scadenza. Default: 4000 ms.
- public int CriticalWindowMs { get; set; } = 4000;
-
///
/// Tetto complessivo di richieste al secondo verso Bidoo (le puntate non sono soggette
/// al limite: hanno corsia preferenziale). Default: 40.
@@ -147,12 +126,6 @@ namespace AutoBidder.Utilities
///
public bool BidLeadTrackingEnabled { get; set; } = true;
- ///
- /// Propone (non applica) una correzione dell'anticipo quando i dati raccolti
- /// mostrano uno scarto sistematico. Default: true.
- ///
- public bool BidLeadSuggestionsEnabled { get; set; } = true;
-
/// Misure necessarie prima di azzardare una proposta. Default: 15.
public int BidLeadMinSamples { get; set; } = 15;
@@ -217,12 +190,6 @@ namespace AutoBidder.Utilities
///
public int LogRetentionDays { get; set; } = 90;
- ///
- /// Registra una scheda dettagliata per ogni asta conclusa (serie dei prezzi,
- /// puntate per utente, durata). È la materia prima per migliorare le strategie.
- ///
- public bool DetailedStatsEnabled { get; set; } = true;
-
// ═══════════════════════════════════════════════════════════════════
// CATALOGO — cache
// ═══════════════════════════════════════════════════════════════════
@@ -401,31 +368,6 @@ namespace AutoBidder.Utilities
// IMPOSTAZIONI DATABASE
// ???????????????????????????????????????????????????????????????
- ///
- /// Abilita il salvataggio automatico delle aste completate nel database.
- /// Default: true (consigliato per statistiche)
- ///
- public bool DatabaseAutoSaveEnabled { get; set; } = true;
-
- ///
- /// Esegue pulizia automatica duplicati all'avvio dell'applicazione.
- /// Default: true (consigliato per mantenere database pulito)
- ///
- public bool DatabaseAutoCleanupDuplicates { get; set; } = true;
-
- ///
- /// Esegue pulizia automatica record incompleti all'avvio.
- /// Default: false (può rimuovere dati utili in caso di errori temporanei)
- ///
- public bool DatabaseAutoCleanupIncomplete { get; set; } = false;
-
- ///
- /// Numero massimo di giorni da mantenere nei risultati aste.
- /// Record più vecchi vengono eliminati automaticamente.
- /// Default: 180 (6 mesi), 0 = disabilitato
- ///
- public int DatabaseMaxRetentionDays { get; set; } = 180;
-
// ???????????????????????????????????????????????????????????????
// STRATEGIE AVANZATE DI PUNTATA
// ???????????????????????????????????????????????????????????????
@@ -449,24 +391,6 @@ namespace AutoBidder.Utilities
///
public bool LogStrategyDecisions { get; set; } = true;
- ///
- /// Log calcoli valore prodotto [VALUE]
- /// Default: false (attiva per debug)
- ///
- public bool LogValueCalculations { get; set; } = false;
-
- ///
- /// Log rilevamento competizione e heat [COMPETITION]
- /// Default: false
- ///
- public bool LogCompetition { get; set; } = false;
-
- ///
- /// Log timing e polling (molto verbose!) [TIMING]
- /// Default: false (attiva solo per debug timing)
- ///
- public bool LogTiming { get; set; } = false;
-
///
/// Log errori e warning [ERROR/WARN]
/// Default: true
@@ -494,45 +418,9 @@ namespace AutoBidder.Utilities
///
public bool LogAuctionStatus { get; set; } = true;
- ///
- /// Log profiling avversari [OPPONENT]
- /// Default: false
- ///
- public bool LogOpponentProfiling { get; set; } = false;
-
// 🎯 STRATEGIE SEMPLIFICATE
- ///
- /// Entry Point: Usato SOLO per calcolare i limiti consigliati (70% del MaxPrice storico).
- /// NON blocca le puntate! I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
- /// Default: true (per calcolo limiti consigliati)
- ///
- public bool EntryPointEnabled { get; set; } = true;
-
- ///
- /// Anti-bot: rinuncia al ciclo quando l'ultimo puntatore punta a cadenza fissa.
- ///
- /// Predefinito cambiato a false. Le marche temporali di Bidoo sono in
- /// secondi interi, quindi la regola non puo' davvero distinguere un automatismo da
- /// una persona regolare: si riduce a "le ultime pause dell'avversario sono identiche
- /// al secondo", cosa comunissima. Rigiocando i dossier con backtest.ps1
- /// rifiutava fra il 4% e il 9% delle puntate, a seconda dell'anticipo, e la
- /// premessa era comunque rovesciata: un avversario a cadenza fissa punta con secondi
- /// di anticipo, quindi e' il piu' facile da battere aspettando l'ultimo istante.
- ///
- /// Per rivedere il confronto con i propri dati:
- /// $env:AUTOBIDDER_BACKTEST_LEGACY=1 prima di backtest.ps1.
- ///
- public bool AntiBotDetectionEnabled { get; set; } = false;
-
- ///
- /// User Exhaustion: Sfrutta utenti stanchi (oltre 50 puntate)
- /// quando ci sono pochi altri bidder attivi.
- /// Default: true
- ///
- public bool UserExhaustionEnabled { get; set; } = true;
-
// 🎯 CONTROLLO CONVENIENZA PRODOTTO
///
@@ -552,14 +440,6 @@ namespace AutoBidder.Utilities
/// Default: -5 (permetti fino al 5% di perdita)
///
public double MinSavingsPercentage { get; set; } = -5.0;
-
- ///
- /// Abilita il controllo anti-collisione hardcoded.
- /// Se attivo, blocca le puntate quando ci sono 3+ bidder attivi negli ultimi 10 secondi.
- /// ATTENZIONE: Questo controllo può far perdere aste competitive!
- /// Default: false (DISABILITATO - non blocca mai)
- ///
- public bool HardcodedAntiCollisionEnabled { get; set; } = false;
// ── Fascia oraria sospesa ────────────────────────────────────────
// Vedi BiddingHours per i numeri: alle 0 e alle 9 la stessa asta costa quasi il
@@ -594,6 +474,29 @@ namespace AutoBidder.Utilities
///
public int LearningBootstrapSecondsPerStart { get; set; } = 120;
+ // ── Anticipo adattivo ────────────────────────────────────────────
+ // Vedi Ml/LatencyModel: l'anticipo lo decide la rete di questa sessione, dentro
+ // i paletti qui sotto. Un anticipo scritto a mano su una singola asta vince sempre.
+
+ ///
+ /// Lasciar decidere l'anticipo al modello di latenza (margine + coda del ping).
+ /// Spento, vale per tutte le aste senza
+ /// un anticipo proprio.
+ ///
+ public bool AdaptiveLeadEnabled { get; set; } = true;
+
+ ///
+ /// Anticipo minimo che il modello può scegliere. Sotto i 300 ms un picco di rete
+ /// fa arrivare la puntata a giochi chiusi: il ping tocca 444 ms nel p99,9.
+ ///
+ public int LeadMinMs { get; set; } = 300;
+
+ ///
+ /// Anticipo massimo che il modello può scegliere. Oltre 1500 ms si regala
+ /// più di un secondo agli avversari a ogni ciclo, e il costo per vincere sale.
+ ///
+ public int LeadMaxMs { get; set; } = 1500;
+
// ── Rimozione automatica delle aste concluse ─────────────────────
// Vedi FinishedAuctionCleanup: si toglie di serie, si trattiene cio' su cui c'e'
// stato un esborso.
@@ -650,142 +553,18 @@ namespace AutoBidder.Utilities
// RILEVAMENTO COMPETIZIONE E HEAT METRIC
// 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
- ///
- /// Abilita rilevamento competizione e heat metric.
- /// Conta bidder attivi e collisioni per determinare il "calore" dell'asta.
- /// Default: true
- ///
- public bool CompetitionDetectionEnabled { get; set; } = true;
-
- ///
- /// Finestra temporale in secondi per contare bidder attivi.
- /// Default: 30 (ultimi 30 secondi)
- ///
- public int CompetitionWindowSeconds { get; set; } = 30;
-
- ///
- /// Numero minimo di bidder attivi per considerare l'asta "affollata".
- /// Se >= a questa soglia, applica logica di evitamento.
- /// Default: 3
- ///
- public int CompetitionThreshold { get; set; } = 3;
-
- ///
- /// Abilita auto-pausa per aste troppo competitive.
- /// Default: false (solo warning, non pausa automatica)
- ///
- public bool AutoPauseHotAuctions { get; set; } = false;
-
- ///
- /// Soglia heat metric per auto-pausa (0-100).
- /// Default: 80 (pausa se heat >= 80%)
- ///
- public int HeatThresholdForPause { get; set; } = 80;
-
// ???????????????????????????????????????????????????????????????
// SOFT RETREAT E COLLISION MANAGEMENT
// ???????????????????????????????????????????????????????????????
- ///
- /// Abilita soft retreat automatico dopo N collisioni consecutive.
- /// Default: true
- ///
- public bool SoftRetreatEnabled { get; set; } = true;
-
- ///
- /// Numero di collisioni consecutive per attivare soft retreat.
- /// Default: 3
- ///
- public int SoftRetreatAfterCollisions { get; set; } = 3;
-
- ///
- /// Durata del ritiro, in secondi.
- ///
- /// Ridotta da 30 a 12: il timer delle aste osservate si azzera ogni 8-12
- /// secondi, quindi trenta secondi di pausa sono tre cicli interi — nella pratica
- /// l'asta è persa. Dodici secondi bastano a spezzare una serie di collisioni
- /// senza consegnare la partita.
- ///
- public int SoftRetreatDurationSeconds { get; set; } = 12;
-
- ///
- /// Blocca la puntata se il prezzo sale più in fretta di tanti euro al secondo.
- /// 0 = controllo spento (predefinito).
- ///
- /// Prima era una costante nel codice fissata a 0,10 €/s, cioè dieci puntate
- /// al secondo: su oltre 40.000 valutazioni riprese dai dossier non è mai scattata,
- /// e la velocità massima mai osservata è 0,016 €/s. Se lo si vuole usare davvero,
- /// un valore sensato sta intorno a 0,01-0,02 €/s.
- ///
- public double PriceVelocityBlockPerSecond { get; set; } = 0;
-
// ???????????????????????????????????????????????????????????????
// PROBABILISTIC BIDDING
// ???????????????????????????????????????????????????????????????
- ///
- /// Abilita policy di puntata probabilistica.
- /// Decide se puntare con probabilità p basata su competizione e ROI.
- /// Default: false (richiede tuning)
- ///
- public bool ProbabilisticBiddingEnabled { get; set; } = false;
-
- ///
- /// Probabilità base di puntata (0.0 - 1.0).
- /// Default: 0.8 (80%)
- ///
- public double BaseBidProbability { get; set; } = 0.8;
-
- ///
- /// Fattore di riduzione probabilità per ogni bidder attivo extra.
- /// Default: 0.1 (riduce del 10% per ogni bidder oltre la soglia)
- ///
- public double ProbabilityReductionPerBidder { get; set; } = 0.1;
-
// ???????????????????????????????????????????????????????????????
// OPPONENT PROFILING
// ???????????????????????????????????????????????????????????????
- ///
- /// Abilita profiling degli avversari.
- /// Identifica utenti aggressivi e applica regole specifiche.
- /// Default: true
- ///
- public bool OpponentProfilingEnabled { get; set; } = true;
-
- ///
- /// Soglia puntate per considerare un utente "aggressivo".
- /// Default: 10 (se un utente ha fatto >= 10 puntate in un'asta)
- ///
- public int AggressiveBidderThreshold { get; set; } = 10;
-
- ///
- /// Dimensione finestra scorrevole per analisi bidder aggressivi.
- /// Analizza le ultime N puntate invece del conteggio totale.
- /// Default: 30 (ultime 30 puntate)
- ///
- public int AggressiveBidderWindowSize { get; set; } = 30;
-
- ///
- /// Soglia percentuale per considerare un utente "aggressivo".
- /// Se un utente ha più di X% delle puntate nella finestra, è aggressivo.
- /// Default: 40 (40% delle puntate)
- ///
- public double AggressiveBidderPercentageThreshold { get; set; } = 40.0;
-
- ///
- /// Dimensione finestra per rilevamento situazioni di duello.
- /// Default: 20 (ultime 20 puntate)
- ///
- public int DuelDetectionWindowSize { get; set; } = 20;
-
- ///
- /// Azione da intraprendere con bidder aggressivi.
- /// "Avoid" = evita l'asta, "Compete" = continua normalmente, "Outbid" = punta più aggressivamente
- /// Default: "Compete" (cambiato da Avoid per essere meno restrittivo)
- ///
- public string AggressiveBidderAction { get; set; } = "Compete";
-
// ???????????????????????????????????????????????????????????????
// BANKROLL & SAFETY MANAGER
// ???????????????????????????????????????????????????????????????
@@ -840,19 +619,6 @@ namespace AutoBidder.Utilities
// ???????????????????????????????????????????????????????????????
// LOGGING AVANZATO
// ???????????????????????????????????????????????????????????????
-
- ///
- /// Abilita logging avanzato con metriche dettagliate.
- /// Include: collisioni, timer scaduto, latenza, heat metric.
- /// Default: true
- ///
- public bool AdvancedLoggingEnabled { get; set; } = true;
-
- ///
- /// Salva metriche per ogni puntata nel database.
- /// Default: true
- ///
- public bool SaveBidMetricsToDatabase { get; set; } = true;
// ═══════════════════════════════════════════════════════════════════
// INTERFACCIA
diff --git a/Mimante/Utilities/StatsWipe.cs b/Mimante/Utilities/StatsWipe.cs
new file mode 100644
index 0000000..e7808b0
--- /dev/null
+++ b/Mimante/Utilities/StatsWipe.cs
@@ -0,0 +1,225 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+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.
+ ///
+ /// pesa ogni voce prima: cancellare è irreversibile, e
+ /// dire quanto sparisce è il minimo per una scelta informata.
+ ///
+ public static class StatsWipe
+ {
+ public sealed class Options
+ {
+ public bool History { get; set; } = true;
+ public bool ProductStats { get; set; } = true;
+ public bool BidLeadMeasures { get; set; } = true;
+ public bool Exports { get; set; } = true;
+ public bool LegacyArchives { get; set; } = true;
+ public bool Learning { get; set; }
+
+ public bool Nothing => !(History || ProductStats || BidLeadMeasures || Exports || LegacyArchives || Learning);
+ }
+
+ public readonly record struct Sizes(
+ int HistoryAuctions, long HistoryBytes,
+ long ProductsBytes,
+ int BidLeadSamples, long BidLeadBytes,
+ int ExportFiles, long ExportBytes,
+ int LegacyFiles, long LegacyBytes,
+ long LearningBytes);
+
+ public sealed class Report
+ {
+ public string? BackupPath { get; init; }
+ public int FilesDeleted { get; set; }
+ public long BytesFreed { get; set; }
+ public List Steps { get; } = new();
+ public List Errors { get; } = new();
+
+ public string Summary =>
+ string.Join("; ", Steps) +
+ (Errors.Count > 0 ? $". Problemi: {string.Join("; ", Errors)}" : "");
+ }
+
+ private static string LearningFolder => Path.Combine(AppPaths.StatsFolder, "Apprendimento");
+
+ // ── Misura ───────────────────────────────────────────────────────
+
+ public static Sizes Measure()
+ {
+ int aste = 0, misure = 0;
+ try { aste = CompletedAuctionsStore.LoadAll().Count; } catch { }
+ try { misure = BidLeadStats.All().Count; } catch { }
+
+ var export = ListFiles(AppPaths.ExportFolder, "*");
+ var legacy = LegacyFiles();
+ var learning = ListFiles(LearningFolder, "*");
+
+ return new Sizes(
+ aste, SizeOf(CompletedAuctionsStore.StorePath),
+ SizeOf(AppPaths.ProductsFile),
+ misure, SizeOf(AppPaths.BidLeadStatsFile),
+ export.Count, export.Sum(SizeOf),
+ legacy.Count, legacy.Sum(SizeOf),
+ learning.Sum(SizeOf));
+ }
+
+ // ── Azzeramento ──────────────────────────────────────────────────
+
+ public static Report Run(Options o)
+ {
+ string? backup = null;
+
+ if (o.History)
+ {
+ // La copia prima di tutto: se fallisce, non si cancella niente.
+ backup = CompletedAuctionsStore.BackupNow();
+ }
+
+ var report = new Report { BackupPath = backup };
+
+ if (o.History)
+ {
+ Try(report, "storico", () =>
+ {
+ report.BytesFreed += SizeOf(CompletedAuctionsStore.StorePath);
+ CompletedAuctionsStore.Clear();
+ report.Steps.Add($"storico svuotato (copia in {backup})");
+ });
+ }
+
+ if (o.ProductStats)
+ {
+ Try(report, "statistiche prodotto", () =>
+ {
+ ProductStatsStore.ClearStatistics();
+ report.Steps.Add("statistiche per prodotto azzerate (opzioni conservate)");
+ });
+ }
+
+ if (o.BidLeadMeasures)
+ {
+ Try(report, "misure anticipo", () =>
+ {
+ BidLeadStats.Clear();
+ report.Steps.Add("misure dell'anticipo azzerate");
+ });
+ }
+
+ if (o.Exports)
+ {
+ var (n, bytes) = ClearExports();
+ report.FilesDeleted += n;
+ report.BytesFreed += bytes;
+ report.Steps.Add($"esportazioni: {n} file tolti");
+ }
+
+ if (o.LegacyArchives)
+ {
+ var (n, bytes) = DeleteAll(report, "archivi mensili", LegacyFiles());
+ report.FilesDeleted += n;
+ report.BytesFreed += bytes;
+ report.Steps.Add($"archivi mensili: {n} file tolti");
+ }
+
+ if (o.Learning)
+ {
+ Try(report, "apprendimento", () =>
+ {
+ Ml.LearningService.Forget();
+ Ml.LatencyModel.Forget();
+ });
+ 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");
+ }
+
+ return report;
+ }
+
+ /// Svuota la sola cartella delle esportazioni. Restituisce file tolti e byte liberati.
+ public static (int Files, long Bytes) ClearExports()
+ {
+ var report = new Report();
+ return DeleteAll(report, "esportazioni", ListFiles(AppPaths.ExportFolder, "*"));
+ }
+
+ // ── Attrezzi ─────────────────────────────────────────────────────
+
+ /// Gli archivi mensili delle versioni precedenti: aste-AAAA-MM.jsonl e .json.
+ private static List LegacyFiles()
+ {
+ var folder = AppPaths.StatsFolder;
+ if (!Directory.Exists(folder)) return new List();
+
+ return Directory.EnumerateFiles(folder, "aste-*.json*")
+ .Where(f =>
+ {
+ var name = Path.GetFileName(f);
+ return name.Length >= 12 && char.IsDigit(name[5]) &&
+ (name.EndsWith(".jsonl", StringComparison.OrdinalIgnoreCase) ||
+ name.EndsWith(".json", StringComparison.OrdinalIgnoreCase));
+ })
+ .ToList();
+ }
+
+ private static List ListFiles(string folder, string pattern)
+ {
+ try
+ {
+ return Directory.Exists(folder)
+ ? Directory.EnumerateFiles(folder, pattern).ToList()
+ : new List();
+ }
+ catch { return new List(); }
+ }
+
+ private static long SizeOf(string path)
+ {
+ try { return File.Exists(path) ? new FileInfo(path).Length : 0; }
+ catch { return 0; }
+ }
+
+ private static (int Files, long Bytes) DeleteAll(Report report, string label, IEnumerable files)
+ {
+ var n = 0;
+ long bytes = 0;
+
+ foreach (var f in files)
+ {
+ try
+ {
+ var size = SizeOf(f);
+ File.Delete(f);
+ n++;
+ bytes += size;
+ }
+ catch (Exception ex)
+ {
+ report.Errors.Add($"{label}: {Path.GetFileName(f)} non cancellato ({ex.Message})");
+ }
+ }
+
+ return (n, bytes);
+ }
+
+ private static void Try(Report report, string label, Action action)
+ {
+ try { action(); }
+ catch (Exception ex) { report.Errors.Add($"{label}: {ex.Message}"); }
+ }
+ }
+}
diff --git a/Mimante/ViewModels/AuctionViewModel.cs b/Mimante/ViewModels/AuctionViewModel.cs
index f259f68..59fc7fc 100644
--- a/Mimante/ViewModels/AuctionViewModel.cs
+++ b/Mimante/ViewModels/AuctionViewModel.cs
@@ -87,6 +87,7 @@ namespace AutoBidder.ViewModels
set
{
_auctionInfo.BidBeforeDeadlineMs = value;
+ _auctionInfo.BidLeadIsManual = true;
OnPropertyChanged(nameof(BidBeforeDeadlineMs));
}
}