- 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.
248 lines
10 KiB
C#
248 lines
10 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
|
|
namespace AutoBidder.Utilities
|
|
{
|
|
/// <summary>
|
|
/// Misura l'anticipo <b>effettivo</b> delle puntate e ne ricava un consiglio.
|
|
///
|
|
/// <para>L'anticipo impostato dall'utente è una scommessa: "parto 800 ms prima della
|
|
/// scadenza". Ma ciò che conta è dove la puntata arriva davvero, cioè quanto restava
|
|
/// alla scadenza nell'istante in cui il server l'ha accettata. Fra i due valori ci
|
|
/// sono il ritardo di rete e l'errore di attesa.</para>
|
|
///
|
|
/// <para>Il motore calcolava già questo numero a ogni puntata, ma lo scriveva solo nel
|
|
/// log dell'asta, dove nessuno lo poteva sommare. Qui viene raccolto: con qualche
|
|
/// decina di misure si vede se l'anticipo impostato è troppo largo — e ogni
|
|
/// millisecondo di troppo è tempo in cui un avversario può passare davanti.</para>
|
|
/// </summary>
|
|
public static class BidLeadStats
|
|
{
|
|
/// <summary>Misure conservate. Oltre, si dimenticano le più vecchie.</summary>
|
|
private const int MaxSamples = 500;
|
|
|
|
private static readonly object Sync = new();
|
|
private static Store? _cache;
|
|
|
|
public class Sample
|
|
{
|
|
public DateTime At { get; set; }
|
|
public string AuctionId { get; set; } = "";
|
|
|
|
/// <summary>Anticipo impostato per quell'asta.</summary>
|
|
public int ConfiguredMs { get; set; }
|
|
|
|
/// <summary>Millisecondi realmente mancanti alla scadenza quando è partita.</summary>
|
|
public double ActualMs { get; set; }
|
|
|
|
/// <summary>Ping medio osservato su quell'asta al momento della puntata.</summary>
|
|
public double PingMs { get; set; }
|
|
|
|
public bool Success { get; set; }
|
|
|
|
/// <summary>Il server ha rifiutato perché il tempo era scaduto.</summary>
|
|
public bool TooLate { get; set; }
|
|
}
|
|
|
|
private class Store
|
|
{
|
|
public List<Sample> Samples { get; set; } = new();
|
|
}
|
|
|
|
private static Store Load()
|
|
{
|
|
lock (Sync)
|
|
{
|
|
if (_cache != null) return _cache;
|
|
|
|
try
|
|
{
|
|
var path = AppPaths.BidLeadStatsFile;
|
|
_cache = File.Exists(path)
|
|
? JsonSerializer.Deserialize<Store>(File.ReadAllText(path)) ?? new Store()
|
|
: new Store();
|
|
}
|
|
catch
|
|
{
|
|
_cache = new Store();
|
|
}
|
|
|
|
return _cache;
|
|
}
|
|
}
|
|
|
|
private static void Save()
|
|
{
|
|
try
|
|
{
|
|
AppPaths.EnsureFolders();
|
|
lock (Sync)
|
|
{
|
|
File.WriteAllText(AppPaths.BidLeadStatsFile,
|
|
JsonSerializer.Serialize(_cache, new JsonSerializerOptions { WriteIndented = true }));
|
|
}
|
|
}
|
|
catch { /* una misura persa non giustifica un errore all'utente */ }
|
|
}
|
|
|
|
public static void Record(Sample sample)
|
|
{
|
|
lock (Sync)
|
|
{
|
|
var store = Load();
|
|
store.Samples.Add(sample);
|
|
|
|
if (store.Samples.Count > MaxSamples)
|
|
store.Samples.RemoveRange(0, store.Samples.Count - MaxSamples);
|
|
}
|
|
|
|
Save();
|
|
}
|
|
|
|
public static List<Sample> All()
|
|
{
|
|
lock (Sync) return Load().Samples.ToList();
|
|
}
|
|
|
|
public static void Clear()
|
|
{
|
|
lock (Sync) Load().Samples.Clear();
|
|
Save();
|
|
}
|
|
|
|
/// <summary>Riepilogo delle misure raccolte.</summary>
|
|
public readonly record struct Summary(
|
|
int Count,
|
|
int Successes,
|
|
int TooLate,
|
|
double AverageActualMs,
|
|
double MedianActualMs,
|
|
double MinActualMs,
|
|
double AveragePingMs,
|
|
int TypicalConfiguredMs)
|
|
{
|
|
public bool HasData => Count > 0;
|
|
|
|
public double SuccessRate => Count > 0 ? Successes * 100.0 / Count : 0;
|
|
}
|
|
|
|
public static Summary Summarize()
|
|
{
|
|
var samples = All();
|
|
if (samples.Count == 0) return default;
|
|
|
|
// Solo le puntate accettate dicono qualcosa sull'anticipo: una rifiutata non
|
|
// è mai arrivata a destinazione e il suo "anticipo" non è confrontabile.
|
|
var accepted = samples.Where(s => s.Success).ToList();
|
|
var basis = accepted.Count > 0 ? accepted : samples;
|
|
|
|
var actual = basis.Select(s => s.ActualMs).OrderBy(v => v).ToList();
|
|
|
|
return new Summary(
|
|
Count: samples.Count,
|
|
Successes: samples.Count(s => s.Success),
|
|
TooLate: samples.Count(s => s.TooLate),
|
|
AverageActualMs: actual.Average(),
|
|
MedianActualMs: actual[actual.Count / 2],
|
|
MinActualMs: actual[0],
|
|
AveragePingMs: basis.Average(s => s.PingMs),
|
|
TypicalConfiguredMs: (int)basis.Average(s => s.ConfiguredMs));
|
|
}
|
|
|
|
/// <summary>Esito della taratura: un consiglio, mai un'applicazione automatica.</summary>
|
|
public readonly record struct Advice(bool HasAdvice, int SuggestedLeadMs, string Reason);
|
|
|
|
/// <summary>
|
|
/// Propone un anticipo a partire dalle misure.
|
|
///
|
|
/// <para>Il criterio è prudente per costruzione. Il margine minimo che va comunque
|
|
/// lasciato è il ritardo di rete più un cuscinetto: sotto quella soglia la puntata
|
|
/// rischia di arrivare a giochi chiusi, e una puntata tardiva costa un'asta intera
|
|
/// mentre un anticipo un po' largo costa solo qualche possibilità in più
|
|
/// all'avversario. Per questo si scende solo se lo scarto è ampio, e mai sotto il
|
|
/// margine di sicurezza.</para>
|
|
/// </summary>
|
|
/// <summary>
|
|
/// Anticipo minimo sensato, in millisecondi. Sotto questa soglia la puntata
|
|
/// dipende dal non avere un picco di rete: sui dossier raccolti il ping è
|
|
/// mediano 56 ms ma il p99,9 è 444 ms, quindi con 200 ms di anticipo lo 0,43%
|
|
/// delle puntate arriverebbe dopo la scadenza — e una tardiva costa l'asta.
|
|
/// </summary>
|
|
public const int SafeFloorMs = 600;
|
|
|
|
/// <summary>
|
|
/// Anticipo oltre il quale si comincia a pagare. Il motore punta in ogni ciclo che
|
|
/// arriva fino all'anticipo senza puntate altrui, e su 1.007.850 cicli rigiocati la
|
|
/// mediana delle puntate per asta è 1 sotto il secondo, 15 sotto i due secondi,
|
|
/// 46 sotto i tre e 97 sotto i quattro. Ogni secondo in più moltiplica per dieci
|
|
/// la spesa, quindi il consiglio non supera mai questo valore.
|
|
/// </summary>
|
|
public const int SafeCeilingMs = 2000;
|
|
|
|
public static Advice Suggest(int currentLeadMs, int minSamples = 15)
|
|
{
|
|
var samples = All().Where(s => s.Success).ToList();
|
|
|
|
if (samples.Count < minSamples)
|
|
{
|
|
// Anche senza misure proprie qualcosa si può dire: le due soglie sono
|
|
// ricavate dai dossier, non da un'intuizione.
|
|
if (currentLeadMs > 0 && currentLeadMs < SafeFloorMs)
|
|
return new Advice(true, SafeFloorMs,
|
|
$"Anticipo {currentLeadMs} ms: sotto i {SafeFloorMs} ms basta un picco di rete " +
|
|
$"perché la puntata arrivi a giochi chiusi. Servono comunque {minSamples} puntate " +
|
|
$"riuscite per una taratura sulle tue misure (finora {samples.Count}).");
|
|
|
|
if (currentLeadMs > SafeCeilingMs)
|
|
return new Advice(true, SafeCeilingMs,
|
|
$"Anticipo {currentLeadMs} ms: oltre i {SafeCeilingMs} ms si punta in molti più cicli " +
|
|
$"del necessario, e ogni ciclo in più è una puntata spesa. Servono comunque " +
|
|
$"{minSamples} puntate riuscite per una taratura sulle tue misure (finora {samples.Count}).");
|
|
|
|
return new Advice(false, currentLeadMs,
|
|
$"Servono almeno {minSamples} puntate riuscite per un consiglio attendibile (finora {samples.Count}).");
|
|
}
|
|
|
|
var tooLate = All().Count(s => s.TooLate);
|
|
|
|
// Se arrivano puntate tardive il problema è opposto: bisogna allargare.
|
|
if (tooLate > 0)
|
|
{
|
|
var ping = samples.Average(s => s.PingMs);
|
|
var suggested = (int)Math.Ceiling((currentLeadMs + ping + 200) / 50.0) * 50;
|
|
return new Advice(true, suggested,
|
|
$"{tooLate} puntate rifiutate perché tardive: conviene allargare l'anticipo a {suggested} ms.");
|
|
}
|
|
|
|
var actual = samples.Select(s => s.ActualMs).OrderBy(v => v).ToList();
|
|
var median = actual[actual.Count / 2];
|
|
var worst = actual[0]; // la volta che è arrivata più tardi
|
|
var avgPing = samples.Average(s => s.PingMs);
|
|
|
|
// Margine da non erodere: il ritardo di rete più mezzo cuscinetto, e mai
|
|
// sotto il pavimento ricavato dai dossier.
|
|
var safety = Math.Max(avgPing + 150, SafeFloorMs);
|
|
|
|
// Si può guadagnare solo ciò che avanza rispetto al caso peggiore osservato.
|
|
var headroom = worst - safety;
|
|
|
|
if (headroom < 150)
|
|
return new Advice(false, currentLeadMs,
|
|
$"L'anticipo attuale è già vicino al margine di sicurezza (peggior caso {worst:F0} ms, ping medio {avgPing:F0} ms).");
|
|
|
|
var proposal = (int)Math.Floor((currentLeadMs - headroom) / 50.0) * 50;
|
|
proposal = Math.Max(proposal, (int)Math.Ceiling(safety / 50.0) * 50);
|
|
|
|
if (proposal >= currentLeadMs)
|
|
return new Advice(false, currentLeadMs, "L'anticipo attuale è già adeguato.");
|
|
|
|
return new Advice(true, proposal,
|
|
$"Le puntate arrivano in media {median:F0} ms prima della scadenza e mai oltre {worst:F0} ms: " +
|
|
$"si può scendere da {currentLeadMs} a {proposal} ms mantenendo il margine di sicurezza.");
|
|
}
|
|
}
|
|
}
|