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>
329 lines
14 KiB
C#
329 lines
14 KiB
C#
using System;
|
|
using System.Threading;
|
|
using AutoBidder.Utilities;
|
|
|
|
namespace AutoBidder.Data
|
|
{
|
|
/// <summary>
|
|
/// L'archivio delle osservazioni: un solo file SQLite con tutto ciò che si osserva e
|
|
/// si decide — aste, puntate, interrogazioni, reset, nostre puntate, decisioni del
|
|
/// motore, misure di rete, puntatori, sessioni, contabilità del rischio.
|
|
///
|
|
/// <para><b>Perché un database.</b> Prima c'erano un file JSON per asta (i dossier),
|
|
/// un file JSON con lo storico compatto, uno con le statistiche per prodotto: copie
|
|
/// parziali degli stessi fatti, da tenere allineate, e nessuna interrogabile. Qui ogni
|
|
/// fatto sta in una riga sola, con un indice, e una domanda come «quante puntate
|
|
/// manuali fra le 10 e le 13 sui buoni da 10 € sono rimaste senza risposta?» è una
|
|
/// riga di SQL invece di una lettura di otto gigabyte.</para>
|
|
///
|
|
/// <para>Il motore (coda di scrittura, letture in WAL) è in <see cref="SqliteDatabase"/>.
|
|
/// I dati d'esercizio e i registri stanno nell'altro file, <see cref="OperationalDatabase"/>.</para>
|
|
/// </summary>
|
|
public sealed class AuctionDatabase : SqliteDatabase
|
|
{
|
|
public const int SchemaVersion = 1;
|
|
|
|
private static readonly object InstanceSync = new();
|
|
private static AuctionDatabase? _instance;
|
|
|
|
/// <summary>L'archivio dell'applicazione, aperto al primo uso in <see cref="AppPaths.DatabaseFile"/>.</summary>
|
|
public static AuctionDatabase Instance
|
|
{
|
|
get
|
|
{
|
|
lock (InstanceSync)
|
|
{
|
|
if (_instance == null || _instance.IsDisposed)
|
|
_instance = Open(AppPaths.DatabaseFile);
|
|
return _instance;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Chiude l'archivio dell'applicazione svuotando la coda. All'uscita, o prima di spostarlo.</summary>
|
|
public static void CloseInstance()
|
|
{
|
|
lock (InstanceSync)
|
|
{
|
|
_instance?.Dispose();
|
|
_instance = null;
|
|
}
|
|
}
|
|
|
|
private long _nextDecisionId;
|
|
private long _auctionsVersion;
|
|
|
|
/// <summary>Cambia a ogni scrittura sulle aste: chi tiene una copia in memoria la confronta con questo.</summary>
|
|
public long AuctionsVersion => Interlocked.Read(ref _auctionsVersion);
|
|
|
|
private AuctionDatabase(string path) : base(path, SchemaV1, SchemaVersion, "AutoBidder.Database") { }
|
|
|
|
public static AuctionDatabase Open(string path) => new(path);
|
|
|
|
protected override void OnOpened(SqliteConnection writer)
|
|
{
|
|
// Gli id delle decisioni si assegnano qui, non da SQLite: chi registra una
|
|
// decisione deve poterla legare alla puntata senza aspettare il disco.
|
|
try { _nextDecisionId = writer.Query("SELECT COALESCE(MAX(decision_id), 0) AS n FROM bot_decisions")[0].Long("n"); }
|
|
catch { _nextDecisionId = 0; }
|
|
}
|
|
|
|
protected override void OnStatementExecuted(string sql)
|
|
{
|
|
if (sql.Contains("auctions", StringComparison.Ordinal)) Interlocked.Increment(ref _auctionsVersion);
|
|
}
|
|
|
|
/// <summary>Il prossimo id per <c>bot_decisions</c>: unico, crescente, senza aspettare la scrittura.</summary>
|
|
public long NextDecisionId() => Interlocked.Increment(ref _nextDecisionId);
|
|
|
|
/// <summary>
|
|
/// Svuota le tabelle delle osservazioni e delle decisioni. Restano i prodotti
|
|
/// (portano il valore reale scelto dall'utente) e le sessioni.
|
|
/// </summary>
|
|
public void WipeObservations()
|
|
{
|
|
EnqueueBatch(new (string, object?[])[]
|
|
{
|
|
("DELETE FROM auctions", Array.Empty<object?>()),
|
|
("DELETE FROM bot_decisions", Array.Empty<object?>()),
|
|
("DELETE FROM network_metrics", Array.Empty<object?>()),
|
|
("DELETE FROM bidders", Array.Empty<object?>()),
|
|
("DELETE FROM risk_ledger", Array.Empty<object?>())
|
|
});
|
|
Flush();
|
|
Vacuum();
|
|
}
|
|
|
|
private const string SchemaV1 = """
|
|
CREATE TABLE IF NOT EXISTS meta(
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT);
|
|
|
|
-- Un prodotto: ciò che le aste hanno in comune. market_price_est è il valore
|
|
-- reale V scelto dall'utente; retail_price_bidoo è il «Compralo Ora».
|
|
CREATE TABLE IF NOT EXISTS items(
|
|
item_id INTEGER PRIMARY KEY,
|
|
product_key TEXT NOT NULL UNIQUE,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
category TEXT,
|
|
retail_price_bidoo REAL,
|
|
market_price_est REAL,
|
|
market_price_source TEXT,
|
|
shipping REAL,
|
|
first_seen_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL);
|
|
|
|
-- Un'asta: una riga sola, aggiornata dall'apertura alla chiusura. Le colonne
|
|
-- del riepilogo restano NULL finché l'asta è in corso.
|
|
CREATE TABLE IF NOT EXISTS auctions(
|
|
auction_id TEXT PRIMARY KEY,
|
|
item_id INTEGER REFERENCES items(item_id),
|
|
product_key TEXT NOT NULL DEFAULT '',
|
|
name TEXT NOT NULL DEFAULT '',
|
|
url TEXT NOT NULL DEFAULT '',
|
|
type TEXT NOT NULL DEFAULT 'normal',
|
|
status TEXT NOT NULL DEFAULT 'open',
|
|
me TEXT,
|
|
app_version TEXT,
|
|
added_at TEXT NOT NULL,
|
|
first_seen_at TEXT NOT NULL,
|
|
opened_at TEXT NOT NULL,
|
|
closed_at TEXT,
|
|
end_time TEXT,
|
|
final_price REAL,
|
|
winner_user TEXT,
|
|
won_by_me INTEGER NOT NULL DEFAULT 0,
|
|
outcome TEXT,
|
|
final_status TEXT,
|
|
bid_increment REAL NOT NULL DEFAULT 0.01,
|
|
bid_fee REAL,
|
|
shipping REAL,
|
|
transaction_fee REAL,
|
|
buy_now_price REAL,
|
|
market_value REAL,
|
|
has_win_limit INTEGER NOT NULL DEFAULT 0,
|
|
win_limit TEXT,
|
|
my_bids INTEGER NOT NULL DEFAULT 0,
|
|
winner_bids_paid INTEGER,
|
|
winner_bids_free INTEGER,
|
|
resets INTEGER NOT NULL DEFAULT 0,
|
|
distinct_bidders INTEGER NOT NULL DEFAULT 0,
|
|
total_observed_bids INTEGER NOT NULL DEFAULT 0,
|
|
top_bidder_share REAL,
|
|
observed_from_start INTEGER NOT NULL DEFAULT 0,
|
|
observed_to_end INTEGER NOT NULL DEFAULT 0,
|
|
observed_minutes REAL,
|
|
avg_ping_ms REAL,
|
|
p95_ping_ms REAL,
|
|
polls INTEGER NOT NULL DEFAULT 0,
|
|
poll_errors INTEGER NOT NULL DEFAULT 0,
|
|
configured_lead_ms INTEGER,
|
|
lead_is_manual INTEGER NOT NULL DEFAULT 0,
|
|
timer_expired INTEGER NOT NULL DEFAULT 0,
|
|
successful_bids INTEGER NOT NULL DEFAULT 0,
|
|
failed_bids INTEGER NOT NULL DEFAULT 0,
|
|
price_velocity_per_minute REAL,
|
|
price_series_json TEXT,
|
|
bids_by_user_json TEXT,
|
|
config_json TEXT,
|
|
abandon_reason TEXT,
|
|
learned_at TEXT);
|
|
CREATE INDEX IF NOT EXISTS ix_auctions_end ON auctions(end_time);
|
|
CREATE INDEX IF NOT EXISTS ix_auctions_product ON auctions(product_key);
|
|
CREATE INDEX IF NOT EXISTS ix_auctions_status ON auctions(status);
|
|
|
|
-- Ogni puntata di ogni utente, come la riporta Bidoo. Il prezzo dopo la
|
|
-- puntata la identifica: ogni puntata alza il prezzo di un centesimo esatto.
|
|
CREATE TABLE IF NOT EXISTS bids(
|
|
bid_id INTEGER PRIMARY KEY,
|
|
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
|
|
username TEXT NOT NULL,
|
|
price_after REAL NOT NULL,
|
|
bid_type TEXT NOT NULL DEFAULT '—',
|
|
is_mine INTEGER NOT NULL DEFAULT 0,
|
|
server_ts INTEGER,
|
|
seen_t REAL,
|
|
local_ts TEXT,
|
|
timer_before REAL,
|
|
rtt_ms INTEGER,
|
|
clock_offset_ms INTEGER,
|
|
UNIQUE(auction_id, price_after));
|
|
CREATE INDEX IF NOT EXISTS ix_bids_user ON bids(username);
|
|
CREATE INDEX IF NOT EXISTS ix_bids_server_ts ON bids(auction_id, server_ts);
|
|
|
|
-- Le interrogazioni, già coalescenti: una riga quando cambia qualcosa, e
|
|
-- comunque una al secondo perché il ping resti misurabile.
|
|
CREATE TABLE IF NOT EXISTS polls(
|
|
poll_id INTEGER PRIMARY KEY,
|
|
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
|
|
t REAL NOT NULL,
|
|
local_ts TEXT,
|
|
price REAL,
|
|
timer REAL,
|
|
status TEXT,
|
|
last_bidder TEXT,
|
|
is_mine INTEGER NOT NULL DEFAULT 0,
|
|
ping_ms INTEGER,
|
|
expiry_unix INTEGER,
|
|
server_unix INTEGER,
|
|
clock_offset_ms INTEGER);
|
|
CREATE INDEX IF NOT EXISTS ix_polls_auction ON polls(auction_id, t);
|
|
|
|
-- Un reset del timer: chiude un ciclo, di cui si sa fin dove è sceso il
|
|
-- cronometro e quanto è durato.
|
|
CREATE TABLE IF NOT EXISTS resets(
|
|
reset_id INTEGER PRIMARY KEY,
|
|
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
|
|
t REAL NOT NULL,
|
|
local_ts TEXT,
|
|
reset_count INTEGER,
|
|
price REAL,
|
|
bidder TEXT,
|
|
min_timer REAL,
|
|
cycle_seconds REAL);
|
|
CREATE INDEX IF NOT EXISTS ix_resets_auction ON resets(auction_id, t);
|
|
|
|
-- Le nostre puntate, con anticipo voluto e ottenuto: la coppia su cui si
|
|
-- tara il modello di latenza.
|
|
CREATE TABLE IF NOT EXISTS my_bids(
|
|
id INTEGER PRIMARY KEY,
|
|
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
|
|
t REAL NOT NULL,
|
|
local_ts TEXT,
|
|
price REAL,
|
|
planned_lead_ms INTEGER,
|
|
actual_lead_ms REAL,
|
|
ping_ms INTEGER,
|
|
rtt_ms INTEGER,
|
|
success INTEGER NOT NULL DEFAULT 0,
|
|
error TEXT,
|
|
bids_used INTEGER,
|
|
remaining_bids INTEGER,
|
|
decision_id INTEGER);
|
|
CREATE INDEX IF NOT EXISTS ix_my_bids_auction ON my_bids(auction_id, t);
|
|
|
|
-- Il registro dell'asta (strategie, avvisi, errori), riga per riga.
|
|
CREATE TABLE IF NOT EXISTS auction_log(
|
|
id INTEGER PRIMARY KEY,
|
|
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
|
|
t REAL NOT NULL,
|
|
local_ts TEXT,
|
|
level TEXT,
|
|
category TEXT,
|
|
msg TEXT);
|
|
CREATE INDEX IF NOT EXISTS ix_log_auction ON auction_log(auction_id, t);
|
|
|
|
-- Ogni decisione del motore, in Osserva (shadow) come in Attiva (live):
|
|
-- cosa avrebbe fatto o ha fatto, con che stima e per quale motivo.
|
|
CREATE TABLE IF NOT EXISTS bot_decisions(
|
|
decision_id INTEGER PRIMARY KEY,
|
|
auction_id TEXT NOT NULL,
|
|
ts TEXT NOT NULL,
|
|
t REAL,
|
|
price REAL,
|
|
timer REAL,
|
|
action TEXT NOT NULL,
|
|
p_win REAL,
|
|
ev_estimate REAL,
|
|
model_version TEXT,
|
|
policy TEXT,
|
|
regime TEXT,
|
|
mode TEXT NOT NULL,
|
|
executed INTEGER NOT NULL DEFAULT 0,
|
|
outcome TEXT,
|
|
reason_detail TEXT,
|
|
state_json TEXT,
|
|
context_key TEXT,
|
|
alt_policy TEXT,
|
|
alt_action TEXT,
|
|
alt_p REAL);
|
|
CREATE INDEX IF NOT EXISTS ix_decisions_auction ON bot_decisions(auction_id);
|
|
CREATE INDEX IF NOT EXISTS ix_decisions_ts ON bot_decisions(ts);
|
|
|
|
-- Misure di rete aggregate a finestre di dieci secondi.
|
|
CREATE TABLE IF NOT EXISTS network_metrics(
|
|
id INTEGER PRIMARY KEY,
|
|
ts TEXT NOT NULL,
|
|
rtt_ms INTEGER,
|
|
jitter_ms INTEGER,
|
|
p95_ms INTEGER,
|
|
clock_offset_ms INTEGER,
|
|
samples INTEGER,
|
|
source TEXT);
|
|
CREATE INDEX IF NOT EXISTS ix_network_ts ON network_metrics(ts);
|
|
|
|
-- Chi punta: aggregato asta per asta alla chiusura.
|
|
CREATE TABLE IF NOT EXISTS bidders(
|
|
username TEXT PRIMARY KEY,
|
|
first_seen TEXT,
|
|
last_seen TEXT,
|
|
n_auctions INTEGER NOT NULL DEFAULT 0,
|
|
n_bids INTEGER NOT NULL DEFAULT 0,
|
|
n_auto INTEGER NOT NULL DEFAULT 0,
|
|
n_manual INTEGER NOT NULL DEFAULT 0,
|
|
win_count INTEGER NOT NULL DEFAULT 0,
|
|
aggressiveness_score REAL,
|
|
profile_class TEXT);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions(
|
|
session_id INTEGER PRIMARY KEY,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
username TEXT,
|
|
app_version TEXT,
|
|
notes TEXT);
|
|
|
|
-- Uscite ed entrate in euro, per il gestore del rischio: ogni puntata
|
|
-- riuscita è una riga, ogni vincita è una riga.
|
|
CREATE TABLE IF NOT EXISTS risk_ledger(
|
|
id INTEGER PRIMARY KEY,
|
|
ts TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
amount_euro REAL NOT NULL,
|
|
auction_id TEXT,
|
|
note TEXT);
|
|
CREATE INDEX IF NOT EXISTS ix_ledger_ts ON risk_ledger(ts);
|
|
""";
|
|
}
|
|
}
|