Add utility classes for theme management, waiting, watched products, and notifications
- Implement ThemeManager for dynamic light/dark theme switching in the application. - Create Wait class for cancellable delays without exceptions for smoother user experience. - Introduce WatchedProductsStore to manage and persist watched products in JSON format. - Add WindowsNotifier for system notifications to inform users of important events. - Develop ProductViewModel to encapsulate product data and manage UI interactions effectively.
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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>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("Anti-bot", StringComparison.OrdinalIgnoreCase)) return "anti-bot";
|
||||
if (reason.StartsWith("Soft retreat", StringComparison.OrdinalIgnoreCase)) return "soft-retreat";
|
||||
if (reason.StartsWith("Asta troppo calda", StringComparison.OrdinalIgnoreCase)) return "asta-calda";
|
||||
if (reason.StartsWith("Bidder aggressivi", StringComparison.OrdinalIgnoreCase)) return "avversari-aggressivi";
|
||||
if (reason.StartsWith("Skip probabilistico", StringComparison.OrdinalIgnoreCase)) return "probabilistico";
|
||||
if (reason.StartsWith("Prezzo sale", StringComparison.OrdinalIgnoreCase)) return "velocita-prezzo";
|
||||
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.Settings.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;
|
||||
}
|
||||
|
||||
strategy.UpdateHeatMetric(auction, options.Settings, options.Username);
|
||||
var decision = strategy.ShouldPlaceBid(auction, state, options.Settings, 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,
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user