Le strategie a numero fisso (calore, ritiro morbido, anti-bot, avversari aggressivi, puntata probabilistica, velocità del prezzo, esaurimento, concorrenza) e le impostazioni morte (cadenze di polling, finestra critica, database, log avanzati, suggerimenti sull'anticipo, schede dettagliate) non ci sono più: rigiocate sui dossier non fermavano una puntata sbagliata senza fermarne anche di giuste, e i loro numeri andavano tarati a mano. Restano i paletti dell'utente (tetti, budget, fascia oraria) e il duello. Al loro posto due componenti che imparano dalla sessione in corso, dentro i paletti: - CompetitionRegime per asta (Calmo / Sfogo / Sondaggio) guidato dal valore atteso appreso: tre negativi di fila e si lascia sfogare gli altri, si rientra con una puntata di prova dopo abbastanza cicli buoni, e se viene coperta subito la pazienza raddoppia. È il "capire da soli quando tornare a puntare". - LatencyModel per l'anticipo: margine + p99 della latenza misurata adesso, tenuto fra LeadMinMs e LeadMaxMs; una puntata tardiva alza il margine subito, venti in tempo lo abbassano piano. Un anticipo scritto a mano su un'asta vince sempre (BidLeadIsManual). Archiviazione: l'archivio mensile aste-AAAA-MM.jsonl (95 MB) duplicava il riepilogo che ogni dossier ha già in coda. AuctionDetailStore ora legge testa e coda dei dossier, con cache: 6349 file in 6 s la prima volta. Pulsanti per svuotare le esportazioni e per azzerare le statistiche voce per voce (lo storico passa dalla copia di sicurezza). Scheda Apprendimento: sezione "Autonomia sul momento" con il modello di latenza e il regime di ogni asta seguita. Ml/LEGGIMI.md descrive l'algoritmo per intero. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
350 lines
16 KiB
C#
350 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AutoBidder.Models;
|
|
using AutoBidder.Services;
|
|
using AutoBidder.Utilities;
|
|
|
|
namespace AutoBidder.Engine.Backtest
|
|
{
|
|
/// <summary>
|
|
/// Rigioca un'asta già conclusa e conta cosa avrebbe fatto il motore.
|
|
///
|
|
/// <para><b>Cosa misura davvero.</b> 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 <b>quante puntate sarebbero state
|
|
/// spese</b> e <b>quante volte una strategia le avrebbe bloccate</b>.</para>
|
|
///
|
|
/// <para><b>Cosa non può misurare, e perché va detto.</b> 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.</para>
|
|
/// </summary>
|
|
public static class BacktestRunner
|
|
{
|
|
/// <summary>Parametri della rigiocata.</summary>
|
|
public sealed record Options(
|
|
int LeadMs,
|
|
AppSettings Settings,
|
|
string Username = "",
|
|
double BidCostEuro = 0.20)
|
|
{
|
|
/// <summary>
|
|
/// Le impostazioni come le vede la rigiocata.
|
|
///
|
|
/// <para>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 è.</para>
|
|
///
|
|
/// <para>Vale anche per la suite di test, che gira a qualunque ora.</para>
|
|
/// </summary>
|
|
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<AppSettings>(json)
|
|
?? new AppSettings();
|
|
|
|
clone.QuietHoursEnabled = false;
|
|
return clone;
|
|
}
|
|
}
|
|
|
|
/// <summary>Esito su una singola asta.</summary>
|
|
public sealed class Result
|
|
{
|
|
public string AuctionId { get; init; } = "";
|
|
public string Name { get; init; } = "";
|
|
public string ProductKey { get; init; } = "";
|
|
|
|
/// <summary>Cicli di timer osservati nel dossier.</summary>
|
|
public int Cycles { get; init; }
|
|
|
|
/// <summary>Cicli arrivati fino all'anticipo: qui il motore avrebbe sparato.</summary>
|
|
public int Reached { get; init; }
|
|
|
|
/// <summary>Puntate davvero inviate, cioè i cicli raggiunti che nessuna strategia ha fermato.</summary>
|
|
public int Bids { get; init; }
|
|
|
|
/// <summary>Blocchi per motivo, con il testo che avrebbe scritto la strategia.</summary>
|
|
public Dictionary<string, int> Blocks { get; init; } = new(StringComparer.Ordinal);
|
|
|
|
public double FinalPrice { get; init; }
|
|
public double? BuyNowPrice { get; init; }
|
|
|
|
/// <summary>Costo se l'asta fosse stata vinta all'ultima puntata: prezzo + puntate spese.</summary>
|
|
public double CostIfWon { get; init; }
|
|
|
|
public int BlockedTotal => Blocks.Values.Sum();
|
|
}
|
|
|
|
/// <summary>Motivi di blocco riconosciuti, per raggrupparli senza dipendere dal testo esatto.</summary>
|
|
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<string, int>(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 <b>pieno</b> — 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 ──────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Un ciclo di timer: dalla puntata che l'ha aperto alla successiva (o alla fine).
|
|
/// Lo identifica la <b>scadenza</b> dichiarata dal server, che cambia a ogni
|
|
/// puntata: è la stessa chiave che usa il cecchino per non puntare due volte
|
|
/// sullo stesso ciclo.
|
|
/// </summary>
|
|
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<Cycle> BuildCycles(IReadOnlyList<DossierReader.Poll> polls)
|
|
{
|
|
var cycles = new List<Cycle>();
|
|
|
|
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<AuctionStatus>(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
|
|
};
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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++;
|
|
}
|
|
}
|
|
}
|
|
}
|