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>
367 lines
15 KiB
C#
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 */ }
|
|
}
|
|
}
|
|
}
|