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