Files
Mimante/Mimante/Utilities/ProductStatsStore.cs
T
Alby96andClaude Fable 5.1 9177b31bd5 Due database, interruttore dell'apprendimento, barre a icone; via kill-switch, esportazioni e voci deprecate
Tutto ciò che l'applicazione registra sta ora in due file SQLite in
%LocalAppData%\AutoBidder\Database: autobidder.sqlite per le osservazioni e
esercizio.sqlite per i documenti d'esercizio (aste nel monitor, prodotti, promozioni,
modelli appresi con prefisso ml/) e per i registri applicativo e del riscatto puntate.
Il motore comune è SqliteDatabase; i file JSON e la cartella Apprendimento delle
versioni precedenti vengono importati la prima volta e lasciati dove sono. La cartella
si cambia dalle Impostazioni: al salvataggio si chiede se spostare i file, e si riavvia.
Le cartelle Dati/Statistiche/Registri, i registri su file, le misure dell'anticipo e
le impostazioni morte (LogBids, AutoApplyProductDefaults, NewAuctionLimitsPriority…)
non esistono più.

Il kill-switch se ne va: al suo posto un solo interruttore in barra, accanto ad
Avvia/Osserva/Ferma, che spegne l'apprendimento. Spento, il motore punta solo entro i
limiti dell'utente (prezzo, puntate, budget, fascia oraria, rischio) con l'anticipo
fisso: niente valore atteso, regime, duello, bandit, anticipo adattivo. Le aste vengono
comunque registrate e studiate.

Tre difetti visti osservando il motore dal vivo per un'ora, in Osserva:
- la copertura «Compralo Ora» con V lasciato al listino azzerava la perdita coperta e
  il motore diceva BID a ogni scadenza con P dell'1%: ora vale solo per i prodotti con
  «Valore reale €» scritto, altrimenti la puntata si conta persa e P decide;
- la chiusura di un'asta arrivava due volte (fine, poi rimozione dal monitor) e di
  nuovo al riavvio: modello, profilo, bandit e statistiche per prodotto contavano ogni
  asta due volte. Il monitor la comunica una volta sola, l'apprendimento salta le aste
  già apprese, e le schede prodotto vengono ricostruite una volta dallo storico;
- la pagina delle ricompense veniva scambiata per la pagina di accesso perché porta un
  collegamento a /login.php: prima si guarda se è la pagina delle ricompense.

Interfaccia: la scheda Esporta e tutto il suo codice sono tolti; le esportazioni
chiedono sempre dove salvare. Prodotti, Storico e Apprendimento hanno barre a sole
icone con tooltip che dicono esattamente cosa succede, colorate come il monitor, con
i nuovi pulsanti di pulizia (spegni tutte le stelline, togli i non seguiti, pulizia
completa; seleziona tutte / elimina le selezionate / svuota lo storico). Impostazioni:
sezione Database, Manutenzione con «Elimina tutti i dati (tranne la login)» e
«Impostazioni di fabbrica», esportazione dei registri in testo. Modifiche.txt, il
prompt ormai realizzato, esce dal repository.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:58:08 +02:00

367 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
/// <summary>
/// Scheda di ogni prodotto: <b>opzioni</b> scelte dall'utente e <b>statistiche</b>
/// accumulate, in un file solo (<c>products.json</c>).
///
/// <para>Prima le due metà stavano in posti diversi e con vite diverse: i limiti in
/// <c>watched-products.json</c>, le medie ricalcolate ogni volta scorrendo tutto lo
/// storico. Funzionava, ma significava che per sapere "a quanto chiude di solito questo
/// articolo" bisognava rileggere l'intero archivio, e che quel numero non esisteva da
/// nessuna parte se non a video.</para>
///
/// <para>Qui invece la scheda si aggiorna a ogni asta conclusa e resta su disco: è
/// leggibile da fuori, è il posto naturale dove guardare prima di decidere i limiti di
/// un prodotto, ed evita di rileggere migliaia di record per disegnare una riga.</para>
///
/// <para>Le opzioni restano la fonte di verità di <see cref="WatchedProductsStore"/>:
/// qui vengono <i>rispecchiate</i>, non duplicate come seconda verità. Chi le modifica
/// passa sempre da lì.</para>
/// </summary>
public static class ProductStatsStore
{
private static readonly object Sync = new();
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static StoreData? _cache;
public const string DocumentKey = "products";
/// <summary>Scheda completa di un prodotto: com'è impostato e com'è andata finora.</summary>
public sealed class ProductCard
{
/// <summary>Chiave normalizzata del prodotto: è l'identità nell'archivio.</summary>
public string ProductKey { get; set; } = "";
/// <summary>Id prodotto di Bidoo, quando noto. Più affidabile del nome.</summary>
public int ProductId { get; set; }
public string DisplayName { get; set; } = "";
// ── Opzioni (rispecchiate da watched-products.json) ──────────
public bool IsWatched { get; set; }
public double? MinPrice { get; set; }
public double? MaxPrice { get; set; }
public int? MaxBids { get; set; }
/// <summary>Valore reale V scelto dall'utente; null = Compralo Ora.</summary>
public double? MarketValue { get; set; }
public string? AutoAddState { get; set; }
/// <summary>Orizzonte proprio per l'aggiunta automatica, in minuti.</summary>
public int? MaxStartMinutes { get; set; }
// ── Statistiche accumulate ───────────────────────────────────
public int AuctionsCompleted { get; set; }
public int AuctionsWon { get; set; }
public int AuctionsWithMyBids { get; set; }
/// <summary>Puntate spese in totale su questo prodotto.</summary>
public int MyBidsTotal { get; set; }
public double? LastFinalPrice { get; set; }
public double? MinFinalPrice { get; set; }
public double? MaxFinalPrice { get; set; }
/// <summary>Somma dei prezzi finali: serve a tenere la media senza rileggere nulla.</summary>
public double FinalPriceSum { get; set; }
/// <summary>Valore "Compra Subito" più recente che si è visto.</summary>
public double? BuyNowPrice { get; set; }
public double? ShippingCost { get; set; }
/// <summary>Aste di cui esiste una storia completa, dall'inizio alla fine.</summary>
public int CompleteObservations { get; set; }
public DateTime? FirstCompletedAt { get; set; }
public DateTime? LastCompletedAt { get; set; }
/// <summary>Durata media osservata, in minuti.</summary>
public double ObservedMinutesSum { get; set; }
/// <summary>Puntate osservate in totale (di tutti gli utenti).</summary>
public long TotalObservedBids { get; set; }
// ── Derivati, comodi da leggere anche a occhio nel file ──────
public double AverageFinalPrice =>
AuctionsCompleted > 0 ? Math.Round(FinalPriceSum / AuctionsCompleted, 2) : 0;
public double WinRatePercent =>
AuctionsWithMyBids > 0 ? Math.Round(AuctionsWon * 100.0 / AuctionsWithMyBids, 1) : 0;
public double AverageObservedMinutes =>
AuctionsCompleted > 0 ? Math.Round(ObservedMinutesSum / AuctionsCompleted, 1) : 0;
// Il prezzo massimo consigliato NON sta qui: dipende dalla forma della
// distribuzione delle chiusure, che questa scheda non conserva. Lo calcola
// PriceAdvisor a partire dallo storico, ed e' esposto dalla scheda Prodotti.
// Averne una seconda versione approssimata qui voleva dire due risposte
// diverse alla stessa domanda.
}
private sealed class StoreData
{
public string Note { get; set; } =
"Statistiche e opzioni per prodotto. Le opzioni rispecchiano watched-products.json; " +
"le statistiche si aggiornano a ogni asta conclusa.";
public DateTime UpdatedAt { get; set; } = DateTime.Now;
public List<ProductCard> Products { get; set; } = new();
}
/// <summary>Tutte le schede, dalla più recente per attività.</summary>
public static List<ProductCard> GetAll()
{
lock (Sync)
{
return Load().Products
.OrderByDescending(p => p.LastCompletedAt ?? DateTime.MinValue)
.ToList();
}
}
public static ProductCard? Find(string? productKey)
{
if (string.IsNullOrWhiteSpace(productKey)) return null;
lock (Sync) return FindInternal(Load(), productKey);
}
/// <summary>
/// Aggiorna la scheda del prodotto con l'asta appena conclusa. È l'unico punto in cui
/// le statistiche crescono: tenerle aggiornate qui evita di doverle ricalcolare
/// scorrendo tutto lo storico ogni volta che serve un numero.
/// </summary>
public static void RecordCompleted(CompletedAuctionRecord summary, AuctionDetailRecord? detail)
{
if (summary == null || string.IsNullOrWhiteSpace(summary.ProductKey)) return;
lock (Sync)
{
var data = Load();
var card = FindInternal(data, summary.ProductKey);
if (card == null)
{
card = new ProductCard
{
ProductKey = summary.ProductKey,
DisplayName = summary.Name
};
data.Products.Add(card);
}
if (string.IsNullOrWhiteSpace(card.DisplayName)) card.DisplayName = summary.Name;
card.AuctionsCompleted++;
card.FinalPriceSum += summary.FinalPrice;
card.LastFinalPrice = summary.FinalPrice;
card.MinFinalPrice = card.MinFinalPrice.HasValue
? Math.Min(card.MinFinalPrice.Value, summary.FinalPrice)
: summary.FinalPrice;
card.MaxFinalPrice = card.MaxFinalPrice.HasValue
? Math.Max(card.MaxFinalPrice.Value, summary.FinalPrice)
: summary.FinalPrice;
if (summary.WonByMe) card.AuctionsWon++;
// Le percentuali di vittoria si calcolano solo sulle aste in cui si è
// davvero puntato: contarci dentro quelle osservate le schiaccerebbe verso
// lo zero raccontando una sconfitta mai avvenuta.
if (summary.MyBidsUsed > 0)
{
card.AuctionsWithMyBids++;
card.MyBidsTotal += summary.MyBidsUsed;
}
if (summary.BuyNowPrice.HasValue) card.BuyNowPrice = summary.BuyNowPrice;
card.FirstCompletedAt ??= summary.EndedAt;
card.LastCompletedAt = summary.EndedAt;
if (detail != null)
{
card.ObservedMinutesSum += detail.ObservedMinutes;
card.TotalObservedBids += detail.TotalObservedBids;
if (detail.ShippingCost.HasValue) card.ShippingCost = detail.ShippingCost;
if (detail.IsComplete) card.CompleteObservations++;
}
SyncOptions(card);
Save(data);
}
}
/// <summary>
/// Riallinea le opzioni di tutte le schede a quanto dice
/// <see cref="WatchedProductsStore"/>. Da chiamare dopo una modifica nella scheda
/// Prodotti: le opzioni si cambiano lì, qui se ne tiene copia leggibile.
/// </summary>
public static void SyncOptionsFromWatchList()
{
lock (Sync)
{
var data = Load();
foreach (var product in WatchedProductsStore.GetAll())
{
var key = string.IsNullOrWhiteSpace(product.ProductKey)
? ProductKeyHelper.GenerateProductKey(product.DisplayName)
: product.ProductKey;
if (string.IsNullOrWhiteSpace(key)) continue;
var card = FindInternal(data, key);
if (card == null)
{
card = new ProductCard { ProductKey = key, DisplayName = product.DisplayName };
data.Products.Add(card);
}
card.ProductId = product.ProductId;
if (string.IsNullOrWhiteSpace(card.DisplayName)) card.DisplayName = product.DisplayName;
card.IsWatched = product.IsWatched;
card.MinPrice = product.MinPrice;
card.MaxPrice = product.MaxPrice;
card.MaxBids = product.MaxClicks;
card.MarketValue = product.MarketValue;
card.MaxStartMinutes = product.MaxStartMinutes;
card.AutoAddState = product.AutoAddState;
// Il valore reale finisce anche sul prodotto nel database: è da lì
// che le analisi lo leggono, e da lì che le aste nuove lo ereditano.
try
{
var now = Data.AuctionDatabase.Now();
Data.AuctionDatabase.Instance.Enqueue(
"INSERT INTO items(product_key, title, market_price_est, market_price_source, first_seen_at, updated_at) " +
"VALUES(?1, ?2, ?3, CASE WHEN ?3 IS NULL THEN NULL ELSE 'utente' END, ?4, ?4) " +
"ON CONFLICT(product_key) DO UPDATE SET market_price_est = excluded.market_price_est, " +
" market_price_source = excluded.market_price_source, updated_at = excluded.updated_at",
key, product.DisplayName ?? "", product.MarketValue, now);
}
catch { /* il database non deve fermare il salvataggio delle opzioni */ }
}
Save(data);
}
}
/// <summary>
/// Ricostruisce tutte le statistiche dallo storico. Serve la prima volta — le aste
/// concluse prima che questo archivio esistesse non lo hanno mai alimentato — e ogni
/// volta che si sospetta che i totali non tornino.
/// </summary>
public static int RebuildFromHistory()
{
var summaries = CompletedAuctionsStore.LoadAll();
var details = AuctionDetailStore.LoadAll()
.GroupBy(d => d.AuctionId)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
lock (Sync)
{
_cache = new StoreData();
Save(_cache);
}
foreach (var summary in summaries.OrderBy(s => s.EndedAt))
{
details.TryGetValue(summary.AuctionId, out var detail);
RecordCompleted(summary, detail);
}
SyncOptionsFromWatchList();
return summaries.Count;
}
/// <summary>
/// Azzera i numeri di tutte le schede tenendo le opzioni scelte nella scheda
/// Prodotti: si perde la statistica, non la configurazione.
/// </summary>
public static void ClearStatistics()
{
lock (Sync)
{
_cache = new StoreData();
Save(_cache);
}
SyncOptionsFromWatchList();
}
private static void SyncOptions(ProductCard card)
{
var product = WatchedProductsStore.GetAll()
.FirstOrDefault(p => string.Equals(p.ProductKey, card.ProductKey, StringComparison.OrdinalIgnoreCase));
if (product == null) return;
card.ProductId = product.ProductId;
card.IsWatched = product.IsWatched;
card.MinPrice = product.MinPrice;
card.MaxPrice = product.MaxPrice;
card.MaxBids = product.MaxClicks;
card.MaxStartMinutes = product.MaxStartMinutes;
card.AutoAddState = product.AutoAddState;
}
private static ProductCard? FindInternal(StoreData data, string productKey) =>
data.Products.FirstOrDefault(p =>
string.Equals(p.ProductKey, productKey, StringComparison.OrdinalIgnoreCase));
private static StoreData Load()
{
if (_cache != null) return _cache;
try
{
var json = Data.OperationalDatabase.Instance.GetDocumentOrImport(DocumentKey, AppPaths.LegacyProductsFile);
_cache = string.IsNullOrWhiteSpace(json)
? new StoreData()
: JsonSerializer.Deserialize<StoreData>(json) ?? new StoreData();
}
catch
{
// Un file illeggibile non deve impedire di seguire le aste: si riparte da
// zero, e RebuildFromHistory sa rimettere insieme i numeri.
_cache = new StoreData();
}
return _cache;
}
private static void Save(StoreData data)
{
try
{
data.UpdatedAt = DateTime.Now;
Data.OperationalDatabase.Instance.SetDocument(DocumentKey, JsonSerializer.Serialize(data, Options));
}
catch { /* la persistenza non deve mai bloccare l'interfaccia */ }
}
}
}