diff --git a/Mimante/.vscode/tasks.json b/Mimante/.vscode/tasks.json index c56a704..3262479 100644 --- a/Mimante/.vscode/tasks.json +++ b/Mimante/.vscode/tasks.json @@ -78,6 +78,24 @@ }, "problemMatcher": [] }, + { + "label": "valuta apprendimento", + "detail": "Addestra il modello sui dossier più vecchi e lo giudica sui più recenti: separazione, calibrazione, e cosa avrebbe fatto il cancello sulle tue puntate vere.", + "type": "process", + "command": "dotnet", + "args": [ + "msbuild", + "${workspaceFolder}/build/Release.proj", + "-t:Apprendimento", + "-nologo", + "-v:m" + ], + "presentation": { + "reveal": "always", + "panel": "dedicated" + }, + "problemMatcher": [] + }, { "label": "crea installatore", "detail": "Verifica, pubblica ed esegue Inno Setup: bin/installer/AutoBidder--setup.exe. Crea il tag a pacchetto pronto. Non tocca Gitea.", diff --git a/Mimante/App.xaml.cs b/Mimante/App.xaml.cs index 8423086..36a83a7 100644 --- a/Mimante/App.xaml.cs +++ b/Mimante/App.xaml.cs @@ -26,11 +26,6 @@ namespace AutoBidder AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder); AppPaths.EnsureFolders(); - // La modalità della sessione si fissa qui e non cambia più fino alla chiusura: - // chi salva le impostazioni a metà sessione vede l'avviso di riavvio, non un - // motore che cambia comportamento sotto i piedi. - RuntimeProfile.Initialize(settings.SessionMode); - // Le versioni precedenti scrivevano parte dei dati in Roaming e parte in Local, // senza sottocartelle: senza questo passaggio storico e aste sparirebbero. AppPaths.MigrateLegacyFiles(); @@ -39,7 +34,6 @@ namespace AutoBidder // proprio ciò che non si riesce a leggere a video. TextLogService.SessionStarted(AppInfo.Version); TextLogService.PurgeOldLogs(); - TextLogService.App("INFO", $"[MODALITÀ] {RuntimeProfile.Describe()}"); TextLogService.App("INFO", $"[PERCORSI] configurazione in {AppPaths.ConfigRoot}, dati in {AppPaths.DataFolder}"); // Le righe di registro delle aste confluiscono nei rispettivi dossier. @@ -64,6 +58,11 @@ namespace AutoBidder TextLogService.App("WARN", $"[STORICO] Riparazione non riuscita: {ex.Message}"); } + // L'apprendimento è sempre acceso: carica ciò che sa, ricostruisce il profilo + // dallo storico se manca, e in sottofondo legge i dossier non ancora appresi. + Ml.LearningService.OnLog += m => TextLogService.App("INFO", m); + Ml.LearningService.Start(settings); + // Applica il tema salvato (chiaro/scuro) prima di mostrare la finestra ThemeManager.ApplyFromSettings(); } diff --git a/Mimante/Controls/SettingsControl.xaml b/Mimante/Controls/SettingsControl.xaml index 26c0be0..f9f1823 100644 --- a/Mimante/Controls/SettingsControl.xaml +++ b/Mimante/Controls/SettingsControl.xaml @@ -44,32 +44,6 @@ - - - - - - - - - - - - - - - diff --git a/Mimante/Controls/SettingsControl.xaml.cs b/Mimante/Controls/SettingsControl.xaml.cs index 49b0d00..5c61678 100644 --- a/Mimante/Controls/SettingsControl.xaml.cs +++ b/Mimante/Controls/SettingsControl.xaml.cs @@ -48,30 +48,6 @@ namespace AutoBidder.Controls Utilities.ThemeManager.SetAndSave(false); } - // ── Modalità della sessione ────────────────────────────────────── - - private void SessionModeRadio_Checked(object sender, RoutedEventArgs e) => - RefreshModeNotice(Utilities.RuntimeProfile.Mode); - - /// - /// Mostra che modalità è in vigore adesso, e l'avviso di riavvio solo se la scelta - /// nella scheda differisce da quella. Un avviso sempre acceso non lo legge nessuno. - /// - public void RefreshModeNotice(string modalitaInVigore) - { - if (ModeRestartNotice == null || ModeCurrentText == null) return; - - var scelta = ModeRaceRadio?.IsChecked == true - ? Utilities.RuntimeProfile.Gara - : Utilities.RuntimeProfile.Addestramento; - - ModeCurrentText.Text = $"In vigore adesso: {modalitaInVigore}"; - ModeRestartNotice.Visibility = - string.Equals(scelta, modalitaInVigore, System.StringComparison.OrdinalIgnoreCase) - ? Visibility.Collapsed - : Visibility.Visible; - } - // Non servono propriet� wrapper - MainWindow.xaml.cs accede direttamente ai controlli tramite: // Settings.DefaultBidBeforeDeadlineMsTextBox (definito nel XAML con x:Name) // Settings.MaxLogLinesPerAuctionTextBox (definito nel XAML con x:Name) diff --git a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs index 18b1fae..a560b48 100644 --- a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs +++ b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Settings.cs @@ -107,14 +107,6 @@ namespace AutoBidder Settings.DetailedStatsCheckBox.IsChecked = settings.DetailedStatsEnabled; Settings.CatalogCacheSecondsTextBox.Text = settings.CatalogCacheSeconds.ToString(); - // Modalità: si mostra quella salvata; l'avviso compare se differisce da - // quella in vigore, che è stata fissata all'avvio e non cambia fino al riavvio. - if (string.Equals(settings.SessionMode, RuntimeProfile.Gara, StringComparison.OrdinalIgnoreCase)) - Settings.ModeRaceRadio.IsChecked = true; - else - Settings.ModeTrainingRadio.IsChecked = true; - Settings.RefreshModeNotice(RuntimeProfile.Mode); - Settings.QuietHoursCheckBox.IsChecked = settings.QuietHoursEnabled; Settings.QuietHoursStartTextBox.Text = settings.QuietHoursStart.ToString(); Settings.QuietHoursEndTextBox.Text = settings.QuietHoursEnd.ToString(); @@ -430,11 +422,6 @@ namespace AutoBidder settings.DetailedStatsEnabled = Settings.DetailedStatsCheckBox.IsChecked ?? true; - settings.SessionMode = Settings.ModeRaceRadio.IsChecked == true - ? RuntimeProfile.Gara - : RuntimeProfile.Addestramento; - Settings.RefreshModeNotice(RuntimeProfile.Mode); - settings.QuietHoursEnabled = Settings.QuietHoursCheckBox.IsChecked ?? true; if (int.TryParse(Settings.QuietHoursStartTextBox.Text?.Trim(), out var qStart) && qStart is >= 0 and <= 23) settings.QuietHoursStart = qStart; diff --git a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs index e75ac14..34c5bc6 100644 --- a/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs +++ b/Mimante/Core/EventHandlers/MainWindow.EventHandlers.Stats.cs @@ -70,18 +70,24 @@ namespace AutoBidder var detail = BuildDetail(auction, state, record); - // In Gara il dettaglio esteso non si scrive: vedi RuntimeProfile. - if (settings.DetailedStatsEnabled && RuntimeProfile.DetailedStats) + 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. + // storia completa, ed è così che l'analisi sa di poterlo usare. Il percorso + // si prende prima, perché Close lo toglie dal registro dei dossier aperti. + var dossierPath = AuctionDossier.For(auction.AuctionId)?.Path; AuctionDossier.Close(auction, detail, state); ProductStatsStore.RecordCompleted(record, detail); + // L'apprendimento impara subito da questa asta: il dossier è già su disco + // (Close lo svuota) e lo storico è già scritto. Fuori dal percorso di + // chiusura, che deve restare corto. + _ = System.Threading.Tasks.Task.Run(() => Ml.LearningService.OnAuctionClosed(record, dossierPath)); + NotifyOutcome(record, settings); Dispatcher.BeginInvoke(() => diff --git a/Mimante/Ml/AuctionProfileStats.cs b/Mimante/Ml/AuctionProfileStats.cs new file mode 100644 index 0000000..213e14e --- /dev/null +++ b/Mimante/Ml/AuctionProfileStats.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace AutoBidder.Ml +{ + /// + /// Cosa aspettarsi da un'asta prima che cominci: puntate del vincitore e prezzo di + /// chiusura in rapporto al valore, per prodotto, fascia oraria e tipo di giorno. + /// + /// Il problema dei cassetti vuoti. Prodotto × fascia oraria × feriale/festivo + /// fa centinaia di combinazioni, e molte hanno tre aste o nessuna. Una media su tre + /// aste non è una stima, è un aneddoto. Per questo ogni stima è un restringimento: + /// si parte dalla media globale, la si corregge verso quella del prodotto quanto più il + /// prodotto ha esempi, e poi verso quella della combinazione esatta quanto più ne ha + /// lei. Con dieci esempi il cassetto pesa metà; con cento quasi tutto; con uno quasi + /// niente. È lo stimatore di Bayes empirico, e non serve nessuna libreria. + /// + /// Si aggiorna a ogni asta chiusa con la formula di Welford, senza tenere gli + /// esempi: media e varianza corrono, i dati no. + /// + public sealed class AuctionProfileStats + { + /// Esempi oltre i quali un cassetto pesa più del livello sopra. + public const double Shrink = 10.0; + + private sealed class Cell + { + public long N { get; set; } + public double MeanBids { get; set; } + public double M2Bids { get; set; } + public long NBids { get; set; } + public double MeanRatio { get; set; } + public double M2Ratio { get; set; } + public long NRatio { get; set; } + } + + private readonly Dictionary _cells = new(StringComparer.Ordinal); + private readonly object _sync = new(); + + public long AuctionsObserved { get; private set; } + + /// Stima per una combinazione, con quanti esempi la sorreggono davvero. + public readonly record struct Estimate(double WinnerBids, double CloseRatio, long ExactSupport, long ProductSupport); + + /// + /// Registra un'asta chiusa. Le puntate del vincitore possono mancare (dato non + /// attendibile o non registrato): in quel caso alimentano solo il prezzo. + /// + public void Observe(string? productKey, DateTime closedAtLocal, int? winnerBids, double? closeRatio) + { + var keys = KeysFor(productKey, closedAtLocal); + + lock (_sync) + { + AuctionsObserved++; + + foreach (var k in keys) + { + if (!_cells.TryGetValue(k, out var c)) _cells[k] = c = new Cell(); + c.N++; + + if (winnerBids is { } b && b > 0) + { + c.NBids++; + var d = b - c.MeanBids; + c.MeanBids += d / c.NBids; + c.M2Bids += d * (b - c.MeanBids); + } + + if (closeRatio is { } r && r > 0) + { + c.NRatio++; + var d = r - c.MeanRatio; + c.MeanRatio += d / c.NRatio; + c.M2Ratio += d * (r - c.MeanRatio); + } + } + } + } + + /// Cosa aspettarsi da un'asta di questo prodotto che chiude a quest'ora. + public Estimate? Expect(string? productKey, DateTime whenLocal) + { + var keys = KeysFor(productKey, whenLocal); // [globale, prodotto, esatta] + + lock (_sync) + { + if (!_cells.TryGetValue(keys[0], out var global) || global.N == 0) return null; + + _cells.TryGetValue(keys[1], out var product); + _cells.TryGetValue(keys[2], out var exact); + + var bids = Shrunk(global.MeanBids, global.NBids, product?.MeanBids, product?.NBids ?? 0, exact?.MeanBids, exact?.NBids ?? 0); + var ratio = Shrunk(global.MeanRatio, global.NRatio, product?.MeanRatio, product?.NRatio ?? 0, exact?.MeanRatio, exact?.NRatio ?? 0); + + return new Estimate(bids, ratio, exact?.N ?? 0, product?.N ?? 0); + } + } + + /// + /// Globale → prodotto → esatta, ogni livello pesato dai propri esempi. + /// + private static double Shrunk(double g, long ng, double? p, long np, double? e, long ne) + { + var stima = g; + if (p is { } pv && np > 0) stima = (np * pv + Shrink * stima) / (np + Shrink); + if (e is { } ev && ne > 0) stima = (ne * ev + Shrink * stima) / (ne + Shrink); + return stima; + } + + private static string[] KeysFor(string? productKey, DateTime when) + { + var prod = (productKey ?? "").Trim().ToLowerInvariant(); + var band = BidFeatures.HourBand(when.Hour); + var day = BidFeatures.IsWeekend(when.DayOfWeek) ? "fest" : "fer"; + return new[] { "g", "p:" + prod, "p:" + prod + "|h" + band + "|" + day }; + } + + // ── Persistenza ────────────────────────────────────────────────── + + private sealed class Snapshot + { + public int Version { get; set; } = 1; + public long AuctionsObserved { get; set; } + public Dictionary Cells { get; set; } = new(); + } + + public string ToJson() + { + lock (_sync) + { + return JsonSerializer.Serialize(new Snapshot + { + AuctionsObserved = AuctionsObserved, + Cells = new Dictionary(_cells) + }); + } + } + + public static AuctionProfileStats FromJson(string json) + { + var s = new AuctionProfileStats(); + try + { + var snap = JsonSerializer.Deserialize(json); + if (snap?.Cells != null) + { + foreach (var kv in snap.Cells) s._cells[kv.Key] = kv.Value; + s.AuctionsObserved = snap.AuctionsObserved; + } + } + catch (JsonException) { /* si riparte da vuoto */ } + return s; + } + } +} diff --git a/Mimante/Ml/BidFeatures.cs b/Mimante/Ml/BidFeatures.cs new file mode 100644 index 0000000..06a76db --- /dev/null +++ b/Mimante/Ml/BidFeatures.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; + +namespace AutoBidder.Ml +{ + /// + /// Lo stato di un'asta nell'istante di una puntata, ridotto a ciò che serve per + /// prevedere se quella puntata resterà senza risposta. + /// + /// Quanto è durato il ciclo che questa puntata chiude: secondi dalla puntata precedente. Negativo = ignoto. + /// Quota di autopuntate fra le ultime dieci, da 0 a 1. Negativo = ignoto. + /// Puntatori distinti fra le ultime venti puntate. + /// Prezzo corrente in rapporto al valore del prodotto. Null = valore ignoto. + /// Ora locale in cui avviene la puntata. + /// Giorno della settimana. + /// Ora locale in cui l'asta è stata agganciata (apertura, o quando si è cominciato a seguirla). + /// La puntata è dell'autopuntata del sito. + /// Puntate automatiche di fila prima di questa. Negativo = nessuna manuale in vista. + /// Prezzo in centesimi: è il numero di puntate fatte da tutti nell'asta. + /// Chi punta è lo stesso di due puntate fa: la firma del duello. + /// Chiave normalizzata del prodotto. + public readonly record struct BidContext( + double CycleSeconds, + double AutoShareRecent, + int DistinctBiddersRecent, + double? PriceRatio, + int Hour, + DayOfWeek Day, + int OpenHour, + bool IsAutoBid, + int CyclesSinceManual, + int AuctionDepthCents, + bool SameBidderAsTwoAgo, + string ProductKey); + + /// + /// Trasforma un in un vettore di numeri per il modello. + /// + /// Perché quasi tutto a fasce. Le variabili non sono lineari: un ciclo di + /// 9 secondi non è «tre volte» uno di 3, è un mondo diverso — chi ha aspettato 9 + /// secondi ha visto passare la soglia dell'autopuntata senza che nessuno sparasse. A + /// fasce, ogni fascia impara il suo peso e il modello resta lineare, cioè leggibile: + /// si può aprire il file dei pesi e vedere cosa ha imparato. + /// + /// Le fasce orarie ricalcano la curva misurata sulle chiusure: notte e + /// ore di confine (0-1, 9) care, 10-13 le migliori, poi tre fasce fino a mezzanotte. + /// Il giorno è solo feriale/festivo: sette valori distinti sarebbero troppo pochi + /// esempi per ciascuno. + /// + /// Il prodotto è ridotto a 32 cassetti con una funzione di hash stabile. + /// Due prodotti possono finire nello stesso cassetto, ed è accettato: il modello impara + /// una tendenza di gruppo; il profilo esatto per prodotto sta altrove. + /// + public static class BidFeatures + { + private const int GapBuckets = 6; // <2, <4, <6, <8, <10, 10+ + private const int BiddersBuckets = 5; // 1, 2, 3, 4-5, 6+ + private const int RatioBuckets = 6; // <2%, <5%, <10%, <20%, 20%+, ignoto + private const int HourBands = 6; + private const int DayTypes = 2; + private const int BidTypes = 2; + private const int SinceManualBuckets = 4; + private const int DepthBuckets = 4; // <50, <150, <400, 400+ + private const int ProductBuckets = 32; + + public const int Size = + GapBuckets + 2 /* quota auto + ignota */ + BiddersBuckets + RatioBuckets + + HourBands + DayTypes + BidTypes + SinceManualBuckets + DepthBuckets + + 1 /* stesso puntatore */ + HourBands /* ora di aggancio */ + ProductBuckets; + + /// Nomi delle variabili, nello stesso ordine del vettore: per leggere i pesi. + public static readonly string[] Names = BuildNames(); + + public static double[] Build(in BidContext c) + { + var x = new double[Size]; + var o = 0; + + // durata del ciclo + if (c.CycleSeconds >= 0) x[o + GapBucket(c.CycleSeconds)] = 1; + o += GapBuckets; + + // quota di autopuntate recenti (numerica) + bandiera "ignota" + if (c.AutoShareRecent >= 0) x[o] = Math.Clamp(c.AutoShareRecent, 0, 1); + else x[o + 1] = 1; + o += 2; + + x[o + BiddersBucket(c.DistinctBiddersRecent)] = 1; + o += BiddersBuckets; + + x[o + RatioBucket(c.PriceRatio)] = 1; + o += RatioBuckets; + + x[o + HourBand(c.Hour)] = 1; + o += HourBands; + + x[o + (IsWeekend(c.Day) ? 1 : 0)] = 1; + o += DayTypes; + + x[o + (c.IsAutoBid ? 1 : 0)] = 1; + o += BidTypes; + + x[o + SinceManualBucket(c.CyclesSinceManual)] = 1; + o += SinceManualBuckets; + + x[o + DepthBucket(c.AuctionDepthCents)] = 1; + o += DepthBuckets; + + if (c.SameBidderAsTwoAgo) x[o] = 1; + o += 1; + + x[o + HourBand(c.OpenHour)] = 1; + o += HourBands; + + x[o + ProductBucket(c.ProductKey)] = 1; + + return x; + } + + // ── Fasce ──────────────────────────────────────────────────────── + + public static int GapBucket(double s) => s < 2 ? 0 : s < 4 ? 1 : s < 6 ? 2 : s < 8 ? 3 : s < 10 ? 4 : 5; + + public static int BiddersBucket(int n) => n <= 1 ? 0 : n == 2 ? 1 : n == 3 ? 2 : n <= 5 ? 3 : 4; + + public static int RatioBucket(double? r) => + r is not { } v ? 5 : v < 0.02 ? 0 : v < 0.05 ? 1 : v < 0.10 ? 2 : v < 0.20 ? 3 : 4; + + /// + /// 0: notte e ore sospese (0-8) · 1: le 9 · 2: 10-12 · 3: 13-17 · 4: 18-20 · 5: 21-23. + /// + public static int HourBand(int h) + { + h = ((h % 24) + 24) % 24; + if (h <= 8) return 0; + if (h == 9) return 1; + if (h <= 12) return 2; + if (h <= 17) return 3; + if (h <= 20) return 4; + return 5; + } + + public static bool IsWeekend(DayOfWeek d) => d is DayOfWeek.Saturday or DayOfWeek.Sunday; + + public static int SinceManualBucket(int n) => n < 0 ? 3 : n == 0 ? 0 : n <= 2 ? 1 : n <= 5 ? 2 : 3; + + public static int DepthBucket(int cents) => cents < 50 ? 0 : cents < 150 ? 1 : cents < 400 ? 2 : 3; + + /// + /// Hash stabile fra un avvio e l'altro. string.GetHashCode cambia a ogni + /// processo per scelta di .NET: usarlo qui farebbe rileggere pesi riferiti a + /// cassetti diversi da quelli in cui erano stati imparati. + /// + public static int ProductBucket(string? key) + { + if (string.IsNullOrWhiteSpace(key)) return 0; + + uint h = 2166136261; + foreach (var ch in key.Trim().ToLowerInvariant()) + { + h ^= ch; + h *= 16777619; + } + return (int)(h % ProductBuckets); + } + + private static string[] BuildNames() + { + var n = new List(); + n.AddRange(new[] { "ciclo<2s", "ciclo<4s", "ciclo<6s", "ciclo<8s", "ciclo<10s", "ciclo≥10s" }); + n.AddRange(new[] { "quotaAuto", "quotaAutoIgnota" }); + n.AddRange(new[] { "puntatori=1", "puntatori=2", "puntatori=3", "puntatori4-5", "puntatori≥6" }); + n.AddRange(new[] { "prezzo<2%", "prezzo<5%", "prezzo<10%", "prezzo<20%", "prezzo≥20%", "valoreIgnoto" }); + n.AddRange(new[] { "ora0-8", "ora9", "ora10-12", "ora13-17", "ora18-20", "ora21-23" }); + n.AddRange(new[] { "feriale", "festivo" }); + n.AddRange(new[] { "manuale", "automatica" }); + n.AddRange(new[] { "auto di fila=0", "auto di fila1-2", "auto di fila3-5", "auto di fila≥6" }); + n.AddRange(new[] { "profondità<50", "profondità<150", "profondità<400", "profondità≥400" }); + n.Add("stessoPuntatoreDiDueFa"); + n.AddRange(new[] { "aggancio0-8", "aggancio9", "aggancio10-12", "aggancio13-17", "aggancio18-20", "aggancio21-23" }); + for (var i = 0; i < ProductBuckets; i++) n.Add($"prodotto#{i}"); + return n.ToArray(); + } + } +} diff --git a/Mimante/Ml/DossierTrainingReader.cs b/Mimante/Ml/DossierTrainingReader.cs new file mode 100644 index 0000000..335b8b7 --- /dev/null +++ b/Mimante/Ml/DossierTrainingReader.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.Json; + +namespace AutoBidder.Ml +{ + /// + /// Legge da un dossier solo ciò che serve ad addestrare: intestazione, puntate, + /// riepilogo. Le interrogazioni — il 90% delle righe e quasi tutti i byte — si saltano + /// con un confronto di testo prima ancora di interpretare il JSON. + /// + /// È il motivo per cui il primo addestramento su 8,8 GB di dossier è fattibile in + /// sottofondo: per ogni file si interpreta l'uno per cento delle righe. + /// + public static class DossierTrainingReader + { + public sealed record Bid(double T, string User, long UnixSeconds, string BidType, bool Mine, double Price); + + public sealed class Session + { + public string AuctionId { get; init; } = ""; + public string ProductKey { get; init; } = ""; + public double? BuyNowPrice { get; init; } + public DateTime AddedAtLocal { get; init; } + public List Bids { get; init; } = new(); + public string Winner { get; init; } = ""; + public double FinalPrice { get; init; } + public bool HasSummary { get; init; } + + /// Il nostro nome utente, dall'intestazione (dossier recenti) o da fuori. + public string Me { get; init; } = ""; + + /// + /// Utilizzabile per l'addestramento: chiusa, con almeno qualche puntata, e con + /// l'ultima puntata registrata che sia davvero del vincitore. Se non lo è, il + /// dossier ha un buco proprio in fondo, e l'etichetta sarebbe sbagliata. + /// + public bool IsUsable => + HasSummary && Bids.Count >= 3 && !string.IsNullOrEmpty(Winner) && + string.Equals(Bids[^1].User, Winner, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Il nostro nome utente, per i dossier scritti prima che l'intestazione lo portasse. + /// Le puntate di quell'utente valgono come nostre anche se il file non le marca. + /// + public static Session? ReadFile(string path, string? me = null) + { + try { return Read(File.ReadLines(path), me); } + catch (IOException) { return null; } + catch (UnauthorizedAccessException) { return null; } + } + + public static Session Read(IEnumerable lines, string? me = null) + { + string auctionId = "", productKey = "", winner = ""; + var chiSonoIo = me ?? ""; + double? buyNow = null; + double finalPrice = 0; + var addedAt = DateTime.MinValue; + var hasSummary = false; + var bids = new List(); + + foreach (var raw in lines) + { + // Scarto veloce: la stragrande maggioranza delle righe è un poll. + if (raw.Length < 20 || raw.Contains("\"type\":\"poll\"", StringComparison.Ordinal)) continue; + if (raw.Contains("\"type\":\"log\"", StringComparison.Ordinal)) continue; + if (raw.Contains("\"type\":\"reset\"", StringComparison.Ordinal)) continue; + + var line = raw.TrimStart('', ' ', '\t'); + if (line.Length == 0 || line[0] != '{') continue; + + try + { + using var doc = JsonDocument.Parse(line); + var r = doc.RootElement; + var type = Str(r, "type"); + + if (type == "header") + { + auctionId = Str(r, "auctionId") ?? ""; + productKey = Str(r, "productKey") ?? ""; + var headerMe = Str(r, "me"); + if (!string.IsNullOrWhiteSpace(headerMe)) chiSonoIo = headerMe; + if (r.TryGetProperty("product", out var p) && p.ValueKind == JsonValueKind.Object) + buyNow = NullableNum(p, "buyNowPrice"); + if (DateTime.TryParse(Str(r, "addedAt"), CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, out var a)) + addedAt = a.Kind == DateTimeKind.Utc ? a.ToLocalTime() : a; + } + else if (type == "bid") + { + bids.Add(new Bid( + T: Num(r, "t", 0), + User: Str(r, "user") ?? "", + UnixSeconds: ParseUnix(Str(r, "bidAt")), + BidType: Str(r, "bidType") ?? "", + Mine: Bool(r, "mine"), + Price: Num(r, "price", 0))); + } + else if (type == "summary") + { + hasSummary = true; + winner = Str(r, "winner") ?? ""; + finalPrice = Num(r, "finalPrice", 0); + } + } + catch (JsonException) { /* riga rotta: si va avanti */ } + } + + // In ordine di tempo dichiarato dal server, poi di prezzo: è l'ordine vero + // delle puntate, non quello in cui le abbiamo sapute. + bids.Sort((a, b) => + { + var c = a.UnixSeconds.CompareTo(b.UnixSeconds); + return c != 0 ? c : a.Price.CompareTo(b.Price); + }); + + // Le puntate nostre: quelle marcate, oppure quelle del nostro nome utente. I + // dossier scritti finora non marcavano niente — la bandiera veniva copiata da + // un campo che nessuno impostava — quindi il nome è l'unica via per il passato. + if (chiSonoIo.Length > 0) + { + for (var i = 0; i < bids.Count; i++) + { + if (!bids[i].Mine && string.Equals(bids[i].User, chiSonoIo, StringComparison.OrdinalIgnoreCase)) + bids[i] = bids[i] with { Mine = true }; + } + } + + return new Session + { + AuctionId = auctionId, + ProductKey = productKey, + BuyNowPrice = buyNow, + AddedAtLocal = addedAt, + Bids = bids, + Winner = winner, + FinalPrice = finalPrice, + HasSummary = hasSummary, + Me = chiSonoIo + }; + } + + /// + /// Gli esempi di addestramento di un dossier: uno per puntata, con etichetta + /// «è rimasta senza risposta» — vera solo per l'ultima. La prima puntata non ha un + /// ciclo da misurare e si salta. + /// + public static IEnumerable<(BidContext Context, bool Unanswered, bool Mine)> Examples(Session s) + { + var bids = s.Bids; + if (bids.Count < 2) yield break; + + for (var i = 1; i < bids.Count; i++) + { + var b = bids[i]; + var prev = bids[i - 1]; + + var gap = b.UnixSeconds > 0 && prev.UnixSeconds > 0 ? b.UnixSeconds - prev.UnixSeconds : -1; + + var when = b.UnixSeconds > 0 + ? DateTimeOffset.FromUnixTimeSeconds(b.UnixSeconds).ToLocalTime().DateTime + : s.AddedAtLocal.AddSeconds(b.T); + + var ctx = new BidContext( + CycleSeconds: gap, + AutoShareRecent: AutoShare(bids, i, 10), + DistinctBiddersRecent: Distinct(bids, i, 20), + PriceRatio: s.BuyNowPrice is > 0 ? b.Price / s.BuyNowPrice.Value : null, + Hour: when.Hour, + Day: when.DayOfWeek, + OpenHour: s.AddedAtLocal == DateTime.MinValue ? when.Hour : s.AddedAtLocal.Hour, + IsAutoBid: string.Equals(b.BidType, "Auto", StringComparison.OrdinalIgnoreCase), + CyclesSinceManual: SinceManual(bids, i), + AuctionDepthCents: (int)Math.Round(b.Price * 100), + SameBidderAsTwoAgo: i >= 2 && string.Equals(bids[i - 2].User, b.User, StringComparison.OrdinalIgnoreCase), + ProductKey: s.ProductKey); + + yield return (ctx, i == bids.Count - 1, b.Mine); + } + } + + // Le finestre guardano le puntate PRIMA di questa: sono quelle che si sarebbero + // viste nell'istante di decidere. Includere questa stessa puntata sarebbe dare al + // modello un pezzo di futuro. + + private static double AutoShare(List bids, int i, int window) + { + int n = 0, auto = 0; + for (var k = i - 1; k >= 0 && n < window; k--, n++) + if (string.Equals(bids[k].BidType, "Auto", StringComparison.OrdinalIgnoreCase)) auto++; + return n == 0 ? -1 : (double)auto / n; + } + + private static int Distinct(List bids, int i, int window) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int k = i - 1, n = 0; k >= 0 && n < window; k--, n++) seen.Add(bids[k].User); + return Math.Max(1, seen.Count); + } + + private static int SinceManual(List bids, int i) + { + var n = 0; + for (var k = i - 1; k >= 0; k--) + { + if (!string.Equals(bids[k].BidType, "Auto", StringComparison.OrdinalIgnoreCase)) return n; + if (++n >= 20) break; + } + return -1; + } + + // ── JSON ───────────────────────────────────────────────────────── + + private static string? Str(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + private static double Num(JsonElement e, string name, double fallback) => + 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 bool Bool(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True; + + private static long ParseUnix(string? iso) => + DateTimeOffset.TryParse(iso, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var d) + ? d.ToUnixTimeSeconds() : 0; + } +} diff --git a/Mimante/Ml/LearningService.cs b/Mimante/Ml/LearningService.cs new file mode 100644 index 0000000..c7d6e53 --- /dev/null +++ b/Mimante/Ml/LearningService.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using AutoBidder.Models; +using AutoBidder.Utilities; + +namespace AutoBidder.Ml +{ + /// + /// L'apprendimento, sempre acceso: impara da ogni asta che chiude e risponde al motore + /// quando deve decidere. + /// + /// Due cose imparate, per due domande diverse. Il modello delle + /// puntate risponde a «se punto adesso, qualcuno risponderà?»: una regressione + /// logistica su ciò che si vede nell'istante della puntata. Il profilo delle aste + /// risponde a «quanto costa di solito vincere questo prodotto a quest'ora?»: medie per + /// prodotto, fascia oraria e tipo di giorno, con restringimento dove i dati scarseggiano. + /// + /// Da dove impara. Dai dossier. Ogni puntata di ogni asta chiusa è un + /// esempio: rimasta senza risposta oppure no. Non dalle nostre vittorie, che sono + /// diciannove in duemila aste: un modello addestrato su quelle non sarebbe un modello. + /// Il comportamento avversario non dipende da chi ha puntato, e questo rende + /// utilizzabili i milioni di puntate altrui. + /// + /// Il primo addestramento è incrementale e in sottofondo: i dossier sono + /// migliaia e pesano gigabyte, e si leggono un po' per avvio, dal più recente, con un + /// tempo massimo per sessione. Quello che è già stato letto non si rilegge: un elenco + /// dei file appresi lo tiene a mente. Il profilo delle aste invece parte subito dallo + /// storico compatto, che è piccolo e ha le puntate del vincitore già verificate. + /// + /// Cosa NON può dire. Il modello impara come finiscono le aste senza di + /// noi. La nostra puntata cambia il gioco: chi stava per smettere può rispondere. + /// Nessun dato storico dice questo. Il modello dà la stima migliore possibile di + /// quando gli altri sono esausti, ed è quanto si può chiedere ai dati. + /// + public static class LearningService + { + private static readonly object Sync = new(); + + // I salvataggi hanno un lucchetto tutto loro: all'avvio più aste concluse nel + // frattempo vengono chiuse insieme, ognuna su un proprio thread, e senza questo + // scrivevano lo stesso file nello stesso istante — "file in uso da un altro + // processo", che in realtà eravamo noi. + private static readonly object SaveSync = new(); + + private static OnlineLogit _model = new(BidFeatures.Size); + private static AuctionProfileStats _profile = new(); + private static readonly HashSet _ingested = new(StringComparer.OrdinalIgnoreCase); + private static int _auctionsLearned; + private static bool _loaded; + private static volatile bool _bootstrapRunning; + + /// Righe di registro sull'apprendimento: chi si aggancia le scrive dove vuole. + public static event Action? OnLog; + + // ── Percorsi ───────────────────────────────────────────────────── + + private static string Folder => Path.Combine(AppPaths.StatsFolder, "Apprendimento"); + private static string ModelFile => Path.Combine(Folder, "modello-puntate.json"); + private static string ProfileFile => Path.Combine(Folder, "profilo-aste.json"); + private static string ManifestFile => Path.Combine(Folder, "dossier-appresi.txt"); + + // ── Stato, per l'interfaccia e il registro ─────────────────────── + + public static long ModelUpdates { get { lock (Sync) return _model.Updates; } } + public static double CalibrationFactor { get { lock (Sync) return _model.CalibrationFactor; } } + public static int AuctionsLearned { get { lock (Sync) return _auctionsLearned; } } + public static long ProfileAuctions { get { lock (Sync) return _profile.AuctionsObserved; } } + public static bool BootstrapRunning => _bootstrapRunning; + + /// + /// Abbastanza aste apprese perché il cancello sul valore atteso possa fermare una + /// puntata. Prima di questa soglia il modello parla, ma non decide. + /// + public static bool IsReady(AppSettings settings) => + AuctionsLearned >= Math.Max(1, settings.LearningMinAuctions); + + /// I pesi con i loro nomi, ordinati per peso: per guardare cosa ha imparato. + public static IReadOnlyList<(string Nome, double Peso)> Pesi() + { + lock (Sync) + { + return BidFeatures.Names + .Select((n, i) => (n, _model.Weights[i])) + .OrderByDescending(t => Math.Abs(t.Item2)) + .ToList(); + } + } + + // ── Avvio ──────────────────────────────────────────────────────── + + public static void Start(AppSettings settings) + { + Load(); + BootstrapProfileFromHistory(); + _ = Task.Run(() => BootstrapAsync(settings)); + } + + private static void Load() + { + lock (Sync) + { + if (_loaded) return; + _loaded = true; + + try + { + if (File.Exists(ModelFile)) + _model = OnlineLogit.FromJson(File.ReadAllText(ModelFile), BidFeatures.Size); + if (File.Exists(ProfileFile)) + _profile = AuctionProfileStats.FromJson(File.ReadAllText(ProfileFile)); + if (File.Exists(ManifestFile)) + { + foreach (var line in File.ReadLines(ManifestFile)) + { + var l = line.Trim(); + if (l.Length == 0) continue; + if (l.StartsWith("#", StringComparison.Ordinal)) + { + if (l.StartsWith("#appresi=", StringComparison.Ordinal) && + int.TryParse(l["#appresi=".Length..], out var n)) _auctionsLearned = n; + continue; + } + _ingested.Add(l); + } + } + } + catch (Exception ex) + { + OnLog?.Invoke($"[APPRENDIMENTO] Archivio illeggibile, si riparte da zero: {ex.Message}"); + _model = new OnlineLogit(BidFeatures.Size); + _profile = new AuctionProfileStats(); + _ingested.Clear(); + _auctionsLearned = 0; + } + } + + OnLog?.Invoke($"[APPRENDIMENTO] Modello: {ModelUpdates:N0} aggiornamenti da {AuctionsLearned:N0} aste; " + + $"profilo: {ProfileAuctions:N0} aste; dossier già letti: {_ingested.Count:N0}"); + } + + private static void Save() + { + try + { + string model, profile; + lock (Sync) + { + model = _model.ToJson(); + profile = _profile.ToJson(); + } + + lock (SaveSync) + { + Directory.CreateDirectory(Folder); + File.WriteAllText(ModelFile, model); + File.WriteAllText(ProfileFile, profile); + } + } + catch (Exception ex) + { + OnLog?.Invoke($"[APPRENDIMENTO] Salvataggio non riuscito: {ex.Message}"); + } + } + + private static void SaveManifest() + { + try + { + string[] lines; + lock (Sync) + { + lines = new[] { "#appresi=" + _auctionsLearned } + .Concat(_ingested.OrderBy(x => x, StringComparer.Ordinal)) + .ToArray(); + } + + lock (SaveSync) + { + Directory.CreateDirectory(Folder); + File.WriteAllLines(ManifestFile, lines); + } + } + catch (Exception ex) + { + OnLog?.Invoke($"[APPRENDIMENTO] Elenco dei dossier letti non salvato: {ex.Message}"); + } + } + + /// + /// Il profilo parte dallo storico compatto: piccolo, immediato, e con le puntate + /// del vincitore già passate dal controllo di coerenza. Solo se il profilo è vuoto, + /// perché lo storico è la stessa fonte che poi lo alimenta asta per asta. + /// + private static void BootstrapProfileFromHistory() + { + lock (Sync) { if (_profile.AuctionsObserved > 0) return; } + + try + { + var records = CompletedAuctionsStore.LoadAll(); + var fresh = new AuctionProfileStats(); + + foreach (var r in records) + fresh.Observe(r.ProductKey, r.EndedAt.ToLocalTime(), + AuctionIntegrity.TrustedWinnerBids(r), Ratio(r.FinalPrice, r.BuyNowPrice)); + + lock (Sync) _profile = fresh; + Save(); + + OnLog?.Invoke($"[APPRENDIMENTO] Profilo delle aste costruito dallo storico: {records.Count:N0} aste"); + } + catch (Exception ex) + { + OnLog?.Invoke($"[APPRENDIMENTO] Profilo dallo storico non riuscito: {ex.Message}"); + } + } + + /// + /// Legge i dossier non ancora appresi, dal più recente, finché c'è tempo. Il tempo è + /// per avvio: la prima volta ne servono diversi, poi resta solo l'asta appena chiusa. + /// + private static async Task BootstrapAsync(AppSettings settings) + { + if (_bootstrapRunning) return; + _bootstrapRunning = true; + + try + { + await Task.Delay(3000).ConfigureAwait(false); // prima l'avvio, poi lo studio + + var folder = AppPaths.AuctionLogFolder; + if (!Directory.Exists(folder)) return; + + string[] pending; + lock (Sync) + { + pending = Directory.GetFiles(folder, "*.jsonl") + .Where(f => !_ingested.Contains(Path.GetFileName(f))) + .OrderByDescending(Path.GetFileName, StringComparer.Ordinal) + .ToArray(); + } + + if (pending.Length == 0) return; + + var budget = TimeSpan.FromSeconds(Math.Max(10, settings.LearningBootstrapSecondsPerStart)); + var sw = Stopwatch.StartNew(); + var done = 0; + var usable = 0; + + OnLog?.Invoke($"[APPRENDIMENTO] {pending.Length:N0} dossier da leggere, al massimo {budget.TotalSeconds:N0} s per questo avvio"); + + foreach (var file in pending) + { + if (sw.Elapsed > budget) break; + + if (IngestFile(file)) usable++; + done++; + + if (done % 100 == 0) + { + Save(); + SaveManifest(); + OnLog?.Invoke($"[APPRENDIMENTO] {done:N0} dossier letti, {usable:N0} utilizzabili, {ModelUpdates:N0} aggiornamenti"); + } + + // Un respiro ogni file: il motore delle aste ha la precedenza. + await Task.Yield(); + } + + Save(); + SaveManifest(); + + var remaining = pending.Length - done; + OnLog?.Invoke($"[APPRENDIMENTO] Letti {done:N0} dossier ({usable:N0} utilizzabili) in {sw.Elapsed.TotalSeconds:N0} s; " + + (remaining > 0 ? $"ne restano {remaining:N0} per i prossimi avvii." : "archivio completo.")); + } + catch (Exception ex) + { + OnLog?.Invoke($"[APPRENDIMENTO] Studio dei dossier interrotto: {ex.Message}"); + } + finally + { + _bootstrapRunning = false; + } + } + + /// + /// Impara da un dossier. Restituisce true se era utilizzabile. Un file letto si + /// segna come letto anche se non era utilizzabile: rileggerlo non lo renderebbe tale. + /// + private static bool IngestFile(string path) + { + var name = Path.GetFileName(path); + var session = DossierTrainingReader.ReadFile(path); + + lock (Sync) + { + _ingested.Add(name); + if (session == null || !session.IsUsable) return false; + + foreach (var (ctx, unanswered, _) in DossierTrainingReader.Examples(session)) + _model.Update(BidFeatures.Build(ctx), unanswered); + + _auctionsLearned++; + } + + return true; + } + + // ── Chiusura di un'asta: si impara subito ──────────────────────── + + /// + /// Da chiamare quando un'asta è chiusa e il suo dossier è stato scritto. Aggiorna + /// il profilo con lo storico appena registrato e il modello con le puntate del + /// dossier, poi salva: la prossima decisione le vede già. + /// + public static void OnAuctionClosed(CompletedAuctionRecord record, string? dossierPath) + { + if (record == null) return; + + try + { + lock (Sync) + { + _profile.Observe(record.ProductKey, record.EndedAt.ToLocalTime(), + AuctionIntegrity.TrustedWinnerBids(record), Ratio(record.FinalPrice, record.BuyNowPrice)); + } + + var learned = false; + if (!string.IsNullOrWhiteSpace(dossierPath) && File.Exists(dossierPath)) + { + // Il dossier è appena stato svuotato su disco da Close: si può leggere. + learned = IngestFile(dossierPath); + } + + Save(); + SaveManifest(); + + OnLog?.Invoke(learned + ? $"[APPRENDIMENTO] '{record.Name}': appresa ({ModelUpdates:N0} aggiornamenti, {AuctionsLearned:N0} aste)" + : $"[APPRENDIMENTO] '{record.Name}': profilo aggiornato, dossier non utilizzabile per il modello"); + } + catch (Exception ex) + { + OnLog?.Invoke($"[APPRENDIMENTO] Errore su '{record.Name}': {ex.Message}"); + } + } + + // ── Domande dal motore ─────────────────────────────────────────── + + /// + /// Se puntassi adesso, con che probabilità nessuno risponderebbe? Null quando non + /// c'è abbastanza contesto (nessuna puntata recente vista). + /// + public static double? PredictUnanswered(AuctionInfo auction, AuctionState state, DateTime nowLocal, string? myUsername) + { + if (auction == null || state == null) return null; + + var recent = auction.SnapshotRecentBids(20); // dalla più recente + if (recent.Count == 0) return null; + + var last = recent[0]; + var gap = state.ServerUnixSeconds > 0 && last.Timestamp > 0 + ? state.ServerUnixSeconds - last.Timestamp + : -1; + + // Quota di autopuntate: solo sui tipi dichiarati dal server. + int declared = 0, auto = 0; + foreach (var b in recent.Take(10)) + { + if (b.IsAuto) { auto++; declared++; } + else if (b.IsManual) declared++; + } + var autoShare = declared == 0 ? -1 : (double)auto / declared; + + var distinct = recent.Select(b => b.Username).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + + var sinceManual = -1; + for (var i = 0; i < recent.Count; i++) + { + if (recent[i].IsManual) { sinceManual = i; break; } + if (!recent[i].IsAuto) break; // tipo ignoto: non si conta + } + + var ctx = new BidContext( + CycleSeconds: gap, + AutoShareRecent: autoShare, + DistinctBiddersRecent: Math.Max(1, distinct), + PriceRatio: auction.BuyNowPrice is > 0 ? state.Price / auction.BuyNowPrice.Value : null, + Hour: nowLocal.Hour, + Day: nowLocal.DayOfWeek, + OpenHour: auction.AddedAt.ToLocalTime().Hour, + IsAutoBid: false, + CyclesSinceManual: sinceManual, + AuctionDepthCents: (int)Math.Round(state.Price * 100), + SameBidderAsTwoAgo: recent.Count >= 2 && !string.IsNullOrEmpty(myUsername) && + string.Equals(recent[1].Username, myUsername, StringComparison.OrdinalIgnoreCase), + ProductKey: ProductKeyHelper.GenerateProductKey(auction.Name)); + + var x = BidFeatures.Build(ctx); + lock (Sync) return _model.PredictCalibrated(x); + } + + /// Cosa aspettarsi da un'asta di questo prodotto se chiudesse a quest'ora. + public static AuctionProfileStats.Estimate? Expect(string? productKey, DateTime whenLocal) + { + lock (Sync) return _profile.Expect(productKey, whenLocal); + } + + private static double? Ratio(double finalPrice, double? buyNow) => + buyNow is > 0 && finalPrice > 0 ? finalPrice / buyNow.Value : null; + + /// Solo per i test: stato pulito, senza toccare il disco. + public static void ResetForTests() + { + lock (Sync) + { + _model = new OnlineLogit(BidFeatures.Size); + _profile = new AuctionProfileStats(); + _ingested.Clear(); + _auctionsLearned = 0; + _loaded = true; + } + } + } +} diff --git a/Mimante/Ml/OnlineLogit.cs b/Mimante/Ml/OnlineLogit.cs new file mode 100644 index 0000000..2ae7f38 --- /dev/null +++ b/Mimante/Ml/OnlineLogit.cs @@ -0,0 +1,232 @@ +using System; +using System.Text.Json; + +namespace AutoBidder.Ml +{ + /// + /// Regressione logistica che impara un esempio alla volta. + /// + /// Perché proprio questa. È il modello più semplice che dia una + /// probabilità calibrata, e la calibrazione qui non è un lusso: il numero che + /// esce entra in una formula di valore atteso, moltiplicato per euro. Un modello che + /// ordinasse bene le puntate ma sbagliasse la scala direbbe di puntare quando non + /// conviene. Si aggiorna con un passo di discesa del gradiente per esempio, senza + /// bisogno di tenere i dati: è quello che serve a un motore che deve migliorare a ogni + /// asta chiusa, senza riaddestrarsi da capo. + /// + /// Nessuna libreria. Sono trenta righe di aritmetica: un prodotto scalare, + /// una sigmoide, un aggiornamento dei pesi. Portarsi dietro una dipendenza per questo + /// sarebbe più codice, non meno. + /// + /// Sbilanciamento delle classi. Le puntate senza risposta sono una su + /// quattrocento. Non si riequilibra apposta: pesare di più i positivi gonfierebbe le + /// probabilità, e sarebbero poi confrontate con costi veri. Si lascia che il modello + /// impari la scala vera, e si accetta che serva più materiale. + /// + public sealed class OnlineLogit + { + /// Pesi delle variabili; l'ultimo elemento è l'intercetta. + public double[] Weights { get; private set; } + + /// + /// Media mobile dei pesi, usata per prevedere quando è acceso. + /// + /// Con un passo costante i pesi oscillano attorno al punto buono: ogni esempio + /// li strattona un po' nella sua direzione, e l'ultimo esempio di ogni asta è sempre + /// il positivo. Prevedere con la media degli ultimi qualche migliaio di passi toglie + /// l'oscillazione senza fermare l'apprendimento: i pesi vivi continuano a muoversi, + /// la media li segue con calma. + /// + public double[] AveragedWeights { get; private set; } + + /// + /// Prevedere con la media dei pesi invece che con i pesi vivi. Spento di serie: + /// sui dossier veri non ha migliorato né separazione né scala, e la calibrazione + /// in linea fa il lavoro che ci si aspettava dalla media. + /// + public bool UseAveraging { get; set; } = false; + + /// Quanti passi «pesa» la media mobile: 2000 sono circa otto aste. + public int AveragingWindow { get; set; } = 2000; + + /// Quanti esempi hanno aggiornato i pesi finora. + public long Updates { get; private set; } + + // ── Calibrazione in linea ──────────────────────────────────────── + // + // Sui dossier veri il modello separa bene ma la scala deriva: addestrato sui + // vecchi e giudicato sui nuovi sovrastima di 1,5-1,8 volte, e il mercato cambia + // davvero fra un mese e l'altro. Si tiene quindi il rapporto fra esiti osservati e + // probabilità previste sugli ultimi esempi, e si scala la previsione con quello. È + // il modo più semplice di restare nella scala giusta mentre il mondo si muove, e + // non richiede nessun dato in più di quelli che già passano di qui. + private double _emaObserved; + private double _emaPredicted; + + /// Su quanti esempi si misura il rapporto: 20000 sono circa ottanta aste. + public int CalibrationWindow { get; set; } = 20000; + + /// + /// Osservato / previsto sugli ultimi esempi, limitato fra ¼ e 4. Uno = scala giusta. + /// + public double CalibrationFactor + { + get + { + if (Updates < 2000 || _emaPredicted <= 0) return 1.0; + return Math.Clamp(_emaObserved / _emaPredicted, 0.25, 4.0); + } + } + + /// Probabilità riportata nella scala osservata di recente. + public double PredictCalibrated(ReadOnlySpan x) => + Math.Clamp(Predict(x) * CalibrationFactor, 0.0, 1.0); + + /// + /// Passo di apprendimento. Costante, non decrescente: il mercato cambia — nuovi + /// utenti, nuove abitudini — e un passo che tende a zero smetterebbe di seguirlo. + /// + public double LearningRate { get; set; } = 0.01; + + /// + /// Regolarizzazione: tiene i pesi piccoli quando una variabile ha pochi esempi. + /// Serve soprattutto per i prodotti rari, che altrimenti farebbero oscillare tutto. + /// + public double L2 { get; set; } = 1e-4; + + public int FeatureCount => Weights.Length - 1; + + public OnlineLogit(int featureCount) + { + if (featureCount <= 0) throw new ArgumentOutOfRangeException(nameof(featureCount)); + Weights = new double[featureCount + 1]; + AveragedWeights = new double[featureCount + 1]; + } + + private OnlineLogit(double[] weights, double[] averaged, long updates) + { + Weights = weights; + AveragedWeights = averaged; + Updates = updates; + } + + /// Probabilità dell'esito positivo per queste variabili. + public double Predict(ReadOnlySpan x) + { + if (x.Length != FeatureCount) + throw new ArgumentException($"attese {FeatureCount} variabili, ricevute {x.Length}"); + + // Nei primissimi passi la media è ancora vicina a zero: si usano i pesi vivi. + var w = UseAveraging && Updates >= 200 ? AveragedWeights : Weights; + return Sigmoid(Dot(w, x)); + } + + /// Un passo di apprendimento su un esempio con la sua etichetta. + public void Update(ReadOnlySpan x, bool positive) + { + var p = Sigmoid(Dot(Weights, x)); // il gradiente usa i pesi vivi, sempre + var err = (positive ? 1.0 : 0.0) - p; + + // La calibrazione si misura sulla previsione fatta PRIMA di imparare da questo + // esempio: è quella che il motore avrebbe usato. + var beta = 1.0 / Math.Min(Updates + 1, Math.Max(1, CalibrationWindow)); + _emaObserved += ((positive ? 1.0 : 0.0) - _emaObserved) * beta; + _emaPredicted += (Predict(x) - _emaPredicted) * beta; + var w = Weights; + var lr = LearningRate; + + for (var i = 0; i < x.Length; i++) + { + if (x[i] == 0) continue; // le variabili a zero non muovono niente + w[i] += lr * (err * x[i] - L2 * w[i]); + } + + w[^1] += lr * err; // intercetta: senza regolarizzazione, deve poter valere quanto serve + Updates++; + + // Media mobile esponenziale: nei primi passi segue da vicino (così non parte + // da zero), poi si assesta sulla finestra scelta. + var betaMedia = 1.0 / Math.Min(Updates, Math.Max(1, AveragingWindow)); + var a = AveragedWeights; + for (var i = 0; i < a.Length; i++) a[i] += (w[i] - a[i]) * betaMedia; + } + + private static double Dot(double[] w, ReadOnlySpan x) + { + var s = w[^1]; + for (var i = 0; i < x.Length; i++) s += w[i] * x[i]; + return s; + } + + private static double Sigmoid(double z) + { + // Oltre ±30 la sigmoide è già 0 o 1 in doppia precisione: si evita l'overflow. + if (z > 30) return 1.0; + if (z < -30) return 0.0; + return 1.0 / (1.0 + Math.Exp(-z)); + } + + // ── Persistenza ────────────────────────────────────────────────── + + private sealed class Snapshot + { + public int Version { get; set; } = 2; + public double[] Weights { get; set; } = Array.Empty(); + public double[]? AveragedWeights { get; set; } + public long Updates { get; set; } + public double LearningRate { get; set; } + public double L2 { get; set; } + public bool UseAveraging { get; set; } = false; + public int AveragingWindow { get; set; } = 2000; + public double EmaObserved { get; set; } + public double EmaPredicted { get; set; } + public int CalibrationWindow { get; set; } = 20000; + } + + public string ToJson() => JsonSerializer.Serialize(new Snapshot + { + Weights = Weights, + AveragedWeights = AveragedWeights, + Updates = Updates, + LearningRate = LearningRate, + L2 = L2, + UseAveraging = UseAveraging, + AveragingWindow = AveragingWindow, + EmaObserved = _emaObserved, + EmaPredicted = _emaPredicted, + CalibrationWindow = CalibrationWindow + }); + + /// + /// Rilegge un modello salvato. Se il numero di variabili non coincide — perché le + /// variabili sono cambiate fra una versione e l'altra — si riparte da zero: pesi + /// riferiti a variabili diverse non sono un modello, sono rumore. + /// + public static OnlineLogit FromJson(string json, int expectedFeatureCount) + { + try + { + var s = JsonSerializer.Deserialize(json); + if (s?.Weights != null && s.Weights.Length == expectedFeatureCount + 1) + { + var averaged = s.AveragedWeights != null && s.AveragedWeights.Length == s.Weights.Length + ? s.AveragedWeights + : (double[])s.Weights.Clone(); + + // Si rileggono lo STATO appreso (pesi, conteggio, calibrazione) e non i + // parametri (passo, regolarizzazione, media): quelli sono scelte del + // codice, tarate sui dossier veri, e un cambio deve valere dal riavvio + // successivo — non restare inchiodato al valore di quando il file nacque. + return new OnlineLogit(s.Weights, averaged, s.Updates) + { + _emaObserved = s.EmaObserved, + _emaPredicted = s.EmaPredicted + }; + } + } + catch (JsonException) { /* file illeggibile: si riparte */ } + + return new OnlineLogit(expectedFeatureCount); + } + } +} diff --git a/Mimante/Models/AuctionInfo.cs b/Mimante/Models/AuctionInfo.cs index 1e744a2..000215c 100644 --- a/Mimante/Models/AuctionInfo.cs +++ b/Mimante/Models/AuctionInfo.cs @@ -584,6 +584,18 @@ namespace AutoBidder.Models [JsonIgnore] public bool AutoBidDuelDetected { get; set; } + // ── 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. + + /// Probabilità che una puntata fatta adesso resti senza risposta. + [JsonIgnore] + public double? LearnedUnansweredProbability { get; set; } + + /// Valore atteso in euro di una puntata fatta adesso: sotto zero si perde. + [JsonIgnore] + public double? LearnedExpectedValue { get; set; } + /// /// Registra la risposta avversaria arrivata adesso e aggiorna il conteggio. /// Restituisce true se proprio con questa risposta il duello viene riconosciuto, diff --git a/Mimante/Modifiche.txt b/Mimante/Modifiche.txt index ecc80b1..1b07c86 100644 --- a/Mimante/Modifiche.txt +++ b/Mimante/Modifiche.txt @@ -17,3 +17,5 @@ Nota: - In alternativa al metodo descritto prima possiamo definire delle sessioni "di addestramento" e delle sessioni "reali" in cui la prima si concentra su log e raccolta dati mentre la seconda di concentra su velocità ed esecuzione portando al minimo raccolta dati ed esportazione Storico aste per analisi: C:\Users\alber\Downloads\AutoBidder\storico-aste-20260907-163509.json + +Fai domande se non sei sicuro di come procedere oppure se non sono stato abbastanza chiaro. diff --git a/Mimante/Services/AuctionMonitor.cs b/Mimante/Services/AuctionMonitor.cs index 24817c6..09c233f 100644 --- a/Mimante/Services/AuctionMonitor.cs +++ b/Mimante/Services/AuctionMonitor.cs @@ -452,7 +452,7 @@ namespace AutoBidder.Services // Il dossier registra tutto ciò che passa di qui: è materiale che esiste // solo adesso. var pollSettings = Settings; - AuctionDossier.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls && RuntimeProfile.RawPolls); + AuctionDossier.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls); if (state.RecentBidsHistory != null && state.RecentBidsHistory.Count > 0) { @@ -870,7 +870,7 @@ namespace AutoBidder.Services } else { - if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Convenienza OK (check={settings.ValueCheckEnabled}, buyNow={auction.BuyNowPrice?.ToString("F2") ?? "N/D"})", + auction.AddLog($"✓ Convenienza OK (check={settings.ValueCheckEnabled}, buyNow={auction.BuyNowPrice?.ToString("F2") ?? "N/D"})", Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Value); } @@ -925,7 +925,7 @@ namespace AutoBidder.Services } else if (session != null) { - if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Puntate residue OK ({session.RemainingBids} > {settings.MinimumRemainingBids})", + auction.AddLog($"✓ Puntate residue OK ({session.RemainingBids} > {settings.MinimumRemainingBids})", Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Limit); } } @@ -933,7 +933,7 @@ namespace AutoBidder.Services // CONTROLLO 2: Non puntare se sono già il vincitore corrente if (state.IsMyBid) { - if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Sono già vincitore corrente - skip", + auction.AddLog($"✓ Sono già vincitore corrente - skip", Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.BidAttempt); return false; } @@ -954,7 +954,7 @@ namespace AutoBidder.Services if (maxBids > 0) { - if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Puntate OK ({usedBids}/{maxBids})", + auction.AddLog($"✓ Puntate OK ({usedBids}/{maxBids})", Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Limit); } } @@ -976,7 +976,7 @@ namespace AutoBidder.Services return false; } - if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Prezzo OK (€{state.Price:F2} in [{auction.MinPrice:F2}, {(auction.MaxPrice > 0 ? auction.MaxPrice.ToString("F2") : "∞")}])", + auction.AddLog($"✓ Prezzo OK (€{state.Price:F2} in [{auction.MinPrice:F2}, {(auction.MaxPrice > 0 ? auction.MaxPrice.ToString("F2") : "∞")}])", Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Price); @@ -1020,7 +1020,7 @@ namespace AutoBidder.Services } } - if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Tutti i controlli ShouldBid superati", + auction.AddLog($"✓ Tutti i controlli ShouldBid superati", Models.AuctionLogLevel.Info, Models.AuctionLogCategory.BidAttempt); return true; @@ -1131,9 +1131,21 @@ namespace AutoBidder.Services var added = 0; var dossier = AuctionDossier.For(auction.AuctionId); + // Le puntate nostre vanno marcate qui, dove il nome di sessione c'è: il + // parser lo fa solo se glielo si passa, e nel percorso dei poll non gli + // arriva. Senza, nessun dossier ha mai portato una puntata con mine:true, + // e l'analisi non poteva dire cosa fosse successo alle nostre. + var me = _apiClient.GetSession()?.Username; + if (!string.IsNullOrEmpty(me)) AuctionDossier.CurrentUsername = me; + foreach (var bid in newBids) { if (!existing.Add($"{bid.Timestamp}_{bid.Username}_{bid.Price:F2}")) continue; + + if (!bid.IsMyBid && !string.IsNullOrEmpty(me) && + string.Equals(bid.Username, me, StringComparison.OrdinalIgnoreCase)) + bid.IsMyBid = true; + bids.Add(bid); added++; diff --git a/Mimante/Services/BidStrategyService.cs b/Mimante/Services/BidStrategyService.cs index 9274103..cbb27b3 100644 --- a/Mimante/Services/BidStrategyService.cs +++ b/Mimante/Services/BidStrategyService.cs @@ -210,6 +210,40 @@ namespace AutoBidder.Services 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; + + if (Ml.LearningService.IsReady(settings) && 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%! diff --git a/Mimante/Tests/AuctionDossierTests.cs b/Mimante/Tests/AuctionDossierTests.cs index f932180..ece5800 100644 --- a/Mimante/Tests/AuctionDossierTests.cs +++ b/Mimante/Tests/AuctionDossierTests.cs @@ -85,6 +85,76 @@ public class AuctionDossierTests AuctionDossier.Abandon(auction.AuctionId, "fine test"); } + // ── Coalescenza dei poll ───────────────────────────────────────────── + // + // Con ~17 interrogazioni al secondo per asta, su 40 dossier veri l'88,8% dei poll + // aveva la stessa faccia del precedente. Si scrive solo quando cambia qualcosa di + // visibile: la profondita' di ogni ciclo resta ricostruibile, il file pesa un decimo. + + private static int Conta(AuctionDossier dossier, string tipo) => + LinesOf(dossier).Count(l => l.Contains($"\"type\":\"{tipo}\"", StringComparison.Ordinal)); + + [Fact] + public void I_poll_identici_di_fila_si_scrivono_una_volta_sola() + { + var auction = NewAuction(); + var dossier = AuctionDossier.OpenFor(auction, Settings())!; + + for (var i = 0; i < 10; i++) dossier.Poll(State(1.24, 15.6), includeRaw: true); + Assert.Equal(1, Conta(dossier, "poll")); + + // Cambia il secondo sul cronometro: si scrive, perche' e' da qui che si legge + // fin dove e' sceso il timer. + dossier.Poll(State(1.24, 14.9), includeRaw: true); + Assert.Equal(2, Conta(dossier, "poll")); + + // Cambia il prezzo: si scrive. + dossier.Poll(State(1.25, 15.0), includeRaw: true); + Assert.Equal(3, Conta(dossier, "poll")); + + AuctionDossier.Abandon(auction.AuctionId, "fine test"); + } + + [Fact] + public void Il_reset_porta_il_minimo_del_ciclo_chiuso_e_la_sua_durata() + { + var auction = NewAuction(); + var dossier = AuctionDossier.OpenFor(auction, Settings())!; + + dossier.Poll(State(1.24, 8.0), includeRaw: true); + dossier.Poll(State(1.24, 3.4), includeRaw: true); + dossier.Poll(State(1.24, 1.2), includeRaw: true); + dossier.Poll(State(1.25, 8.0), includeRaw: true); // il prezzo sale: ciclo chiuso + dossier.Reset(1, 1.25, "marco_82"); + + var reset = LinesOf(dossier).Single(l => l.Contains("\"type\":\"reset\"", StringComparison.Ordinal)); + var r = JsonDocument.Parse(reset).RootElement; + + Assert.Equal(1.2, r.GetProperty("minTimer").GetDouble(), 3); + Assert.True(r.GetProperty("cycleSeconds").GetDouble() >= 0); + + AuctionDossier.Abandon(auction.AuctionId, "fine test"); + } + + [Fact] + public void Le_righe_RESET_del_registro_non_si_copiano_nel_dossier() + { + // Ripetevano parola per parola l'evento reset gia' scritto: una riga di log per + // ogni reset, e nessuna diceva qualcosa in piu'. + var auction = NewAuction(); + var dossier = AuctionDossier.OpenFor(auction, Settings())!; + + dossier.Log("info", "General", "[RESET #3] marco_82 → €1,25 | Timer: 8,0s"); + dossier.Log("info", "General", "qualcosa che invece conta"); + + var logs = LinesOf(dossier).Where(l => l.Contains("\"type\":\"log\"", StringComparison.Ordinal)).ToArray(); + + Assert.Single(logs); + Assert.Contains("qualcosa che invece conta", logs[0]); + + AuctionDossier.Abandon(auction.AuctionId, "fine test"); + } + [Fact] public void Ogni_riga_e_json_valido_per_conto_suo() { diff --git a/Mimante/Tests/MlModelBacktest.cs b/Mimante/Tests/MlModelBacktest.cs new file mode 100644 index 0000000..7957def --- /dev/null +++ b/Mimante/Tests/MlModelBacktest.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using AutoBidder.Ml; +using Xunit; +using Xunit.Abstractions; + +namespace AutoBidder.Tests; + +/// +/// Valuta il modello sui dossier veri, con una divisione onesta nel tempo: si impara dai +/// più vecchi e si giudica sui più recenti, che il modello non ha mai visto. Sul futuro, +/// non su ciò che ha già letto. +/// +/// Si attiva solo con le variabili d'ambiente, come la rigiocata: +/// +/// AUTOBIDDER_ML_DIR — cartella dei dossier; +/// AUTOBIDDER_ML_MAX — quanti file al massimo (0 = tutti); +/// AUTOBIDDER_ML_OUT — dove scrivere il rapporto. +/// +/// +/// Cosa misura. Quanto il modello separa le puntate rimaste senza risposta +/// dalle altre (sollevamento nella cima della classifica), se la probabilità è nella scala +/// giusta (calibrazione per fasce), e — sulle nostre puntate vere — cosa avrebbe fatto il +/// cancello del valore atteso: quante ne avrebbe fermate, e quante di quelle fermate erano +/// invece vincenti. +/// +public class MlModelBacktest +{ + private readonly ITestOutputHelper _output; + + public MlModelBacktest(ITestOutputHelper output) => _output = output; + + private sealed record Row(double P, bool Unanswered, bool Mine, double Ev); + + [Fact] + public void Valuta_il_modello_sui_dossier_reali() + { + var folder = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_DIR"); + + if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) + { + _output.WriteLine("AUTOBIDDER_ML_DIR non impostata o inesistente: valutazione saltata."); + return; + } + + var max = int.TryParse(Environment.GetEnvironmentVariable("AUTOBIDDER_ML_MAX"), out var m) ? m : 0; + + // Ordine per nome = ordine di data: i dossier cominciano con la data. + var files = Directory.GetFiles(folder, "*.jsonl").OrderBy(Path.GetFileName, StringComparer.Ordinal).ToList(); + if (max > 0 && files.Count > max) files = files.Skip(files.Count - max).ToList(); + + // Il nostro nome utente: i dossier scritti prima di oggi non lo portano + // nell'intestazione, e senza non si sa quali puntate erano nostre. + var me = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_ME"); + + var sessions = new List(); + foreach (var f in files) + { + var s = DossierTrainingReader.ReadFile(f, me); + if (s != null && s.IsUsable) sessions.Add(s); + } + + var report = new StringBuilder(); + report.AppendLine($"Dossier letti: {files.Count:N0}, utilizzabili: {sessions.Count:N0}"); + + if (sessions.Count < 20) + { + report.AppendLine("Troppo pochi dossier utilizzabili per una valutazione."); + _output.WriteLine(report.ToString()); + return; + } + + var split = (int)(sessions.Count * 0.7); + var train = sessions.Take(split).ToList(); + var test = sessions.Skip(split).ToList(); + + // Le variabili si costruiscono una volta sola: leggere i file è la parte lenta, + // addestrare è veloce, e così si possono confrontare più configurazioni. + var trainX = train.SelectMany(s => DossierTrainingReader.Examples(s)) + .Select(e => (X: BidFeatures.Build(e.Context), Y: e.Unanswered)).ToList(); + var testX = test.SelectMany(s => DossierTrainingReader.Examples(s).Select(e => (s, e))) + .Select(t => ( + X: BidFeatures.Build(t.e.Context), Y: t.e.Unanswered, Mine: t.e.Mine, + Margine: t.s.BuyNowPrice is > 0 ? t.s.BuyNowPrice.Value - t.e.Context.AuctionDepthCents / 100.0 - 0.01 : double.NaN)) + .ToList(); + + var n = testX.Count; + var positives = testX.Count(r => r.Y); + var baseRate = (double)positives / n; + + report.AppendLine($"Addestramento: {train.Count:N0} aste, {trainX.Count:N0} puntate"); + report.AppendLine($"Valutazione : {test.Count:N0} aste, {n:N0} puntate, {positives:N0} senza risposta ({baseRate:P3})"); + report.AppendLine(); + + // ── Confronto fra configurazioni ───────────────────────────────── + // + // Il modello deve separare (sollevamento) E stare nella scala giusta + // (calibrazione): la probabilità entra in un valore atteso in euro. Si prova una + // griglia piccola e si sceglie la configurazione meglio calibrata fra quelle che + // separano davvero. + const double costo = 0.20; + // Prima griglia (passo × passate × media): il passo 0,01 in una passata era il + // migliore, ma sovrastimava ancora di 1,6-1,8 volte e la media non aiutava. Il + // sospetto è la regolarizzazione: con centinaia di migliaia di passi il + // decadimento accumulato riduce i pesi a due terzi, e i pesi sono quasi tutti + // negativi, quindi le probabilità salgono. Seconda griglia: passo × regolarizzazione. + var configs = new List<(string Nome, double Lr, int Epochs, bool Avg, double L2)>(); + foreach (var lr in new[] { 0.01, 0.003 }) + foreach (var l2 in new[] { 1e-4, 1e-6, 0.0 }) + configs.Add(($"passo {lr:0.000}, L2 {l2:0.######}", lr, 1, false, l2)); + + report.AppendLine("Configurazione | sollev. 5% | prevista/osservata (cima 10%) | prevista/osservata (tutte)"); + report.AppendLine("-------------------------------------+------------+-------------------------------+---------------------------"); + + OnlineLogit? best = null; string bestName = ""; double bestScore = double.MaxValue; double bestLift = 0; + foreach (var (nome, lr, ep, avg, l2) in configs) + { + var modello = new OnlineLogit(BidFeatures.Size) { LearningRate = lr, UseAveraging = avg, L2 = l2 }; + for (var e = 0; e < ep; e++) foreach (var (x, y) in trainX) modello.Update(x, y); + + var ps = testX.Select(r => modello.Predict(r.X)).ToArray(); + var order = Enumerable.Range(0, n).OrderByDescending(i => ps[i]).ToArray(); + var k5 = Math.Max(1, n / 20); var k10 = Math.Max(1, n / 10); + var lift5 = order.Take(k5).Count(i => testX[i].Y) / (double)k5 / baseRate; + var top10 = order.Take(k10).ToArray(); + var ratioTop = top10.Average(i => ps[i]) / Math.Max(1e-9, top10.Average(i => testX[i].Y ? 1.0 : 0.0)); + var ratioAll = ps.Average() / baseRate; + + report.AppendLine($"{nome,-37} | {lift5,9:N2}x | {ratioTop,29:N2} | {ratioAll,25:N2}"); + + // Punteggio: distanza della scala da 1 (in logaritmo), solo se separa. + var score = Math.Abs(Math.Log(Math.Max(1e-9, ratioTop))) + Math.Abs(Math.Log(Math.Max(1e-9, ratioAll))); + if (lift5 > 2.0 && score < bestScore) { bestScore = score; best = modello; bestName = nome; bestLift = lift5; } + } + report.AppendLine(); + + if (best == null) + { + report.AppendLine("Nessuna configurazione separa abbastanza (sollevamento al 5% > 2x)."); + _output.WriteLine(report.ToString()); + Assert.Fail("nessuna configurazione separa"); + return; + } + + report.AppendLine($"Scelta: {bestName}"); + report.AppendLine(); + + // ── Valutazione prequenziale: come nell'uso vero ───────────────── + // + // Il modello in produzione è online: quando decide su un'asta ha già imparato da + // tutte quelle chiuse prima. Giudicarlo "congelato" sui vecchi e applicato ai nuovi + // gli attribuisce una deriva che nell'uso non subisce. Qui ogni asta di valutazione + // viene prima prevista, poi appresa, in ordine di tempo: è esattamente la sequenza + // del motore. Le previsioni sono quelle calibrate, come le vede il motore. + var model = best; + var rows = new List(); + var perAsta = test.Select(s => DossierTrainingReader.Examples(s) + .Select(e => ( + X: BidFeatures.Build(e.Context), Y: e.Unanswered, Mine: e.Mine, + Margine: s.BuyNowPrice is > 0 ? s.BuyNowPrice.Value - e.Context.AuctionDepthCents / 100.0 - 0.01 : double.NaN)) + .ToList()).ToList(); + + foreach (var esempi in perAsta) + { + foreach (var r in esempi) + { + var p = model.PredictCalibrated(r.X); + rows.Add(new Row(p, r.Y, r.Mine, double.IsNaN(r.Margine) ? double.NaN : p * r.Margine - costo)); + } + foreach (var r in esempi) model.Update(r.X, r.Y); + } + + report.AppendLine($"Valutazione prequenziale (prevista poi appresa, asta per asta), fattore di calibrazione finale {model.CalibrationFactor:N2}:"); + + var meanPos = rows.Where(r => r.Unanswered).Average(r => r.P); + var meanNeg = rows.Where(r => !r.Unanswered).Average(r => r.P); + report.AppendLine($"P media sulle puntate senza risposta : {meanPos:P3}"); + report.AppendLine($"P media sulle altre : {meanNeg:P3}"); + report.AppendLine($"rapporto : {meanPos / Math.Max(meanNeg, 1e-9):N2}x"); + report.AppendLine(); + + // Sollevamento: fra le puntate a probabilità più alta, quante erano davvero finali. + var sorted = rows.OrderByDescending(r => r.P).ToList(); + report.AppendLine("Sollevamento nella cima della classifica (precisione / tasso di base):"); + double liftTop5 = 0; + foreach (var frac in new[] { 0.01, 0.05, 0.10, 0.25 }) + { + var k = Math.Max(1, (int)(n * frac)); + var top = sorted.Take(k); + var prec = top.Count(r => r.Unanswered) / (double)k; + var lift = prec / baseRate; + if (frac == 0.05) liftTop5 = lift; + report.AppendLine($" cima {frac:P0}: {k,8:N0} puntate, precisione {prec:P2}, sollevamento {lift:N2}x"); + } + report.AppendLine(); + + // Calibrazione: la probabilità prevista deve somigliare a quella osservata. + report.AppendLine("Calibrazione per fasce di probabilità prevista:"); + var edges = new[] { 0.0, 0.001, 0.0025, 0.005, 0.01, 0.02, 0.05, 1.0 }; + for (var i = 0; i < edges.Length - 1; i++) + { + var bin = rows.Where(r => r.P >= edges[i] && r.P < edges[i + 1]).ToList(); + if (bin.Count == 0) continue; + report.AppendLine($" P in [{edges[i]:P2}, {edges[i + 1]:P2}): {bin.Count,8:N0} puntate, prevista {bin.Average(r => r.P):P3}, osservata {bin.Average(r => r.Unanswered ? 1.0 : 0.0):P3}"); + } + report.AppendLine(); + + // Le nostre puntate vere: cosa avrebbe fatto il cancello. + var mie = rows.Where(r => r.Mine && !double.IsNaN(r.Ev)).ToList(); + if (mie.Count > 0) + { + var passate = mie.Where(r => r.Ev >= 0).ToList(); + var fermate = mie.Where(r => r.Ev < 0).ToList(); + report.AppendLine($"Le mie puntate nel periodo di valutazione: {mie.Count:N0}, di cui finali {mie.Count(r => r.Unanswered):N0}"); + report.AppendLine($" il cancello ne avrebbe lasciate passare {passate.Count:N0} (finali: {passate.Count(r => r.Unanswered):N0})"); + report.AppendLine($" e ne avrebbe fermate {fermate.Count:N0} (di cui finali, cioè vincenti perse: {fermate.Count(r => r.Unanswered):N0})"); + } + else + { + report.AppendLine("Nessuna mia puntata nel periodo di valutazione."); + } + report.AppendLine(); + + report.AppendLine("Pesi più forti (cosa ha imparato):"); + foreach (var (nome, peso) in BidFeatures.Names.Select((nm, i) => (nm, model.Weights[i])).OrderByDescending(t => Math.Abs(t.Item2)).Take(15)) + report.AppendLine($" {peso,+8:F3} {nome}"); + + var text = report.ToString(); + _output.WriteLine(text); + + var outFile = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_OUT"); + if (!string.IsNullOrWhiteSpace(outFile)) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outFile))!); + File.WriteAllText(outFile, text); + } + + // La condizione minima perché il modello serva a qualcosa: nella cima della + // classifica le puntate finali devono essere più frequenti che a caso. + Assert.True(liftTop5 > 1.0, $"il modello non separa: sollevamento al 5% = {liftTop5:N2}"); + } +} diff --git a/Mimante/Tests/MlTests.cs b/Mimante/Tests/MlTests.cs new file mode 100644 index 0000000..1237462 --- /dev/null +++ b/Mimante/Tests/MlTests.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutoBidder.Ml; +using Xunit; + +namespace AutoBidder.Tests; + +/// +/// Il modello impara un esempio alla volta e deve restare calibrato: la probabilità +/// che produce viene moltiplicata per euro, quindi la scala conta quanto l'ordine. +/// +public class OnlineLogitTests +{ + [Fact] + public void Impara_una_regola_semplice() + { + var m = new OnlineLogit(2); + var rnd = new Random(7); + + // Positivo se e solo se la prima variabile e' accesa. Col passo di serie (0,01, + // scelto sui dossier veri) servono piu' esempi che con uno grande: qui conta che + // impari, non quanto in fretta. + for (var i = 0; i < 20000; i++) + { + var a = rnd.Next(2) == 1; + var x = new[] { a ? 1.0 : 0.0, rnd.Next(2) }; + m.Update(x, a); + } + + Assert.True(m.Predict(new[] { 1.0, 0.0 }) > 0.9); + Assert.True(m.Predict(new[] { 0.0, 1.0 }) < 0.1); + } + + [Fact] + public void Resta_calibrato_su_una_classe_rara() + { + // Positivi al 2%: la probabilita' media prevista deve stare vicino al 2%, + // non gonfiata verso il 50%. E' quello che rende usabile il valore atteso. + var m = new OnlineLogit(1); + var rnd = new Random(3); + + for (var i = 0; i < 40000; i++) m.Update(new[] { 1.0 }, rnd.NextDouble() < 0.02); + + var p = m.Predict(new[] { 1.0 }); + Assert.InRange(p, 0.01, 0.04); + } + + [Fact] + public void Si_salva_e_si_rilegge_uguale() + { + var m = new OnlineLogit(3); + m.Update(new[] { 1.0, 0.0, 1.0 }, true); + m.Update(new[] { 0.0, 1.0, 0.0 }, false); + + var back = OnlineLogit.FromJson(m.ToJson(), 3); + + Assert.Equal(m.Weights, back.Weights); + Assert.Equal(m.Updates, back.Updates); + Assert.Equal(m.CalibrationFactor, back.CalibrationFactor, 9); + } + + [Fact] + public void I_parametri_vengono_dal_codice_non_dal_file() + { + // Il passo e' una scelta tarata sui dossier: cambiarla nel codice deve valere dal + // riavvio successivo, non restare inchiodata al valore di quando il file nacque. + var m = new OnlineLogit(2) { LearningRate = 0.5, L2 = 0.1, UseAveraging = true }; + m.Update(new[] { 1.0, 0.0 }, true); + + var back = OnlineLogit.FromJson(m.ToJson(), 2); + var fresco = new OnlineLogit(2); + + Assert.Equal(fresco.LearningRate, back.LearningRate); + Assert.Equal(fresco.L2, back.L2); + Assert.Equal(fresco.UseAveraging, back.UseAveraging); + } + + [Fact] + public void Con_un_numero_diverso_di_variabili_si_riparte_da_zero() + { + // Pesi riferiti a variabili diverse non sono un modello, sono rumore. + var m = new OnlineLogit(3); + m.Update(new[] { 1.0, 1.0, 1.0 }, true); + + var back = OnlineLogit.FromJson(m.ToJson(), 5); + + Assert.Equal(5, back.FeatureCount); + Assert.Equal(0, back.Updates); + } +} + +/// +/// Le variabili sono a fasce e ogni gruppo accende esattamente una casella: un vettore +/// mal formato farebbe imparare al modello relazioni che non esistono. +/// +public class BidFeaturesTests +{ + private static BidContext Ctx( + double gap = 7, double auto = 0.8, int bidders = 3, double? ratio = 0.05, + int hour = 12, DayOfWeek day = DayOfWeek.Tuesday, int open = 11, bool isAuto = false, + int sinceManual = 2, int depth = 120, bool same = false, string key = "50 bidoo shop") => + new(gap, auto, bidders, ratio, hour, day, open, isAuto, sinceManual, depth, same, key); + + [Fact] + public void Dimensione_e_nomi_coincidono() + { + Assert.Equal(BidFeatures.Size, BidFeatures.Names.Length); + Assert.Equal(BidFeatures.Size, BidFeatures.Build(Ctx()).Length); + } + + [Fact] + public void Ogni_gruppo_a_fasce_accende_una_sola_casella() + { + var x = BidFeatures.Build(Ctx()); + var n = BidFeatures.Names; + + int Accese(string prefisso) => n.Select((name, i) => (name, i)) + .Count(t => t.name.StartsWith(prefisso, StringComparison.Ordinal) && x[t.i] == 1); + + Assert.Equal(1, Accese("ciclo")); + Assert.Equal(1, Accese("puntatori")); + Assert.Equal(1, Accese("prezzo") + Accese("valoreIgnoto")); + Assert.Equal(1, Accese("ora")); + Assert.Equal(1, Accese("feriale") + Accese("festivo")); + Assert.Equal(1, Accese("manuale") + Accese("automatica")); + Assert.Equal(1, Accese("auto di fila")); + Assert.Equal(1, Accese("profondità")); + Assert.Equal(1, Accese("aggancio")); + Assert.Equal(1, Accese("prodotto#")); + } + + [Theory] + [InlineData(0, 0)] [InlineData(4, 0)] [InlineData(8, 0)] + [InlineData(9, 1)] [InlineData(10, 2)] [InlineData(12, 2)] + [InlineData(13, 3)] [InlineData(17, 3)] [InlineData(18, 4)] + [InlineData(20, 4)] [InlineData(21, 5)] [InlineData(23, 5)] + public void Le_fasce_orarie_ricalcano_la_curva_misurata(int ora, int fascia) + { + Assert.Equal(fascia, BidFeatures.HourBand(ora)); + } + + [Fact] + public void Il_cassetto_del_prodotto_e_stabile_e_in_scala() + { + var a = BidFeatures.ProductBucket("50 Bidoo Shop"); + var b = BidFeatures.ProductBucket("50 bidoo shop"); // maiuscole indifferenti + + Assert.Equal(a, b); + Assert.InRange(a, 0, 31); + + // Un hash stabile deve dare sempre lo stesso numero, avvio dopo avvio: qui si + // fissa il valore atteso per accorgersi se qualcuno cambia la funzione. + Assert.Equal(BidFeatures.ProductBucket("prova"), BidFeatures.ProductBucket("prova")); + } + + [Fact] + public void Il_valore_ignoto_e_il_ciclo_ignoto_hanno_una_casella_propria() + { + var x = BidFeatures.Build(Ctx(ratio: null, gap: -1, auto: -1)); + var n = BidFeatures.Names; + + Assert.Equal(1, x[Array.IndexOf(n, "valoreIgnoto")]); + Assert.Equal(1, x[Array.IndexOf(n, "quotaAutoIgnota")]); + Assert.Equal(0, n.Select((name, i) => (name, i)).Where(t => t.name.StartsWith("ciclo")).Sum(t => x[t.i])); + } +} + +/// +/// Il profilo per prodotto e ora deve restringersi verso il livello sopra quando i dati +/// sono pochi: una media su tre aste non e' una stima, e' un aneddoto. +/// +public class AuctionProfileStatsTests +{ + private static readonly DateTime Lun12 = new(2026, 9, 7, 12, 0, 0); // lunedi', fascia 10-12 + private static readonly DateTime Sab21 = new(2026, 9, 12, 21, 0, 0); // sabato, fascia 21-23 + + [Fact] + public void Con_pochi_esempi_la_stima_resta_vicina_al_globale() + { + var s = new AuctionProfileStats(); + for (var i = 0; i < 200; i++) s.Observe("altro", Lun12, 25, 0.05); // globale: 25 puntate + s.Observe("raro", Sab21, 100, 0.30); // un solo esempio, estremo + + var e = s.Expect("raro", Sab21)!.Value; + + // Un esempio contro k=10: peso 1/11 verso il 100, il resto verso il 25. + Assert.InRange(e.WinnerBids, 25, 40); + Assert.Equal(1, e.ExactSupport); + } + + [Fact] + public void Con_molti_esempi_la_combinazione_esatta_domina() + { + var s = new AuctionProfileStats(); + for (var i = 0; i < 200; i++) s.Observe("altro", Lun12, 25, 0.05); + for (var i = 0; i < 200; i++) s.Observe("caro", Sab21, 100, 0.30); + + var e = s.Expect("caro", Sab21)!.Value; + + Assert.InRange(e.WinnerBids, 90, 100); + Assert.InRange(e.CloseRatio, 0.27, 0.30); + } + + [Fact] + public void Il_prodotto_pesa_anche_in_una_fascia_mai_vista() + { + var s = new AuctionProfileStats(); + for (var i = 0; i < 200; i++) s.Observe("altro", Lun12, 25, 0.05); + for (var i = 0; i < 100; i++) s.Observe("caro", Lun12, 100, 0.30); + + // Stesso prodotto, fascia diversa: niente esempi esatti, ma il prodotto parla. + var e = s.Expect("caro", Sab21)!.Value; + + Assert.True(e.WinnerBids > 60, $"attese >60, ottenute {e.WinnerBids:F1}"); + Assert.Equal(0, e.ExactSupport); + Assert.Equal(100, e.ProductSupport); + } + + [Fact] + public void Senza_niente_di_appreso_non_si_inventa() + { + Assert.Null(new AuctionProfileStats().Expect("x", Lun12)); + } + + [Fact] + public void Si_salva_e_si_rilegge() + { + var s = new AuctionProfileStats(); + for (var i = 0; i < 30; i++) s.Observe("p", Lun12, 40, 0.10); + + var back = AuctionProfileStats.FromJson(s.ToJson()); + + Assert.Equal(s.AuctionsObserved, back.AuctionsObserved); + Assert.Equal(s.Expect("p", Lun12)!.Value.WinnerBids, back.Expect("p", Lun12)!.Value.WinnerBids, 6); + } +} + +/// +/// Il lettore per l'addestramento deve etichettare bene e rifiutare i dossier con un buco +/// in fondo: un'etichetta sbagliata proprio sull'ultima puntata insegnerebbe il contrario. +/// +public class DossierTrainingReaderTests +{ + private static string Bid(double t, string user, string at, string type, double price, bool mine = false) => + $"{{\"t\":{t.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"type\":\"bid\",\"at\":\"x\",\"user\":\"{user}\",\"bidAt\":\"{at}\",\"bidType\":\"{type}\",\"mine\":{(mine ? "true" : "false")},\"price\":{price.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}"; + + private static IEnumerable Dossier(string winner, bool conPoll = true) + { + yield return "{\"type\":\"header\",\"auctionId\":\"1\",\"name\":\"Prova\",\"productKey\":\"prova\",\"addedAt\":\"2026-09-07T11:00:00+02:00\",\"product\":{\"buyNowPrice\":10}}"; + if (conPoll) for (var i = 0; i < 50; i++) yield return "{\"t\":1,\"type\":\"poll\",\"price\":0.01,\"timer\":5}"; + yield return Bid(1, "anna", "2026-09-07T11:00:00+02:00", "Auto", 0.01); + yield return Bid(2, "bruno", "2026-09-07T11:00:06+02:00", "Auto", 0.02); + yield return Bid(3, "anna", "2026-09-07T11:00:12+02:00", "Manuale", 0.03); + yield return Bid(4, "carla", "2026-09-07T11:00:21+02:00", "Manuale", 0.04, mine: true); + yield return "{\"type\":\"log\",\"msg\":\"[RESET #1] anna\"}"; + yield return $"{{\"type\":\"summary\",\"winner\":\"{winner}\",\"finalPrice\":0.04,\"wonByMe\":false}}"; + } + + [Fact] + public void Salta_i_poll_e_legge_le_puntate_col_tipo() + { + var s = DossierTrainingReader.Read(Dossier("carla")); + + Assert.Equal(4, s.Bids.Count); + Assert.Equal("Manuale", s.Bids[2].BidType); + Assert.True(s.Bids[3].Mine); + Assert.Equal(10, s.BuyNowPrice); + Assert.True(s.IsUsable); + } + + [Fact] + public void Un_dossier_con_un_buco_in_fondo_non_si_usa() + { + // L'ultima puntata registrata non e' del vincitore: manca qualcosa alla fine, e + // l'etichetta "senza risposta" andrebbe sulla puntata sbagliata. + var s = DossierTrainingReader.Read(Dossier("qualcunaltro")); + + Assert.False(s.IsUsable); + } + + [Fact] + public void Gli_esempi_sono_uno_per_puntata_tranne_la_prima_e_solo_l_ultimo_e_positivo() + { + var s = DossierTrainingReader.Read(Dossier("carla")); + var ex = DossierTrainingReader.Examples(s).ToList(); + + Assert.Equal(3, ex.Count); + Assert.Equal(new[] { false, false, true }, ex.Select(e => e.Unanswered).ToArray()); + Assert.True(ex[2].Mine); + } + + [Fact] + public void Il_ciclo_si_misura_dal_tempo_dichiarato_dal_server() + { + var s = DossierTrainingReader.Read(Dossier("carla")); + var ex = DossierTrainingReader.Examples(s).ToList(); + + Assert.Equal(6, ex[0].Context.CycleSeconds); // 11:00:06 - 11:00:00 + Assert.Equal(9, ex[2].Context.CycleSeconds); // 11:00:21 - 11:00:12 + Assert.Equal(11, ex[0].Context.Hour); + Assert.Equal(DayOfWeek.Monday, ex[0].Context.Day); + } + + [Fact] + public void Le_finestre_guardano_solo_le_puntate_precedenti() + { + var s = DossierTrainingReader.Read(Dossier("carla")); + var ex = DossierTrainingReader.Examples(s).ToList(); + + // Terza puntata (anna, manuale): prima di lei due automatiche -> quota 1.0. + Assert.Equal(1.0, ex[1].Context.AutoShareRecent, 6); + Assert.False(ex[1].Context.IsAutoBid); + // Quarta puntata: prima di lei anna (manuale) -> zero automatiche di fila. + Assert.Equal(0, ex[2].Context.CyclesSinceManual); + Assert.Equal(2, ex[2].Context.DistinctBiddersRecent); // anna, bruno visti prima di carla + } +} diff --git a/Mimante/Tests/RuntimeProfileTests.cs b/Mimante/Tests/RuntimeProfileTests.cs deleted file mode 100644 index ab7cfe5..0000000 --- a/Mimante/Tests/RuntimeProfileTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using AutoBidder.Utilities; -using Xunit; - -namespace AutoBidder.Tests; - -/// -/// Modalita' della sessione. Due proprieta' da difendere: che Gara tolga esattamente il -/// lavoro dichiarato e nient'altro, e che la scelta sia immutabile per tutta la vita del -/// processo — un salvataggio a meta' sessione non deve cambiare il motore sotto i piedi. -/// -public class RuntimeProfileTests -{ - [Fact] - public void Addestramento_registra_tutto() - { - RuntimeProfile.ResetForTests(); - RuntimeProfile.Initialize(RuntimeProfile.Addestramento); - - Assert.False(RuntimeProfile.IsRace); - Assert.True(RuntimeProfile.RawPolls); - Assert.True(RuntimeProfile.VerboseAuctionLog); - Assert.True(RuntimeProfile.DetailedStats); - } - - [Fact] - public void Gara_toglie_solo_il_lavoro_dichiarato() - { - RuntimeProfile.ResetForTests(); - RuntimeProfile.Initialize(RuntimeProfile.Gara); - - Assert.True(RuntimeProfile.IsRace); - Assert.False(RuntimeProfile.RawPolls); - Assert.False(RuntimeProfile.VerboseAuctionLog); - Assert.False(RuntimeProfile.DetailedStats); - } - - [Fact] - public void La_prima_scelta_vince_per_tutta_la_sessione() - { - RuntimeProfile.ResetForTests(); - RuntimeProfile.Initialize(RuntimeProfile.Gara); - - // Un salvataggio delle impostazioni a meta' sessione passa di qui: non deve - // cambiare niente fino al riavvio. - RuntimeProfile.Initialize(RuntimeProfile.Addestramento); - - Assert.True(RuntimeProfile.IsRace); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("qualcosa")] - [InlineData("gara")] - public void Un_valore_ignoto_vale_Addestramento_e_le_maiuscole_non_contano(string? valore) - { - RuntimeProfile.ResetForTests(); - RuntimeProfile.Initialize(valore); - - // "gara" minuscolo e' Gara: un file modificato a mano non deve spegnere la scelta. - var atteso = string.Equals(valore, "gara", System.StringComparison.OrdinalIgnoreCase); - Assert.Equal(atteso, RuntimeProfile.IsRace); - } - - [Fact] - public void Il_predefinito_di_fabbrica_e_Addestramento() - { - RuntimeProfile.ResetForTests(); - Assert.Equal(RuntimeProfile.Addestramento, new AppSettings().SessionMode); - Assert.False(RuntimeProfile.IsRace); - } -} diff --git a/Mimante/Utilities/AuctionDossier.cs b/Mimante/Utilities/AuctionDossier.cs index a3fcbc1..a2deb92 100644 --- a/Mimante/Utilities/AuctionDossier.cs +++ b/Mimante/Utilities/AuctionDossier.cs @@ -54,6 +54,27 @@ namespace AutoBidder.Utilities private long _events; private bool _closed; + // ── Coalescenza dei poll ───────────────────────────────────────── + // + // Con il tetto delle richieste tolto arrivano ~17 interrogazioni al secondo per + // asta, e su 40 dossier veri l'88,8% ha la stessa faccia della precedente: stesso + // prezzo, stesso puntatore, stesso stato, stesso secondo sul cronometro. Scriverle + // tutte faceva dossier da 30 MB per una sola asta, e 8,8 GB in tutto — di cui nove + // decimi ripetizioni. Si scrive quando cambia qualcosa, e comunque una volta al + // secondo perché il ping resti misurabile. Ogni cambio di secondo e ogni reset + // vengono scritti: la profondità di ogni ciclo resta ricostruibile esattamente. + private string? _lastPollSignature; + private DateTime _lastPollWrittenAt = DateTime.MinValue; + + // Quanto è sceso il timer nel ciclo appena chiuso e quanto è durato: sono i due + // numeri che l'apprendimento vuole da un reset, e si ricavano solo qui, dove + // passano tutti i poll. + private double _cycleMinTimer = double.MaxValue; + private double _cycleStartedElapsed = -1; + private double _completedCycleMinTimer = double.NaN; + private double _completedCycleSeconds = double.NaN; + private double _lastPollPrice = double.NaN; + private AuctionDossier(string path, DateTime startedAt) { _writer = FileLogWriter.For(path); @@ -62,6 +83,14 @@ namespace AutoBidder.Utilities public string Path => _writer.Path; + /// + /// Il nostro nome utente su Bidoo, scritto nell'intestazione di ogni dossier aperto + /// da qui in poi. Lo imposta il motore appena conosce la sessione. Serve a chi + /// rilegge il file per sapere quali puntate erano nostre senza dipendere da niente + /// di esterno al file. + /// + public static volatile string CurrentUsername = ""; + /// Righe scritte finora, intestazione esclusa. public long EventCount => _events; @@ -173,6 +202,7 @@ namespace AutoBidder.Utilities schema = "autobidder.auction.v1", app = AppInfo.Version, auctionId = auction.AuctionId, + me = string.IsNullOrEmpty(CurrentUsername) ? null : CurrentUsername, name = auction.Name, productKey = ProductKeyHelper.GenerateProductKey(auction.Name), url = auction.OriginalUrl, @@ -321,10 +351,39 @@ namespace AutoBidder.Utilities lock (_sync) { if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs); + + // Un cambio di prezzo è un reset visto da qui: il ciclo che finisce va + // consegnato a Reset() con il suo minimo e la sua durata. + var now = Elapsed(); + if (!double.IsNaN(_lastPollPrice) && state.Price > _lastPollPrice) + { + _completedCycleMinTimer = _cycleMinTimer == double.MaxValue ? double.NaN : _cycleMinTimer; + _completedCycleSeconds = _cycleStartedElapsed >= 0 ? now - _cycleStartedElapsed : double.NaN; + _cycleMinTimer = double.MaxValue; + _cycleStartedElapsed = now; + } + else if (_cycleStartedElapsed < 0) + { + _cycleStartedElapsed = now; + } + _lastPollPrice = state.Price; + + if (state.Timer > 0 && state.Timer < _cycleMinTimer) _cycleMinTimer = state.Timer; } if (!includeRaw) return; + var signature = string.Concat( + state.Price.ToString("F2"), '|', state.LastBidder, '|', state.Status, '|', + (int)state.Timer, '|', state.IsMyBid ? '1' : '0'); + + var wall = DateTime.Now; + if (signature == _lastPollSignature && (wall - _lastPollWrittenAt).TotalMilliseconds < 1000) + return; + + _lastPollSignature = signature; + _lastPollWrittenAt = wall; + Write(new { t = Elapsed(), @@ -399,6 +458,15 @@ namespace AutoBidder.Utilities /// Il timer è stato azzerato da una puntata: l'asta continua. public void Reset(int resetCount, double price, string? bidder) { + double? minTimer, cycleSeconds; + lock (_sync) + { + minTimer = double.IsNaN(_completedCycleMinTimer) ? null : Math.Round(_completedCycleMinTimer, 2); + cycleSeconds = double.IsNaN(_completedCycleSeconds) ? null : Math.Round(_completedCycleSeconds, 2); + _completedCycleMinTimer = double.NaN; + _completedCycleSeconds = double.NaN; + } + Write(new { t = Elapsed(), @@ -406,13 +474,24 @@ namespace AutoBidder.Utilities at = Now(), resetCount, price, - bidder + bidder, + + // Del ciclo appena chiuso: fin dove è sceso il cronometro prima che + // qualcuno puntasse, e quanto è durato. È il dato con cui si ricostruisce + // a che punto del conto alla rovescia la gente punta davvero. + minTimer, + cycleSeconds }); } /// Una riga del registro dell'asta (strategie, avvisi, errori). public void Log(string level, string category, string message) { + // Le righe "[RESET #n] utente → prezzo" ripetono parola per parola l'evento + // reset che sta già nel dossier: nel campione erano una riga di log ogni reset, + // e nessuna diceva qualcosa in più. Restano nel registro a video. + if (message != null && message.StartsWith("[RESET #", StringComparison.Ordinal)) return; + Write(new { t = Elapsed(), diff --git a/Mimante/Utilities/RuntimeProfile.cs b/Mimante/Utilities/RuntimeProfile.cs deleted file mode 100644 index c44e8d2..0000000 --- a/Mimante/Utilities/RuntimeProfile.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; - -namespace AutoBidder.Utilities -{ - /// - /// La modalità della sessione, letta una volta all'avvio e immutabile fino alla chiusura. - /// - /// Addestramento registra tutto: ogni interrogazione nel dossier, ogni - /// decisione della strategia nel registro dell'asta, il dettaglio esteso delle aste - /// concluse. È la modalità con cui si raccolgono i dati che poi servono a tarare le - /// regole — e i dossier sono la materia prima di ogni analisi fatta finora. - /// - /// Gara fa solo il lavoro che serve a puntare. Quello che toglie: - /// - /// la riga JSON per ogni interrogazione nel dossier — a 17 poll al secondo per - /// asta un'asta lunga vale decine di megabyte; - /// le righe di registro informative («✓ prezzo OK» e simili), composte a ogni - /// giro prima ancora di sapere se qualcuno le leggerà; - /// il dettaglio esteso di ogni asta conclusa. - /// - /// Restano puntate, esiti, errori, lo storico delle aste concluse e tutto ciò che - /// alimenta le decisioni — il riconoscimento del duello con l'autopuntata compreso, che - /// vive sulle puntate avversarie e non sui poll. - /// - /// Cosa aspettarsi, misurato. Il lavoro che Gara evita costa 4,9 µs per - /// interrogazione (3,3 la riga JSON, 1,6 le sei stringhe di registro). A 57 aste per - /// 17 poll al secondo sono 4,7 ms di CPU al secondo: mezzo punto percentuale di un - /// core. Il guadagno vero di Gara è sul disco e sulla leggibilità dei dossier, non - /// sulla velocità: la scrittura era già asincrona, e la CPU spesa a prepararla è - /// trascurabile. Se il programma è lento, la causa sta altrove. - /// - /// Perché immutabile. Scelta esplicita dell'utente: la modalità vale per - /// tutta la sessione e cambiarla richiede il riavvio. Un interruttore a caldo - /// funzionerebbe uguale, ma una sessione tutta di un tipo ha un pregio suo — i dossier - /// che ne escono sono confrontabili fra loro, senza buchi a metà. - /// - public static class RuntimeProfile - { - public const string Addestramento = "Addestramento"; - public const string Gara = "Gara"; - - private static string? _mode; - - /// La modalità in vigore. Se nessuno l'ha fissata, Addestramento. - public static string Mode => _mode ?? Addestramento; - - public static bool IsRace => string.Equals(Mode, Gara, StringComparison.OrdinalIgnoreCase); - - /// - /// Fissa la modalità per tutta la vita del processo. La prima chiamata vince: le - /// successive sono ignorate, così un salvataggio delle impostazioni a metà sessione - /// non cambia il comportamento sotto i piedi del motore. - /// - public static void Initialize(string? mode) - { - if (_mode != null) return; - _mode = Normalize(mode); - } - - /// Il nome canonico di una modalità; qualunque altra cosa vale Addestramento. - public static string Normalize(string? mode) => - string.Equals(mode, Gara, StringComparison.OrdinalIgnoreCase) ? Gara : Addestramento; - - /// Solo per i test: azzera la scelta. - public static void ResetForTests() => _mode = null; - - // ── Cosa fa ciascuna modalità ──────────────────────────────────── - - /// Scrivere nel dossier una riga per ogni interrogazione. - public static bool RawPolls => !IsRace; - - /// Comporre le righe di registro informative («✓ prezzo OK» e simili). - public static bool VerboseAuctionLog => !IsRace; - - /// Scrivere il dettaglio esteso di ogni asta conclusa. - public static bool DetailedStats => !IsRace; - - /// Una riga per il registro d'avvio. - public static string Describe() => IsRace - ? "Gara: niente poll grezzi nel dossier, niente registro informativo, niente dettaglio esteso" - : "Addestramento: registrazione completa"; - } -} diff --git a/Mimante/Utilities/SettingsManager.cs b/Mimante/Utilities/SettingsManager.cs index 559f9f9..5548fee 100644 --- a/Mimante/Utilities/SettingsManager.cs +++ b/Mimante/Utilities/SettingsManager.cs @@ -565,12 +565,34 @@ namespace AutoBidder.Utilities // Vedi BiddingHours per i numeri: alle 0 e alle 9 la stessa asta costa quasi il // doppio che fra le 10 e le 13. + // ── Apprendimento ──────────────────────────────────────────────── + // Vedi Ml/LearningService. Sempre acceso: impara da ogni asta chiusa. + /// - /// Modalità della sessione: Addestramento registra tutto, Gara fa solo - /// il lavoro che serve a puntare. Letta una volta all'avvio: cambiarla richiede il - /// riavvio. Vedi per cosa cambia e per quanto pesa. + /// Lasciar fermare una puntata al modello quando il valore atteso è negativo. + /// Spento, il modello parla nel registro ma non decide. /// - public string SessionMode { get; set; } = RuntimeProfile.Addestramento; + public bool LearningGateEnabled { get; set; } = true; + + /// + /// Aste apprese sotto le quali il modello non ferma niente. Un modello appena nato + /// direbbe cose a caso, e a caso fermerebbe le puntate. Predefinito 300. + /// + public int LearningMinAuctions { get; set; } = 300; + + /// + /// Moltiplica il costo della puntata nel confronto col valore atteso: 1 è il + /// valore atteso puro, 2 pretende che una puntata renda il doppio del suo costo. + /// Più alto = più selettivo = meno puntate. + /// + public double LearningEvMultiplier { get; set; } = 1.0; + + /// + /// Secondi per avvio dedicati a leggere i dossier non ancora appresi, in + /// sottofondo. La prima volta ne servono diversi avvii; poi resta solo l'asta + /// appena chiusa. + /// + public int LearningBootstrapSecondsPerStart { get; set; } = 120; // ── Rimozione automatica delle aste concluse ───────────────────── // Vedi FinishedAuctionCleanup: si toglie di serie, si trattiene cio' su cui c'e' diff --git a/Mimante/build/Release.proj b/Mimante/build/Release.proj index e2ea1de..f9162f5 100644 --- a/Mimante/build/Release.proj +++ b/Mimante/build/Release.proj @@ -421,6 +421,37 @@ + + + + + + $(LOCALAPPDATA)\AutoBidder\Dati\Registri\Aste + 0 + $(Radice)\bin\apprendimento-report.txt + + + + + + + + + + + + + +