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++;
}
}
}
}