using System; using System.Collections.Generic; using System.Linq; using AutoBidder.Models; using AutoBidder.Services; using AutoBidder.Utilities; namespace AutoBidder.Engine.Backtest { /// /// Rigioca un'asta già conclusa e conta cosa avrebbe fatto il motore. /// /// Cosa misura davvero. Il motore punta soltanto nei cicli che arrivano /// fino all'anticipo impostato senza che nessun altro abbia puntato: se un avversario /// punta prima, la scadenza si sposta e il cecchino non spara. Il dossier registra, /// per ogni ciclo, quanto è sceso il timer prima che qualcuno intervenisse — che è /// esattamente il dato che serve. Da lì si ricava quante puntate sarebbero state /// spese e quante volte una strategia le avrebbe bloccate. /// /// Cosa non può misurare, e perché va detto. Non dice se avresti vinto. /// Una nostra puntata rimette in gioco l'asta, e come avrebbero reagito gli avversari /// non sta in nessun registro: nessuna rigiocata può inventarselo. L'ultimo ciclo di /// ogni asta arriva per forza a zero — è il motivo per cui l'asta è finita — quindi /// contarlo come vittoria sarebbe una conclusione costruita a tavolino. Qui si contano /// i costi e i blocchi, che sono osservabili; l'esito no. /// public static class BacktestRunner { /// Parametri della rigiocata. public sealed record Options( int LeadMs, AppSettings Settings, string Username = "", double BidCostEuro = 0.20) { /// /// Le impostazioni come le vede la rigiocata. /// /// Spegne la sospensione a fascia oraria, che guarda l'orologio di parete: /// una rigiocata ripercorre aste già concluse, e l'ora in cui viene lanciata non /// c'entra nulla con l'ora in cui quelle aste correvano. Lasciarla accesa /// renderebbe il risultato dipendente da quando si preme il pulsante — di notte /// il motore rifiuterebbe ogni puntata e la rigiocata direbbe zero, che è /// esattamente il tipo di risultato che sembra vero e non lo è. /// /// Vale anche per la suite di test, che gira a qualunque ora. /// public AppSettings EffectiveSettings { get; } = Clona(Settings); private static AppSettings Clona(AppSettings origine) { var copia = origine ?? new AppSettings(); // Copia superficiale via serializzazione: le impostazioni sono un sacco di // proprietà semplici, e mutare quella dell'utente sarebbe un effetto // collaterale che nessuno si aspetta da una rigiocata. var json = System.Text.Json.JsonSerializer.Serialize(copia); var clone = System.Text.Json.JsonSerializer.Deserialize(json) ?? new AppSettings(); clone.QuietHoursEnabled = false; return clone; } } /// Esito su una singola asta. public sealed class Result { public string AuctionId { get; init; } = ""; public string Name { get; init; } = ""; public string ProductKey { get; init; } = ""; /// Cicli di timer osservati nel dossier. public int Cycles { get; init; } /// Cicli arrivati fino all'anticipo: qui il motore avrebbe sparato. public int Reached { get; init; } /// Puntate davvero inviate, cioè i cicli raggiunti che nessuna strategia ha fermato. public int Bids { get; init; } /// Blocchi per motivo, con il testo che avrebbe scritto la strategia. public Dictionary Blocks { get; init; } = new(StringComparer.Ordinal); public double FinalPrice { get; init; } public double? BuyNowPrice { get; init; } /// Costo se l'asta fosse stata vinta all'ultima puntata: prezzo + puntate spese. public double CostIfWon { get; init; } public int BlockedTotal => Blocks.Values.Sum(); } /// Motivi di blocco riconosciuti, per raggrupparli senza dipendere dal testo esatto. public static string Categorize(string? reason) { if (string.IsNullOrEmpty(reason)) return "altro"; if (reason.StartsWith("autopuntata avversaria", StringComparison.OrdinalIgnoreCase)) return "duello"; if (reason.StartsWith("puntata saltata: fascia", StringComparison.OrdinalIgnoreCase)) return "fascia-oraria"; if (reason.StartsWith("valore atteso", StringComparison.OrdinalIgnoreCase)) return "valore-atteso"; if (reason.StartsWith("lascio sfogare", StringComparison.OrdinalIgnoreCase)) return "sfogo"; if (reason.StartsWith("puntata di prova", StringComparison.OrdinalIgnoreCase)) return "sondaggio"; if (reason.StartsWith("Limite puntate", StringComparison.OrdinalIgnoreCase)) return "limite-puntate"; if (reason.StartsWith("Budget", StringComparison.OrdinalIgnoreCase)) return "budget"; return "altro"; } public static Result Run(DossierReader.Session session, Options options) { var header = session.Header; var cycles = BuildCycles(session.Polls); var strategy = new BidStrategyService(); var auction = NewAuction(header, options); var blocks = new Dictionary(StringComparer.Ordinal); var reached = 0; var bids = 0; // Le puntate in ordine cronologico: a ogni ciclo si mostra al motore solo // quelle che a quel momento erano già visibili. Mostrargliele tutte sarebbe // dargli il futuro, e le strategie che guardano lo storico ne uscirebbero // giudicate su informazioni che non avranno mai. var bidsInOrder = session.Bids .Where(b => b.UnixSeconds > 0) .OrderBy(b => b.T) .ToList(); var nextBid = 0; foreach (var cycle in cycles) { while (nextBid < bidsInOrder.Count && bidsInOrder[nextBid].T <= cycle.LastPollT) { Publish(auction, bidsInOrder[nextBid], options.Username); nextBid++; } // Il ciclo è arrivato fino a noi solo se il timer è sceso sotto l'anticipo. // // Bidoo dichiara la scadenza al secondo intero, quindi un ciclo che risulta // "sceso a 2" aveva in realtà fra 2 e 3 secondi davanti: il valore letto è un // pavimento, non una misura. Per non contare puntate che forse non sarebbero // partite si richiede il secondo pieno — con anticipo 2000 ms si // considerano solo i cicli scesi sotto i 2 s. È il conteggio prudente, e la // prudenza qui va verso il basso: meglio sottostimare il costo che promettere // un risparmio che i dati non possono garantire. if ((cycle.MinTimerSeconds + 1) * 1000.0 > options.LeadMs) continue; var state = ToState(cycle, options.Username); // Fuori corsa: il motore non punta su un'asta non aperta. if (state.Status != AuctionStatus.Running && state.Status != AuctionStatus.Pending) continue; if (state.IsMyBid) continue; reached++; // Budget e pareggio: e' il controllo che decide se la puntata ha ancora // senso economico, e va rigiocato come tutti gli altri. Senza, la prova // misurerebbe tutto tranne la cosa che protegge il portafoglio. var budget = BidBudget.Evaluate(new BidBudget.Situation( CurrentPrice: cycle.Price, BidsAlreadyUsed: bids, BidCostEuro: options.BidCostEuro, ProductValue: header?.BuyNowPrice, ShippingCost: null, MaxBids: auction.MaxClicks, MaxTotalSpendEuro: auction.MaxTotalSpendEuro, MinSavingsPercentage: options.EffectiveSettings.MinSavingsPercentage, StopAtBreakEven: auction.StopAtBreakEven)); if (!budget.CanBid) { var key = budget.Reason?.Contains("pareggio") == true ? "pareggio" : budget.Reason?.Contains("spesa") == true ? "tetto-spesa" : "tetto-puntate"; blocks[key] = blocks.TryGetValue(key, out var b) ? b + 1 : 1; continue; } var decision = strategy.ShouldPlaceBid(auction, state, options.EffectiveSettings, options.Username); if (!decision.ShouldBid) { var key = Categorize(decision.Reason); blocks[key] = blocks.TryGetValue(key, out var n) ? n + 1 : 1; continue; } bids++; // Una puntata riuscita: è ciò che il motore registrerebbe, e alimenta i // contatori su cui le strategie stesse decidono ai giri successivi. strategy.RecordBidAttempt(auction, success: true); auction.BidsUsedOnThisAuction = (auction.BidsUsedOnThisAuction ?? 0) + 1; } var finalPrice = session.Summary?.FinalPrice ?? cycles.LastOrDefault()?.Price ?? 0; return new Result { AuctionId = header?.AuctionId ?? "", Name = header?.Name ?? "", ProductKey = header?.ProductKey ?? "", Cycles = cycles.Count, Reached = reached, Bids = bids, Blocks = blocks, FinalPrice = finalPrice, BuyNowPrice = header?.BuyNowPrice, CostIfWon = finalPrice + bids * options.BidCostEuro }; } // ── Ricostruzione dei cicli ────────────────────────────────────── /// /// Un ciclo di timer: dalla puntata che l'ha aperto alla successiva (o alla fine). /// Lo identifica la scadenza dichiarata dal server, che cambia a ogni /// puntata: è la stessa chiave che usa il cecchino per non puntare due volte /// sullo stesso ciclo. /// public sealed record Cycle( long ExpiryUnix, double MinTimerSeconds, double Price, string LastBidder, bool Mine, string Status, long ServerUnix, double PingMs, double LastPollT); public static List BuildCycles(IReadOnlyList polls) { var cycles = new List(); long currentExpiry = 0; var minTimer = double.MaxValue; DossierReader.Poll? deepest = null; foreach (var poll in polls) { if (poll.ExpiryUnix <= 0 || poll.ServerUnix <= 0) continue; if (poll.ExpiryUnix != currentExpiry) { if (deepest != null) cycles.Add(Close(currentExpiry, minTimer, deepest)); currentExpiry = poll.ExpiryUnix; minTimer = double.MaxValue; deepest = null; } // Il timer calcolato dai due istanti del server, non quello riportato: // sono la stessa cosa, ma questo non dipende da come è stato arrotondato. var timer = poll.ExpiryUnix - poll.ServerUnix; if (timer <= minTimer) { minTimer = timer; deepest = poll; } } if (deepest != null) cycles.Add(Close(currentExpiry, minTimer, deepest)); return cycles; } private static Cycle Close(long expiry, double minTimer, DossierReader.Poll deepest) => new( ExpiryUnix: expiry, MinTimerSeconds: Math.Max(0, minTimer), Price: deepest.Price, LastBidder: deepest.LastBidder, Mine: deepest.Mine, Status: deepest.Status, ServerUnix: deepest.ServerUnix, PingMs: deepest.PingMs, LastPollT: deepest.T); // ── Adattatori verso i modelli del motore ──────────────────────── private static AuctionState ToState(Cycle cycle, string username) => new() { AuctionId = "", Timer = cycle.MinTimerSeconds, Price = cycle.Price, LastBidder = cycle.LastBidder, IsMyBid = cycle.Mine || (!string.IsNullOrEmpty(username) && cycle.LastBidder.Equals(username, StringComparison.OrdinalIgnoreCase)), Status = ParseStatus(cycle.Status), ServerUnixSeconds = cycle.ServerUnix, ExpiryUnixSeconds = cycle.ExpiryUnix, PollingLatencyMs = (int)cycle.PingMs, SnapshotTime = DateTimeOffset.FromUnixTimeSeconds(cycle.ServerUnix).UtcDateTime }; private static AuctionStatus ParseStatus(string? status) => Enum.TryParse(status, ignoreCase: true, out var parsed) ? parsed : AuctionStatus.Unknown; private static AuctionInfo NewAuction(DossierReader.Header? header, Options options) => new() { AuctionId = header?.AuctionId ?? "", Name = header?.Name ?? "", BuyNowPrice = header?.BuyNowPrice, BidBeforeDeadlineMs = options.LeadMs, BidLeadIsManual = true, // la rigiocata prova un anticipo preciso, non quello adattivo BidsUsedOnThisAuction = 0 }; /// /// Rende visibile al motore una puntata, come farebbe l'incorporazione di una /// risposta di data.php: in testa alla lista, con il tetto delle ultime cinquanta. /// private static void Publish(AuctionInfo auction, DossierReader.Bid bid, string username) { var entry = new BidHistoryEntry { Price = (decimal)bid.Price, Username = bid.User, Timestamp = bid.UnixSeconds, BidType = "Auto", IsMyBid = bid.Mine || (!string.IsNullOrEmpty(username) && bid.User.Equals(username, StringComparison.OrdinalIgnoreCase)) }; lock (auction.BidsLock) { auction.RecentBids.Insert(0, entry); if (auction.RecentBids.Count > 50) auction.RecentBids.RemoveRange(50, auction.RecentBids.Count - 50); if (!auction.BidderStats.TryGetValue(bid.User, out var stats)) { stats = new BidderInfo { Username = bid.User }; auction.BidderStats[bid.User] = stats; } stats.BidCount++; } } } }