18 Commits
Author SHA1 Message Date
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
Alby96andClaude Fable 5.1 66a1d23d87 Nel motivo della decisione stampa la probabilita, non il valore atteso
La riga «valore atteso +11,99 € (P 1.199,10%)» nel registro e nelle decisioni
mostrava il valore atteso al posto di P(senza risposta).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:02:02 +02:00
Alby96andClaude Fable 5.1 7993ca44a0 Database SQLite al posto dei dossier, gestore del rischio, decisioni in shadow, rete neurale, bandit e simulatore
Le aste non si salvano più in un file JSON per asta più tre archivi da tenere allineati:
un solo database SQLite (winsqlite3.dll di Windows via P/Invoke, nessun pacchetto) con
prodotti, aste, puntate, interrogazioni coalescenti, reset con profondità del ciclo,
nostre puntate, decisioni del motore, misure di rete, puntatori, sessioni e contabilità.
Scrittura in coda su un thread dedicato, letture in WAL. Per scelta dell'utente si parte
da zero: il database si riempie man mano che si osservano le aste; i vecchi dossier
restano leggibili per un'importazione futura ma non si scrivono più. Storico, schede,
apprendimento, rigiocata ed esportazioni leggono tutti da lì.

Il documento di progetto (Modifiche.txt) tradotto in C# senza librerie:
- RiskManager: kill-switch (file KILL_SWITCH, anche dalla barra), HALT persistente per
  stop-loss e drawdown, tetto del giorno, aste in gioco insieme, contabilità in euro.
- Theory: valore atteso con fee, spedizione, valore reale per prodotto (nuova colonna
  nella scheda Prodotti) e copertura «Compralo Ora»; null-model; sopravvivenza empirica e
  Kaplan-Meier dai reset; arrivi di Poisson.
- Shadow per asta: in Osserva il cecchino arriva allo stesso istante, registra cosa
  avrebbe fatto e non punta; alla chiusura ogni decisione riceve l'esito, e ShadowReport
  confronta le policy sugli stessi istanti con intervallo di confidenza sul ROI.
- NeuralNet (MLP, Adam) come sfidante del logistico, scelto dal Brier prequenziale;
  ThompsonBandit come seconda policy che impara dagli esiti delle decisioni;
  PennyAuctionEnv con avversari tarati sul database e QLearningAgent conservativo;
  SimulationLab per il confronto fra policy; esportazione CSV delle decisioni con
  reason_detail. Tutto nella scheda Apprendimento.

Ml/LEGGIMI.md riscritto con le avvertenze su termini d'uso, quadro legale e realtà
economica, lo schema del database e lo stato delle milestone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:00:49 +02:00
Alby96andClaude Fable 5.1 8b7c39c9e3 Sostituisci le euristiche a soglia con regime e anticipo adattivi; un solo archivio per asta
Le strategie a numero fisso (calore, ritiro morbido, anti-bot, avversari aggressivi,
puntata probabilistica, velocità del prezzo, esaurimento, concorrenza) e le impostazioni
morte (cadenze di polling, finestra critica, database, log avanzati, suggerimenti
sull'anticipo, schede dettagliate) non ci sono più: rigiocate sui dossier non fermavano
una puntata sbagliata senza fermarne anche di giuste, e i loro numeri andavano tarati a
mano. Restano i paletti dell'utente (tetti, budget, fascia oraria) e il duello.

Al loro posto due componenti che imparano dalla sessione in corso, dentro i paletti:
- CompetitionRegime per asta (Calmo / Sfogo / Sondaggio) guidato dal valore atteso
  appreso: tre negativi di fila e si lascia sfogare gli altri, si rientra con una
  puntata di prova dopo abbastanza cicli buoni, e se viene coperta subito la pazienza
  raddoppia. È il "capire da soli quando tornare a puntare".
- LatencyModel per l'anticipo: margine + p99 della latenza misurata adesso, tenuto fra
  LeadMinMs e LeadMaxMs; una puntata tardiva alza il margine subito, venti in tempo lo
  abbassano piano. Un anticipo scritto a mano su un'asta vince sempre (BidLeadIsManual).

Archiviazione: l'archivio mensile aste-AAAA-MM.jsonl (95 MB) duplicava il riepilogo che
ogni dossier ha già in coda. AuctionDetailStore ora legge testa e coda dei dossier, con
cache: 6349 file in 6 s la prima volta. Pulsanti per svuotare le esportazioni e per
azzerare le statistiche voce per voce (lo storico passa dalla copia di sicurezza).

Scheda Apprendimento: sezione "Autonomia sul momento" con il modello di latenza e il
regime di ogni asta seguita. Ml/LEGGIMI.md descrive l'algoritmo per intero.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 00:11:49 +02:00
Alby96andClaude Fable 5.1 afb778ea26 Scheda Apprendimento: cosa sa il modello, cosa ha deciso, e la valutazione dall'app
Una scheda nuova nella barra laterale, fra Esporta e Impostazioni, che legge
direttamente dal servizio e si aggiorna da sola ogni cinque secondi mentre e'
visibile: nessun altro pezzo dell'applicazione sa che esiste.

Mostra: lo stato (aste apprese, esempi visti, aste nel profilo, fattore di
calibrazione, soglia per decidere, se lo studio dei dossier e' in corso); i pesi
dal piu' forte con il verso dell'effetto, perche' le variabili sono a fasce e il
modello e' lineare proprio per poterli leggere; le ultime decisioni date al
motore, con probabilita', valore atteso ed esito (fermata, passa, o solo parere
se il modello non aveva ancora appreso abbastanza); il profilo per prodotto e
fascia oraria; e l'ultima valutazione.

La valutazione si lancia dalla scheda, in sottofondo e annullabile. La logica e'
uscita dal test ed e' entrata in LearningEvaluator, condivisa fra il target da
riga di comando e la scheda: i numeri che vede l'utente sono gli stessi che vede
chi sviluppa. "Ricomincia da capo" butta modello, profilo ed elenco dei dossier
letti e ristudia tutto l'archivio, con una conferma prima.

Le decisioni si tengono in una coda corta; lo stesso giro di un'asta ne
produrrebbe molte uguali di fila, quindi si registra solo il cambiamento.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 23:09:03 +02:00
Alby96andClaude Fable 5.1 46134c822d Apprendimento sempre acceso: impara da ogni asta chiusa e decide col valore atteso
Via le due modalita' Addestramento/Gara, per scelta dell'utente: la misura aveva
detto che il lavoro di registrazione costa mezzo punto di un core, e allora tanto
vale registrare sempre. Resta la coalescenza verso l'interfaccia, che era la
correzione vera.

COSA IMPARA. Per ogni puntata di ogni asta chiusa: e' rimasta senza risposta?
E' l'unica etichetta abbondante — 1,8 milioni di esempi sui dossier locali —
contro le diciannove aste vinte dall'utente, che non farebbero un modello. Il
comportamento avversario non dipende da chi ha puntato, e questo rende
utilizzabili le puntate altrui. Regressione logistica online scritta a mano,
senza librerie, in Ml/: un passo di gradiente per esempio, aggiornata a ogni
chiusura, salvata subito. Variabili a fasce: durata del ciclo, quota di
autopuntate recenti, puntatori distinti, prezzo sul valore, fascia oraria (le
fasce ricalcano la curva misurata sulle chiusure), feriale/festivo, ora di
aggancio, tipo di puntata, profondita' dell'asta, stesso puntatore di due fa,
prodotto in 32 cassetti con hash stabile. In parallelo un profilo per prodotto x
fascia oraria x tipo di giorno, con restringimento verso il prodotto e poi verso
il globale dove i dati scarseggiano.

COME DECIDE. In ShouldPlaceBid: probabilita' appresa per il margine residuo meno
il costo della puntata; sotto zero non si punta, e il registro riporta i numeri.
Il modello parla sempre ma decide solo dopo 300 aste apprese. Le previsioni sono
calibrate in linea con il rapporto osservato/previsto sugli ultimi 20.000 esempi.

QUANTO VALE, MISURATO. Valutazione prequenziale sui 3000 dossier piu' recenti
(ogni asta prima prevista poi appresa, come nell'uso): sollevamento 10,8x nella
cima dell'1%, 5,8x al 5%; calibrazione entro il 10-30% in ogni fascia. Sulle 116
puntate vere dell'utente di agosto: nessuna era finale, e il cancello ne avrebbe
fermate 45 senza perderne una vincente. La valutazione congelata (addestra sui
vecchi, giudica sui nuovi) sovrastima di 1,5-1,8x per deriva del mercato, non per
passo o regolarizzazione: provati entrambi. Griglia e prequenziale stanno nel
test MlModelBacktest, target Apprendimento, attivita' VS Code "valuta
apprendimento".

PRIMO ADDESTRAMENTO. In sottofondo, dal piu' recente, con un tempo massimo per
avvio. Il lettore salta le righe di poll prima del JSON: 6289 dossier per 8,8 GB
in 64 secondi, verificato dal vivo, archivio completo al primo avvio.

ACQUISIZIONE. I poll nel dossier si scrivono solo quando cambia qualcosa di
visibile, e comunque uno al secondo: -88,8% misurato, senza perdere la
profondita' di nessun ciclo. Il reset porta il minimo del cronometro e la durata
del ciclo chiuso. Via le righe di log che copiavano i reset parola per parola.
Corretto un difetto vecchio: nessun dossier marcava le puntate nostre, perche' il
parser lo faceva solo se riceveva il nome utente e nel percorso dei poll non gli
arrivava. Ora si marcano in MergeBidHistory e l'intestazione porta il nome
utente; per i dossier vecchi il lettore accetta il nome da fuori.

Trovato dal vivo e corretto: salvataggi concorrenti all'avvio quando piu' aste
chiudono insieme. Il modello salvato non rilegge piu' i parametri (passo,
regolarizzazione): sono scelte del codice, e un cambio vale dal riavvio dopo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:45:49 +02:00
Alby96andClaude Fable 5.1 08c5023f77 Documenti per chi installa da zero, pulizia per prodotto ed esborso, modalita' Gara
Tre richieste, piu' la causa vera della lentezza trovata misurando.

PERCORSI. Le impostazioni e i dati vanno in Documenti\AutoBidder per chi installa
da zero; sessione, riferimenti del sito e profilo del browser restano in
%LocalAppData%, perche' la sessione e' protetta con DPAPI e legata all'utente
Windows. Chi ha gia' un'installazione non viene spostato: se le impostazioni
esistono in %LocalAppData% e non in Documenti si continua a usare quelle dove
sono (scelta esplicita dell'utente, per non toccare mesi di raccolta). La radice
si decide una volta sola all'avvio e non cambia piu' nel processo. La suite di
test la dirotta in una cartella temporanea: senza, un test che tocca le
impostazioni leggerebbe quelle vere.

PULIZIA. Tre criteri nuovi: per prodotto (elenco con i conteggi, confronto senza
maiuscole), solo osservate / con mie puntate (i due versi: storico compatto delle
proprie partite, o storico "di mercato" senza le proprie mosse), prezzo finale
oltre una percentuale del valore (le chiusure incaponite spostano medie e limiti
consigliati verso l'alto per tutti; senza valore noto l'asta resta, non si
indovina). Dopo ogni pulizia le statistiche per prodotto si ricostruiscono da
zero dallo storico rimasto: sono un archivio a parte alimentato asta per asta, e
avrebbero continuato a descrivere aste che non ci sono piu'.

MODALITA'. Addestramento registra tutto; Gara toglie i poll grezzi nel dossier,
le righe informative del registro dell'asta e il dettaglio esteso. Letta una
volta all'avvio e immutabile fino alla chiusura, come chiesto: la scheda mostra
la modalita' in vigore e avvisa del riavvio solo se la scelta differisce.

Misurato prima di promettere: il lavoro che Gara evita costa 4,9 microsecondi
per poll. A 57 aste per 17 poll al secondo sono 4,7 ms di CPU al secondo, mezzo
punto di un core. La scrittura su disco era gia' asincrona. Gara fa bene al disco
e ai dossier, non alla velocita'.

LA LENTEZZA VERA. Ogni poll arrivava al thread dell'interfaccia: un
BeginInvoke, una scansione lineare dei ViewModel e venti notifiche di
proprieta'. Con trenta aste diecimila notifiche al secondo, con cinquantasette
quasi ventimila. Adesso si manda all'interfaccia solo il poll in cui cambia
qualcosa di visibile (prezzo, puntatore, stato, secondo intero, se e' mio), e
comunque uno al secondo per asta perche' ping e contatori si rinfreschino. Il
conto alla rovescia scorre gia' da solo con il battito da 200 ms. Il motore non
e' toccato: la decisione di puntare vede ogni poll come prima.

Misurato su 40 dossier veri, 146.138 poll: ne passano 16.375, l'88,8% viene
scartato. Nove volte meno lavoro sul thread dell'interfaccia.

Nella scheda Impostazioni entrano anche la fascia oraria e la pulizia
dell'elenco, che il motore usava gia' ma che non si potevano ancora cambiare.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 18:39:19 +02:00
Alby96andClaude Opus 5 4e835e220a Togli da sole le aste concluse, ma trattieni quelle costate qualcosa
Con l'aggiunta automatica accesa le aste concluse si accumulano a centinaia e
seppelliscono quelle vive, che e' il contrario di quello che serve guardando un
cruscotto. Il pulsante manuale "rimuovi le aste concluse" c'era gia', ma toglieva
tutto senza distinguere.

La rimozione automatica e' accesa di serie e toglie solo le aste che non hanno
piu' niente da dire. Restano invece, ciascuna con la sua opzione:

- quelle su cui si e' puntato, perche' sono le uniche che vale la pena riguardare
  e i soldi erano veri;
- quelle vinte, perche' c'e' da confermare l'acquisto su Bidoo e una vittoria
  sparita dall'elenco e' il modo piu' semplice di dimenticarsene;
- quelle di cui non si e' vista la fine, perche' sono esattamente quelle da
  controllare.

L'ordine dei controlli conta: prima le ragioni per trattenere, cosi' il motivo
scritto nel registro e' il piu' importante e non l'ultimo incontrato. Una vinta su
cui si e' anche puntato resta "vinta", che e' quella che richiede un'azione. Il
motivo finisce nel registro anche quando l'asta resta: senza, un'asta che sparisce
e una che non sparisce sarebbero entrambe inspiegabili.

La rimozione avviene come ultimo passo, dopo che storico, dossier e statistiche
del prodotto sono gia' scritti: a quel punto togliere l'asta dall'elenco non perde
niente.

Corretto anche un difetto introdotto ieri con la fascia oraria: la rigiocata sui
dossier usava le impostazioni vere, fascia compresa, e la fascia guarda l'orologio
di parete. Una rigiocata ripercorre aste gia' concluse e l'ora in cui la si lancia
non c'entra nulla con l'ora in cui quelle aste correvano: lanciata di notte
avrebbe rifiutato ogni puntata e riportato zero, che e' il tipo di risultato che
sembra vero e non lo e'. Adesso la rigiocata lavora su una copia delle
impostazioni con la fascia spenta, senza mutare quelle dell'utente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:39:00 +02:00
Alby96andClaude Opus 5 480e423124 Togli la pubblicazione dalla obj/ condivisa: rompeva la compilazione di debug
Sintomo: una raffica di CS2001 "file di origine MainWindow.g.cs non trovato" su
otto file .g.cs, lanciando la normale compilazione di debug da VS Code, senza che
il codice fosse cambiato. Il classico guasto intermittente della compilazione WPF.

Causa: il target Pubblica usava la sola opzione -o, che sposta il risultato ma
lascia gli INTERMEDI nella obj/ del progetto, cioe' la stessa cartella che usa la
compilazione di debug. La compilazione WPF genera un progetto temporaneo
_wpftmp.csproj a ogni giro, e quando ci trova dentro lo stato di un'altra
configurazione smette di produrre le classi parziali dello XAML. Bastava
pubblicare mentre si compilava in VS Code, o subito prima, per rompere tutto.

Era gia' il rimedio adottato per i test, dove artifacts-path era stato messo
proprio per questo; il target Pubblica era rimasto indietro. Le due opzioni
convivono: artifacts-path sposta gli intermedi, -o continua a decidere dove
finisce l'eseguibile.

Verificato ripetendo tre volte la sequenza che rompeva - pubblica, poi compila in
debug - con la obj/ svuotata all'inizio: zero errori tutte e tre le volte, e la
pubblicazione non crea piu' obj/Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:27:40 +02:00
Alby96andClaude Opus 5 48096c7fc7 Porta avanti le impostazioni vecchie, o le correzioni di oggi non arrivano
Trovato provando davvero l'applicazione, non dai test. Il settings.json esistente
conteneva MaxRequestsPerSecond 40 e DefaultBidBeforeDeadlineMs 1000, e il valore
su disco vince sempre sui predefiniti: installando la versione nuova, le due
correzioni che pesano di piu' - tetto delle richieste tolto e anticipo a mezzo
secondo - sarebbero state annullate dal file dell'utente, che avrebbe concluso
che non funzionano.

Che il file vinca e' giusto: sono scelte di chi usa il programma. Ma quei due
valori non erano scelte, erano conseguenze di misure sbagliate. Erano stati
tarati quando il tetto stesso accodava le chiamate e gonfiava il ping a 444 ms al
p99,9; senza tetto il ping misurato sta fra 47 e 78 ms.

Migrazione una tantum, con un numero di schema nel file perche' giri una volta
sola. Tocca solo chi ha ancora esattamente il vecchio valore: chi lo aveva gia'
cambiato di suo se lo tiene, perche' quella e' una scelta e non spetta a un
aggiornamento ribaltarla. Il salvataggio avviene dentro Load, che di norma non
tocca il disco, ma una migrazione capita una volta nella vita del file e
lasciarla non salvata la farebbe ripetere a ogni avvio.

Verificato dal vivo sull'eseguibile pubblicato: 40 diventa 0, 1000 diventa 500,
lo schema passa da assente a 1, e le impostazioni nuove (fascia oraria, ritiro
dai duelli) compaiono coi valori giusti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:16:54 +02:00
Alby96andClaude Opus 5 8b9691d692 Sospendi le puntate nella fascia oraria in cui costano il doppio
Su 6060 aste concluse con prezzo e valore noti, la stessa asta non costa uguale a
tutte le ore. Prezzo di chiusura mediano in percentuale del valore del prodotto:
4,6% alle 12 e 5,3% fra le 10 e le 13, contro 8,6% a mezzanotte e 9,7% alle 9. Le
puntate necessarie per vincere seguono la stessa curva: 21-24 nelle ore buone,
26-29 in quelle cattive. Quasi il doppio del costo per lo stesso oggetto.

Il motivo e' che di notte Bidoo sospende le puntate: restano poche aste in corso e
la concorrenza si concentra li'. Le ore di confine, mezzanotte e le nove, sono le
peggiori, perche' c'e' ancora qualcosa da giocare e ci sono tutti addosso.

La fascia predefinita e' 00:00-10:00. Sospende le puntate, NON il monitoraggio:
l'asta resta in stato Attiva, continua a essere seguita e riprende a puntare da
sola alla fine della fascia. E' voluto: le aste lunghe attraversano la fascia, e
fermarle davvero significherebbe perderle invece che risparmiare. Ogni puntata
saltata scrive nel registro il motivo e l'orario di ripresa.

Tre livelli di override, dal piu' specifico al piu' generale: la singola asta
scavalca il prodotto, il prodotto scavalca le impostazioni generali. Un null non
vuol dire "no" ma "non ho un'opinione", ed e' la differenza che permette di
spegnere il filtro su una sola asta senza toccare il resto.

Una fascia a cavallo di mezzanotte (22-6) funziona; inizio uguale a fine vuol dire
filtro spento, che e' l'unica lettura sensata visto che l'alternativa sarebbe non
puntare mai.

Backtest della regola sul duello, completato su tutte le soglie. Sui dossier veri
in cui il bot ha puntato: 13 aste, 366 puntate, zero vittorie. Con soglia 5 le
puntate sarebbero state 49, risparmiate 317 (86,6%), nessuna vittoria persa. Il
risultato tiene da soglia 2 a soglia 10, che e' quanto ci si puo' aspettare da un
campione in cui non si era vinto niente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:50:06 +02:00
Alby96andClaude Opus 5 5bac29b0af Densita' della griglia e suggerimento dell'anticipo allineato ai dati nuovi
Il pannello della singola asta consigliava di tenere l'anticipo fra 600 e 2000 ms
e proponeva 800: numeri nati da misure fatte quando il tetto di 40 richieste al
secondo accodava le chiamate e gonfiava il ping. Adesso dice quello che i dati
dicono davvero: tre quarti delle puntate avversarie sono automatiche e scattano a
2 secondi, sotto quella soglia resta solo chi punta a mano, e il predefinito e'
500 ms perche' e' circa nove volte il ping misurato.

Righe della griglia da 28 a 22 px con carattere piccolo: su un cruscotto che
serve a tenere d'occhio molte aste insieme l'altezza e' la risorsa scarsa, e sei
pixel di aria per riga costano una decina di aste sullo schermo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:31:36 +02:00
Alby96andClaude Opus 5 9b94e2d405 Ritirati dai duelli con l'autopuntata, e punta a mezzo secondo
La strategia A, dai numeri dei dossier: 5410 aste, 2,08 milioni di puntate.

Bidoo dichiara per ogni puntata se e' automatica o manuale. Le automatiche sono
il 75,3% del totale ma vincono il 46,0% delle aste; le manuali sono il 24,7% e ne
vincono il 53,9%. Una puntata manuale vale 3,6 volte una automatica: 178 puntate
per vittoria contro 634.

Il meccanismo del duello e' stato ricostruito esattamente. L'intervallo mediano
fra due puntate consecutive e' 6,0 s su 2.799.681 intervalli: il timer si azzera
a ~8 s e l'autopuntata scatta a 2 s, quindi 8-2=6. Il ciclo da 13,0 s osservato
nelle prove reali era la somma di 7 s di attesa nostra e 6 s della sua risposta.
Puntare tardi non evita l'autopuntata avversaria: la sveglia, perche' si riarma
quando il suo padrone perde la testa della classifica, non a un orario fisso.

Contro una macchina cosi' non esiste attrito vincibile. Adesso il motore conta le
risposte con la firma dei 6+/-2,5 s e dopo cinque di fila smette di puntare su
quell'asta. Una risposta fuori firma azzera il conteggio invece di scalarlo:
l'errore costoso e' ritirarsi da un'asta che si poteva vincere, non spendere
cinque puntate di troppo.

Rigiocato sui dossier veri in cui il bot ha puntato: 366 puntate spese, 13 aste,
zero vittorie. Con la regola a soglia 3 sarebbero state 31 puntate, 335
risparmiate (91,5%), nessuna vittoria persa.

L'anticipo passa da 1000 a 500 ms. Le due ragioni per stare larghi sono cadute:
il p99,9 del ping a 444 ms era misurato col tetto di 40 richieste al secondo che
accodava le chiamate, e senza tetto il ping sta fra 47 e 78 ms; e la concorrenza
sotto i due secondi non e' fitta ma rada, visto che tre quarti degli avversari
sono autopuntate che hanno gia' sparato.

Il tipo di puntata non viene piu' inventato. In due punti il motore ricostruiva
una puntata da un cambio di prezzo fra due interrogazioni e la marcava "Auto"
comunque: adesso quelle portano un trattino, perche' il tipo non lo ha detto
nessuno. La colonna in cronologia mostra quindi solo tipi dichiarati dal server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:20:34 +02:00
Alby96andClaude Opus 5 20434ca273 Riscatta aprendo il collegamento, e fai scorrere il conto alla rovescia
Due difetti che i registri di tre giorni mostravano chiaramente.

Il riscatto dei buoni giudicava la risposta del sito, e la giudicava male: ogni
singolo tentativo risultava "sessione scaduta: risposta la pagina di accesso" —
6 trovati, 0 riscossi, 6 non riusciti, giro dopo giro per tre giorni — mentre le
puntate arrivavano lo stesso. Il riconoscimento della pagina di accesso scattava
su pagine che di accesso non erano, e il servizio dichiarava di non funzionare
mentre funzionava.

Adesso aprire il collegamento e' il riscatto: si apre, si scrive che e' stato
aperto, e il numero vero lo da' la differenza di saldo, che e' l'unica misura che
non mente. Resta un solo fallimento possibile, l'errore di trasporto, dove il
collegamento non e' stato aperto affatto. Un premio si riscuote una volta sola:
fra i due errori, insistere su un premio gia' preso non costa niente, mentre
dichiarare falliti dei riscatti riusciti riempie il registro di allarmi inutili.
Il giudizio vecchio non e' stato cancellato, e' in GiudicaRisposta.

Il conto alla rovescia in griglia restava fermo fra un poll e l'altro. Il valore
era gia' giusto — si ricava dalla scadenza ancorata all'orologio del server, non
dall'ultimo poll — ma nessuno lo richiedeva finche' non arrivava una risposta, e
nelle prove reali fra un poll e l'altro passava anche un secondo e mezzo:
sembrava un'applicazione bloccata. Un battito da cinque volte al secondo notifica
ora le tre sole proprieta' che dipendono dal tempo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:29:05 +02:00
Alby96andClaude Opus 5 feb39ee2de Togli il tetto alle richieste e l'attesa fra i poll: era il collo di bottiglia
Misurato sui registri di tre giorni di prove reali (886 dossier, 4,86 milioni di
interrogazioni). Il tetto globale di 40 richieste al secondo veniva spartito fra
tutte le aste seguite, fino a 31 insieme: la distribuzione risultava tagliata
netta sul limite (p90 39, p99 41, saturo nell'8% dei secondi) e ogni singola asta
finiva interrogata ogni ~596 ms invece dei 220 configurati per la finestra
critica. Con il timer a un secondo restava un solo sguardo prima della scadenza,
e la puntata partiva su una stima della scadenza vecchia di mezzo secondo.

Le quattro soglie di cadenza (lontano/medio/vicino/critico) non descrivevano
quindi niente di reale: qualunque numero ci si scrivesse, il tetto decideva al
posto loro. Adesso non c'e' nessuna attesa fra un poll e il successivo: si
riparte appena la risposta e' arrivata, cioe' ~17 interrogazioni al secondo per
asta con il ping misurato di 57 ms. Resta la sola cadenza allargata per le aste
non ancora cominciate, che non e' una scelta di ritmo: fino all'apertura il
server non ha nulla di diverso da dire.

Senza tetto fisso serviva pero' qualcosa che reagisse, altrimenti l'unico segnale
di "troppe richieste" sarebbe stato un blocco dell'account. Il freno ora lo detta
il server: un 429 o un 503 mette in pausa il polling per un secondo, raddoppiando
fino a otto se insistono, e la pausa si azzera al primo giro riuscito. Le puntate
hanno priorita' Critical e non passano dal freno: se il momento giusto e' adesso,
una pausa preventiva costerebbe l'asta.

Il tetto resta configurabile per chi lo vuole, con 0 = nessun limite come nuovo
predefinito.

Scelta dell'utente, dopo avergli mostrato il rischio: niente tetto, il massimo
che regge la rete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 18:28:02 +02:00
Alby96andClaude Opus 5 1d578debe2 Togli dal percorso caldo la rilettura dell'intero storico
Sei modifiche chieste in questa sessione, di cui una pesa piu' di tutte le altre.

CompletedAuctionsStore rileggeva e rideserializzava l'intero file a ogni chiamata:
misurati 72 ms su 5876 record, 3,9 MB. Append pagava quel prezzo piu' la
riserializzazione e la riscrittura completa per cambiare un record solo, 120 ms
in tutto, e la chiamata arriva da un gestore di eventi di MainWindow, quindi dal
thread dell'interfaccia, che restava fermo a ogni asta conclusa. Il recupero
delle puntate dei vincitori lo faceva in un ciclo: 4675 aste erano 7,2 minuti di
solo rimescolamento JSON e 17,8 GB scritti sul disco.

Adesso c'e' una copia in memoria invalidata sulla data di modifica del file, cosi'
resta giusta anche se il file viene sostituito da fuori (ripristino di un backup),
e un AppendMany che applica molti record con un salvataggio solo, con un indice
per id perche' il caso per cui esiste non torni quadratico. Il recupero salva a
blocchi di cinquanta e deposita comunque quello che ha in mano quando esce, anche
per annullamento: le richieste gia' fatte sono state pagate al server.

Misurato sullo stesso file: 95 volte piu' veloce, 7,2 minuti diventano 4,5
secondi, 17,8 GB diventano 0,36 GB.

Le altre cinque:

- Il setup si chiama AutoBidder_<versione>.exe.
- I pulsanti di stop sono rossi, sia quello globale sia quelli per asta nella
  griglia: erano gli unici tre senza colore mentre avvia e osserva ce l'avevano.
- La pulizia dello storico sa togliere le aste seguite solo in parte. Non serve
  nessun campo nuovo: il prezzo finale in centesimi e' il totale delle puntate
  dell'asta, quindi il rapporto con i reset osservati dice quanta asta si e'
  vista, e la regola vale anche sullo storico gia' raccolto. Verificata sui dati
  veri: su 5876 aste nessuna supera copertura 1,02, che e' quanto ci si aspetta
  se l'identita' e' giusta.
- La griglia ricicla i contenitori di riga invece di ricrearli.
- Il costo puntata gia' esisteva col predefinito giusto: al suggerimento e' stato
  aggiunto perche' 0,20 e' il numero da tenere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:48:54 +02:00
Alby96andClaude Opus 5 5adc4a6526 Passa le note di rilascio dall'ambiente: -p: le spezzava sulle virgole
MSBuild divide il valore di una proprieta' sulle virgole: -p:Note=uno, due
diventa la proprieta' Note=uno piu' un'opzione ` due`, e si finisce su MSB1006
`proprieta' non valida`. Una nota di rilascio in italiano contiene quasi sempre
una virgola, quindi l'attivita' di rilascio si sarebbe rotta al primo uso reale
del prompt delle note.

Le note arrivano ora in AUTOBIDDER_NOTE, che MSBuild legge come proprieta' senza
passare dal parsing della riga di comando. Provato con virgole, apostrofi, due
punti e virgolette: arrivano integri. -p:Note= resta accettato per chi lo passa
a mano senza virgole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 23:02:18 +02:00
Alby96andClaude Opus 5 66e3af043c Riformatta tasks.json come lo scrive il formattatore di VS Code
Solo impaginazione: gli oggetti group e presentation passano da una riga a piu'
righe. Nessuna differenza di contenuto (git diff --ignore-all-space e' vuoto).
Committato per non lasciare l'albero sporco a ogni salvataggio del file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 23:00:08 +02:00
119 changed files with 13640 additions and 4707 deletions
+54 -8
View File
@@ -17,7 +17,10 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"group": { "kind": "build", "isDefault": true },
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": "$msCompile"
},
{
@@ -32,7 +35,10 @@
"-nologo",
"-v:m"
],
"group": { "kind": "test", "isDefault": true },
"group": {
"kind": "test",
"isDefault": true
},
"problemMatcher": "$msCompile"
},
{
@@ -47,7 +53,10 @@
"-nologo",
"-v:m"
],
"presentation": { "reveal": "always", "panel": "dedicated" },
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
},
{
@@ -63,7 +72,28 @@
"-nologo",
"-v:m"
],
"presentation": { "reveal": "always", "panel": "dedicated" },
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "valuta apprendimento",
"detail": "Addestra il modello sui dossier più vecchi e lo giudica sui più recenti: separazione, calibrazione, e cosa avrebbe fatto il cancello sulle tue puntate vere.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Apprendimento",
"-nologo",
"-v:m"
],
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
},
{
@@ -79,7 +109,10 @@
"-nologo",
"-v:m"
],
"presentation": { "reveal": "always", "panel": "dedicated" },
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": "$msCompile"
},
{
@@ -96,7 +129,10 @@
"-nologo",
"-v:m"
],
"presentation": { "reveal": "always", "panel": "dedicated" },
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": "$msCompile"
},
{
@@ -109,11 +145,21 @@
"${workspaceFolder}/build/Release.proj",
"-t:Rilascia",
"-p:Versione=${input:versione}",
"-p:Note=${input:note}",
"-nologo",
"-v:m"
],
"presentation": { "reveal": "always", "panel": "dedicated" },
// Le note passano dall'ambiente, non da -p:. MSBuild spezza il valore di
// una proprietà sulle virgole e una nota in italiano ne ha quasi sempre
// una: si otterrebbe MSB1006 «proprietà non valida». Vedi Release.proj.
"options": {
"env": {
"AUTOBIDDER_NOTE": "${input:note}"
}
},
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": "$msCompile"
}
],
+35 -10
View File
@@ -23,20 +23,32 @@ namespace AutoBidder
// Le impostazioni stanno nella radice fissa, quindi si possono leggere anche
// prima di sapere dove finiranno gli altri dati.
var settings = SettingsManager.Load();
AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder);
AppPaths.Configure(settings.DatabaseFolder);
AppPaths.EnsureFolders();
// Le versioni precedenti scrivevano parte dei dati in Roaming e parte in Local,
// senza sottocartelle: senza questo passaggio storico e aste sparirebbero.
AppPaths.MigrateLegacyFiles();
// I due database si aprono per primi: da qui in poi ogni archivio e ogni
// registro ci scrive. Quello d'esercizio per primo, perché il registro
// applicativo sta lì dentro.
Data.SqliteDatabase.OnLog += m => TextLogService.App("INFO", m);
try
{
var op = Data.OperationalDatabase.Instance;
TextLogService.SessionStarted(AppInfo.Version);
TextLogService.App("INFO", $"[DATABASE] Esercizio: {op.Path} ({op.SizeBytes / 1024:N0} KB)");
var db = Data.AuctionDatabase.Instance;
TextLogService.App("INFO", $"[DATABASE] Osservazioni: {db.Path} (SQLite {Data.SqliteDatabase.LibraryVersion}, {db.SizeBytes / 1024:N0} KB)");
db.Enqueue("INSERT INTO sessions(started_at, app_version) VALUES(?1, ?2)", Data.SqliteDatabase.Now(), AppInfo.Version);
}
catch (Exception ex)
{
TextLogService.App("ERROR", $"[DATABASE] Apertura non riuscita: {ex.Message}");
}
// I registri su file partono subito: quello che succede durante l'avvio è
// proprio ciò che non si riesce a leggere a video.
TextLogService.SessionStarted(AppInfo.Version);
TextLogService.PurgeOldLogs();
// Le righe di registro delle aste confluiscono nei rispettivi dossier.
AuctionDossier.CaptureAuctionLogs();
// Le righe di registro delle aste confluiscono nel database.
Data.AuctionRecorder.CaptureAuctionLogs();
// Lo storico e' cresciuto per mesi con versioni diverse dell'applicazione:
// alcuni record hanno campi che allora non esistevano. Quelli deducibili si
@@ -57,6 +69,11 @@ namespace AutoBidder
TextLogService.App("WARN", $"[STORICO] Riparazione non riuscita: {ex.Message}");
}
// L'apprendimento è sempre acceso: carica ciò che sa, ricostruisce il profilo
// dallo storico se manca, e in sottofondo legge i dossier non ancora appresi.
Ml.LearningService.OnLog += m => TextLogService.App("INFO", m);
Ml.LearningService.Start(settings);
// Applica il tema salvato (chiaro/scuro) prima di mostrare la finestra
ThemeManager.ApplyFromSettings();
}
@@ -66,7 +83,15 @@ namespace AutoBidder
// Quello che è ancora in coda va sul disco adesso: un registro che perde
// proprio le ultime righe è inutile esattamente quando serve di più.
TextLogService.SessionEnded();
FileLogWriter.DisposeAll();
try
{
var db = Data.AuctionDatabase.Instance;
db.Enqueue("UPDATE sessions SET ended_at = ?1 WHERE session_id = (SELECT MAX(session_id) FROM sessions)", Data.SqliteDatabase.Now());
Data.AuctionDatabase.CloseInstance();
Data.OperationalDatabase.CloseInstance();
}
catch { /* all'uscita non c'è più nessuno a cui dirlo */ }
// L'icona di notifica sopravvive al processo se non la si rimuove:
// resterebbe nell'area di notifica come icona fantasma.
+16 -13
View File
@@ -1,4 +1,4 @@
<UserControl x:Class="AutoBidder.Controls.AuctionMonitorControl"
<UserControl x:Class="AutoBidder.Controls.AuctionMonitorControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -60,11 +60,18 @@
<TextBlock Style="{StaticResource Glyph}" Text="&#xE769;"/>
</Button>
<Button x:Name="StopButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Ferma tutte: nessuna richiesta"
Click="StopButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE71A;"/>
</Button>
<ToggleButton x:Name="LearningToggle" Style="{StaticResource IconToggle}"
ToolTip="Apprendimento. ACCESO: il motore decide con il valore atteso appreso, il regime di concorrenza, il riconoscimento del duello e l'anticipo adattivo dalla rete. SPENTO: si punta solo entro i tuoi limiti — prezzo minimo e massimo, puntate, budget, fascia oraria — con l'anticipo fisso delle impostazioni, come un cecchino senza cervello. In entrambi i casi le aste vengono registrate e studiate."
Checked="LearningToggle_Changed" Unchecked="LearningToggle_Changed">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE99A;"/>
</ToggleButton>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="8,4"/>
</StackPanel>
@@ -174,10 +181,6 @@
ToolTip="Rimuovi tutte le aste" Click="RemoveAllButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE711;"/>
</Button>
<Button x:Name="ExportButton" Style="{StaticResource IconButton}"
ToolTip="Esporta le aste monitorate" Click="ExportButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74E;"/>
</Button>
</StackPanel>
</Grid>
</Border>
@@ -390,6 +393,7 @@
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE769;"/>
</Button>
<Button Style="{StaticResource MiniIconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Ferma"
IsEnabled="{Binding CanStop}"
Command="{Binding DataContext.GridStopCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
@@ -476,11 +480,6 @@
Click="CopyAuctionUrlButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8C8;" FontSize="13"/>
</Button>
<Button x:Name="ExportAuctionButton" Style="{StaticResource IconButton}"
ToolTip="Esporta i dati di questa asta"
Click="ExportAuctionButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74E;" FontSize="13"/>
</Button>
</StackPanel>
</Grid>
</Border>
@@ -524,14 +523,18 @@
<TextBlock Grid.Row="0" Grid.Column="0" Text="Anticipo puntata (ms)"
ToolTip="Quanti millisecondi prima della scadenza parte la puntata. È il parametro che decide quanto costa vincere: il motore punta solo nei cicli che arrivano fin qui senza che nessun altro abbia puntato.
Dalla rigiocata di 2.095 aste concluse: sotto il secondo basta in mediana 1 puntata per asta, sotto i 2 s ne servono 15, sotto i 3 s 46. Ma sotto i 600 ms un picco di rete fa arrivare la puntata a giochi chiusi. Tienilo fra 600 e 2000 ms, e comunque sopra il tuo ping medio."
Il 75,3% delle puntate avversarie e' automatica e scatta a 2 secondi esatti: sotto quella soglia resta solo chi punta a mano, e una puntata manuale vince 3,6 volte piu' spesso di una automatica (178 puntate per vittoria contro 634, su 5410 aste).
Predefinito 500 ms: circa nove volte il ping misurato (47-78 ms). Piu' in basso non conviene, perche' Bidoo dichiara la scadenza al secondo intero."
Style="{StaticResource FieldLabel}"/>
<TextBox Grid.Row="1" Grid.Column="0" x:Name="SelectedBidBeforeDeadlineMs"
Margin="0,0,10,2" Text="800"
Margin="0,0,10,2" Text="500"
TextChanged="SelectedBidBeforeDeadlineMs_TextChanged"
ToolTip="Quanti millisecondi prima della scadenza parte la puntata. È il parametro che decide quanto costa vincere: il motore punta solo nei cicli che arrivano fin qui senza che nessun altro abbia puntato.
Dalla rigiocata di 2.095 aste concluse: sotto il secondo basta in mediana 1 puntata per asta, sotto i 2 s ne servono 15, sotto i 3 s 46. Ma sotto i 600 ms un picco di rete fa arrivare la puntata a giochi chiusi. Tienilo fra 600 e 2000 ms, e comunque sopra il tuo ping medio."/>
Il 75,3% delle puntate avversarie e' automatica e scatta a 2 secondi esatti: sotto quella soglia resta solo chi punta a mano, e una puntata manuale vince 3,6 volte piu' spesso di una automatica (178 puntate per vittoria contro 634, su 5410 aste).
Predefinito 500 ms: circa nove volte il ping misurato (47-78 ms). Piu' in basso non conviene, perche' Bidoo dichiara la scadenza al secondo intero."/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="Prezzo minimo €"
ToolTip="Sotto questo prezzo non puntare. Serve a lasciar salire un'asta prima di entrare. 0 = nessun limite."
+14 -26
View File
@@ -67,6 +67,20 @@ namespace AutoBidder.Controls
RaiseEvent(new RoutedEventArgs(StopClickedEvent, this));
}
private void LearningToggle_Changed(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(LearningToggledEvent, this));
}
public static readonly RoutedEvent LearningToggledEvent = EventManager.RegisterRoutedEvent(
"LearningToggled", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public event RoutedEventHandler LearningToggled
{
add { AddHandler(LearningToggledEvent, value); }
remove { RemoveHandler(LearningToggledEvent, value); }
}
private void AddUrlButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(AddUrlClickedEvent, this));
@@ -82,11 +96,6 @@ namespace AutoBidder.Controls
RaiseEvent(new RoutedEventArgs(RemoveAllClickedEvent, this));
}
private void ExportButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ExportClickedEvent, this));
}
private void RemoveFinishedButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(RemoveFinishedClickedEvent, this));
@@ -323,10 +332,6 @@ namespace AutoBidder.Controls
RaiseEvent(new RoutedEventArgs(OpenAuctionExternalClickedEvent, this));
}
private void ExportAuctionButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ExportAuctionClickedEvent, this));
}
private void RefreshProductInfoButton_Click(object sender, RoutedEventArgs e)
{
@@ -396,9 +401,6 @@ namespace AutoBidder.Controls
public static readonly RoutedEvent RemoveAllClickedEvent = EventManager.RegisterRoutedEvent(
"RemoveAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent ExportClickedEvent = EventManager.RegisterRoutedEvent(
"ExportClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent AuctionSelectionChangedEvent = EventManager.RegisterRoutedEvent(
"AuctionSelectionChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
@@ -435,8 +437,6 @@ namespace AutoBidder.Controls
public static readonly RoutedEvent OpenAuctionExternalClickedEvent = EventManager.RegisterRoutedEvent(
"OpenAuctionExternalClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent ExportAuctionClickedEvent = EventManager.RegisterRoutedEvent(
"ExportAuctionClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent RefreshProductInfoClickedEvent = EventManager.RegisterRoutedEvent(
"RefreshProductInfoClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
@@ -493,12 +493,6 @@ namespace AutoBidder.Controls
remove { RemoveHandler(RemoveFinishedClickedEvent, value); }
}
public event RoutedEventHandler ExportClicked
{
add { AddHandler(ExportClickedEvent, value); }
remove { RemoveHandler(ExportClickedEvent, value); }
}
public event RoutedEventHandler AuctionSelectionChanged
{
add { AddHandler(AuctionSelectionChangedEvent, value); }
@@ -583,12 +577,6 @@ namespace AutoBidder.Controls
remove { RemoveHandler(OpenAuctionExternalClickedEvent, value); }
}
public event RoutedEventHandler ExportAuctionClicked
{
add { AddHandler(ExportAuctionClickedEvent, value); }
remove { RemoveHandler(ExportAuctionClickedEvent, value); }
}
public event RoutedEventHandler RefreshProductInfoClicked
{
add { AddHandler(RefreshProductInfoClickedEvent, value); }
-241
View File
@@ -1,241 +0,0 @@
<UserControl x:Class="AutoBidder.Controls.ExportControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ctl="clr-namespace:AutoBidder.Controls"
mc:Ignorable="d"
d:DesignHeight="800" d:DesignWidth="1200"
Background="{DynamicResource Brush.Bg}">
<UserControl.Resources>
<Style x:Key="Glyph" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
<Style x:Key="FilterLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Margin" Value="0,8"/>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Background="{DynamicResource Brush.Surface}" Padding="10,7"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="PreviewButton" Style="{StaticResource IconButton}"
ToolTip="Ricalcola quante aste corrispondono ai filtri"
Click="PreviewButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72C;"/>
</Button>
<TextBlock Text="Esporta" FontWeight="SemiBold" Margin="8,0,0,0"
FontSize="{StaticResource Font.Size.Lg}"
Foreground="{DynamicResource Brush.Text}" VerticalAlignment="Center"/>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="10,4"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Border Style="{StaticResource Pill}" ToolTip="Aste che corrispondono ai filtri">
<TextBlock x:Name="MatchCountText" Text="—"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.Text}"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Di queste, quelle seguite dall'inizio alla fine">
<TextBlock x:Name="CompleteCountText" Text="—"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Border>
</StackPanel>
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="ExportButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Content="Esporta in un file" Margin="0,0,8,0"
Click="ExportButton_Click"/>
<Button x:Name="OpenFolderButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Apri la cartella" Margin="0,0,8,0"
Click="OpenFolderButton_Click"/>
<Button x:Name="ResetFiltersButton" Style="{StaticResource IconButton}"
ToolTip="Azzera i filtri" Click="ResetFiltersButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE711;"/>
</Button>
</StackPanel>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel Margin="20,16" MaxWidth="1000" HorizontalAlignment="Left">
<!-- ═══ Cosa esporta ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Cosa finisce nel file"
Margin="0,0,0,12"
Help="Un unico file JSON con le aste scelte: esito, prezzo finale, valore del prodotto, puntate di ogni utente, ping e latenza, andamento del prezzo e — dove esiste il dossier — ogni singolo evento registrato mentre l'asta era in corso. Il file si spiega da sé: contiene la legenda dei campi, così resta leggibile anche fra sei mesi."/>
</StackPanel>
</Border>
<!-- ═══ Filtri ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<TextBlock Text="Filtri" Style="{StaticResource BlockTitle}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="230"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Periodo (chiusura)"
Style="{StaticResource FilterLabel}"/>
<StackPanel Grid.Row="0" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
<DatePicker x:Name="FromDatePicker" Width="150"
ToolTip="Dalla data di chiusura (vuoto = dall'inizio)"/>
<TextBlock Text="→" Margin="8,0" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.TextMuted}"/>
<DatePicker x:Name="ToDatePicker" Width="150"
ToolTip="Fino alla data di chiusura (vuoto = fino a oggi)"/>
<Button Content="Ultimi 7 giorni" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Margin="12,0,0,0" Click="Last7Days_Click"/>
<Button Content="Ultimi 30" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Margin="6,0,0,0" Click="Last30Days_Click"/>
</StackPanel>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Prodotto contiene"
Style="{StaticResource FilterLabel}"/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="NameFilterTextBox"
Width="320" HorizontalAlignment="Left" Margin="0,4"
ToolTip="Parte del nome del prodotto, senza distinzione fra maiuscole"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Prezzo finale (€)"
Style="{StaticResource FilterLabel}"/>
<StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
<TextBox x:Name="MinPriceTextBox" Width="100"
ToolTip="Prezzo finale minimo (vuoto = nessun limite)"/>
<TextBlock Text="→" Margin="8,0" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBox x:Name="MaxPriceTextBox" Width="100"
ToolTip="Prezzo finale massimo (vuoto = nessun limite)"/>
</StackPanel>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Tetto di aste"
Style="{StaticResource FilterLabel}"/>
<StackPanel Grid.Row="3" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
<TextBox x:Name="MaxAuctionsTextBox" Width="100" Text="0"
ToolTip="0 = tutte. Con gli eventi inclusi, cento aste sono già diversi megabyte."/>
<TextBlock Text="0 = tutte quelle che corrispondono" VerticalAlignment="Center"
Margin="10,0,0,0" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- ═══ Quali aste ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<TextBlock Text="Quali aste" Style="{StaticResource BlockTitle}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<CheckBox x:Name="IncludeWonCheckBox" IsChecked="True" Margin="0,4"
Content="Vinte" Checked="Filter_Changed" Unchecked="Filter_Changed"/>
<CheckBox x:Name="IncludeLostCheckBox" IsChecked="True" Margin="0,4"
Content="Perse (ci avevo puntato)" Checked="Filter_Changed" Unchecked="Filter_Changed"/>
<CheckBox x:Name="IncludeClosedCheckBox" IsChecked="True" Margin="0,4"
Content="Solo osservate (mai puntato)"
ToolTip="Non le ho giocate, ma dicono a quanto chiude il mercato"
Checked="Filter_Changed" Unchecked="Filter_Changed"/>
</StackPanel>
<StackPanel Grid.Column="1">
<CheckBox x:Name="OnlyCompleteCheckBox" Margin="0,4"
Content="Solo quelle seguite dall'inizio alla fine"
ToolTip="Sono le più preziose: su un'asta presa a metà non si sa quante puntate erano già state spese"
Checked="Filter_Changed" Unchecked="Filter_Changed"/>
<CheckBox x:Name="OnlyWithMyBidsCheckBox" Margin="0,4"
Content="Solo quelle su cui ho puntato"
Checked="Filter_Changed" Unchecked="Filter_Changed"/>
<CheckBox x:Name="OnlyWithDossierCheckBox" Margin="0,4"
Content="Solo quelle con il dossier degli eventi"
Checked="Filter_Changed" Unchecked="Filter_Changed"/>
</StackPanel>
</Grid>
<Border Background="{DynamicResource Brush.SurfaceAlt}" CornerRadius="8"
Padding="12,10" Margin="0,12,0,0">
<StackPanel>
<CheckBox x:Name="IncludeEventsCheckBox" IsChecked="True"
Content="Includi tutti gli eventi registrati (dossier completo)"
ToolTip="Senza, il file contiene solo i riepiloghi: molto più leggero, molto meno utile.&#10;&#10;Gli eventi sono ogni interrogazione a Bidoo, ogni puntata di ogni utente con l'ora al millisecondo e ogni mia puntata con anticipo pianificato ed effettivo. È il materiale con cui si tara l'anticipo; è anche ciò che fa pesare il file."/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- ═══ Esito ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<TextBlock Text="Ultima esportazione" Style="{StaticResource BlockTitle}"/>
<Border Background="{DynamicResource Brush.SurfaceAlt}" CornerRadius="8" Padding="12,10">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8A5;" FontSize="13"
Foreground="{DynamicResource Brush.Accent}" Margin="0,0,8,0"/>
<TextBlock x:Name="ResultText" VerticalAlignment="Center" TextWrapping="Wrap"
Text="Nessuna esportazione in questa sessione."
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</StackPanel>
</Border>
<Button x:Name="OpenLastFileButton" HorizontalAlignment="Left"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Apri il file" Margin="0,10,0,0"
Visibility="Collapsed" Click="OpenLastFileButton_Click"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
-192
View File
@@ -1,192 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using AutoBidder.Utilities;
namespace AutoBidder.Controls
{
/// <summary>
/// Scheda Esporta: sceglie quali aste finiscono in un unico file e lo scrive.
///
/// <para>Come gli altri pannelli non conosce gli archivi: costruisce un
/// <see cref="AuctionExportFilter"/> e lascia il lavoro a
/// <see cref="AuctionExporter"/>. Qui dentro c'è solo la traduzione fra i campi
/// dell'interfaccia e i criteri — e il conteggio in tempo reale, che serve a non
/// scoprire dopo un minuto di scrittura che il filtro non selezionava nulla.</para>
/// </summary>
public partial class ExportControl : UserControl
{
private string? _lastExportPath;
public ExportControl()
{
InitializeComponent();
Loaded += (_, _) => RefreshPreview();
}
/// <summary>I criteri come sono impostati adesso.</summary>
public AuctionExportFilter BuildFilter() => new()
{
From = FromDatePicker.SelectedDate,
To = ToDatePicker.SelectedDate,
NameContains = string.IsNullOrWhiteSpace(NameFilterTextBox.Text) ? null : NameFilterTextBox.Text.Trim(),
IncludeWon = IncludeWonCheckBox.IsChecked == true,
IncludeLost = IncludeLostCheckBox.IsChecked == true,
IncludeClosed = IncludeClosedCheckBox.IsChecked == true,
OnlyComplete = OnlyCompleteCheckBox.IsChecked == true,
OnlyWithMyBids = OnlyWithMyBidsCheckBox.IsChecked == true,
OnlyWithDossier = OnlyWithDossierCheckBox.IsChecked == true,
MinFinalPrice = ParseNumber(MinPriceTextBox.Text),
MaxFinalPrice = ParseNumber(MaxPriceTextBox.Text),
MaxAuctions = (int)(ParseNumber(MaxAuctionsTextBox.Text) ?? 0),
IncludeEvents = IncludeEventsCheckBox.IsChecked == true
};
/// <summary>
/// Ricalcola quante aste corrispondono. Si fa a ogni modifica dei filtri: sapere
/// prima che il risultato è vuoto — o che sono duemila — evita di scoprirlo a file
/// scritto.
/// </summary>
public void RefreshPreview()
{
try
{
var selected = AuctionExporter.Select(BuildFilter());
MatchCountText.Text = selected.Count == 1 ? "1 asta" : $"{selected.Count} aste";
var complete = selected.Count(r => r.IsComplete);
CompleteCountText.Text = $"{complete} complete";
ExportButton.IsEnabled = selected.Count > 0;
}
catch (Exception ex)
{
MatchCountText.Text = "—";
CompleteCountText.Text = ex.Message;
}
}
/// <summary>Mostra l'esito dell'esportazione appena fatta.</summary>
public void ShowResult(AuctionExportResult result)
{
if (result.Success)
{
_lastExportPath = result.Path;
var size = result.Bytes >= 1024 * 1024
? $"{result.Bytes / 1024.0 / 1024.0:F1} MB"
: $"{Math.Max(1, result.Bytes / 1024)} kB";
ResultText.Text = $"{result.AuctionCount} aste ({result.WithEvents} con eventi), {size} — {result.Path}";
ResultText.SetResourceReference(ForegroundProperty, "Brush.Text");
OpenLastFileButton.Visibility = Visibility.Visible;
}
else
{
ResultText.Text = result.Message;
ResultText.SetResourceReference(ForegroundProperty, "Brush.Warning");
OpenLastFileButton.Visibility = Visibility.Collapsed;
}
}
/// <summary>Percorso dell'ultimo file scritto, per il pulsante "Apri il file".</summary>
public string? LastExportPath => _lastExportPath;
// ── Eventi dell'interfaccia ──────────────────────────────────────
private void PreviewButton_Click(object sender, RoutedEventArgs e) => RefreshPreview();
private void Filter_Changed(object sender, RoutedEventArgs e) => RefreshPreview();
private void Last7Days_Click(object sender, RoutedEventArgs e) => SetPeriod(7);
private void Last30Days_Click(object sender, RoutedEventArgs e) => SetPeriod(30);
private void SetPeriod(int days)
{
FromDatePicker.SelectedDate = DateTime.Today.AddDays(-days);
ToDatePicker.SelectedDate = DateTime.Today;
RefreshPreview();
}
private void ResetFiltersButton_Click(object sender, RoutedEventArgs e)
{
FromDatePicker.SelectedDate = null;
ToDatePicker.SelectedDate = null;
NameFilterTextBox.Text = "";
MinPriceTextBox.Text = "";
MaxPriceTextBox.Text = "";
MaxAuctionsTextBox.Text = "0";
IncludeWonCheckBox.IsChecked = true;
IncludeLostCheckBox.IsChecked = true;
IncludeClosedCheckBox.IsChecked = true;
OnlyCompleteCheckBox.IsChecked = false;
OnlyWithMyBidsCheckBox.IsChecked = false;
OnlyWithDossierCheckBox.IsChecked = false;
IncludeEventsCheckBox.IsChecked = true;
RefreshPreview();
}
private void ExportButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ExportRequestedEvent, this));
private void OpenFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenExportFolderClickedEvent, this));
private void OpenLastFileButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenLastFileClickedEvent, this));
/// <summary>
/// Legge un numero accettando sia la virgola sia il punto: chi scrive "12,50" e chi
/// scrive "12.50" intende la stessa cosa, e rifiutarne uno dei due sarebbe pedanteria.
/// </summary>
private static double? ParseNumber(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
var clean = text.Trim().Replace(',', '.');
return double.TryParse(clean, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)
? value
: null;
}
// ── Routed events ────────────────────────────────────────────────
public static readonly RoutedEvent ExportRequestedEvent = EventManager.RegisterRoutedEvent(
"ExportRequested", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ExportControl));
public static readonly RoutedEvent OpenExportFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenExportFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ExportControl));
public static readonly RoutedEvent OpenLastFileClickedEvent = EventManager.RegisterRoutedEvent(
"OpenLastFileClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ExportControl));
public event RoutedEventHandler ExportRequested
{
add { AddHandler(ExportRequestedEvent, value); }
remove { RemoveHandler(ExportRequestedEvent, value); }
}
public event RoutedEventHandler OpenExportFolderClicked
{
add { AddHandler(OpenExportFolderClickedEvent, value); }
remove { RemoveHandler(OpenExportFolderClickedEvent, value); }
}
public event RoutedEventHandler OpenLastFileClicked
{
add { AddHandler(OpenLastFileClickedEvent, value); }
remove { RemoveHandler(OpenLastFileClickedEvent, value); }
}
}
}
+343
View File
@@ -0,0 +1,343 @@
<UserControl x:Class="AutoBidder.Controls.LearningControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ctl="clr-namespace:AutoBidder.Controls"
mc:Ignorable="d"
d:DesignHeight="800" d:DesignWidth="1200"
Background="{DynamicResource Brush.Bg}">
<UserControl.Resources>
<Style x:Key="Glyph" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
<Style x:Key="Stat" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
<Setter Property="FontSize" Value="{StaticResource Font.Size.Lg}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="StatLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.TextMuted}"/>
<Setter Property="FontSize" Value="{StaticResource Font.Size.Sm}"/>
<Setter Property="Margin" Value="0,2,0,0"/>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Background="{DynamicResource Brush.Surface}" Padding="10,7"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="RefreshButton" Style="{StaticResource IconButton}"
ToolTip="Aggiorna i numeri" Click="RefreshButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72C;"/>
</Button>
<TextBlock Text="Apprendimento" FontWeight="SemiBold" Margin="8,0,0,0"
FontSize="{StaticResource Font.Size.Lg}"
Foreground="{DynamicResource Brush.Text}" VerticalAlignment="Center"/>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="10,4"/>
<Border Style="{StaticResource Pill}" ToolTip="Aste da cui il modello ha imparato">
<TextBlock x:Name="PillAuctions" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.Text}"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Esempi (puntate) che hanno aggiornato i pesi">
<TextBlock x:Name="PillUpdates" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Fattore di calibrazione: osservato/previsto sugli ultimi esempi. 1 = scala giusta">
<TextBlock x:Name="PillCalibration" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Border>
<Border x:Name="PillReadyBorder" Style="{StaticResource Pill}"
ToolTip="Pronto = ha appreso abbastanza aste per fermare una puntata. Prima parla soltanto.">
<TextBlock x:Name="PillReady" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.Warning}"/>
</Border>
</StackPanel>
<!-- Solo icone, come nel monitor: il tooltip dice esattamente cosa succede. -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="EvaluateButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Accent}"
ToolTip="Valuta ora.&#10;Addestra sulle aste più vecchie del database e giudica sulle più recenti, mai viste. Poi la prova prequenziale: ogni asta prima prevista e poi appresa, come fa il motore. Qualche minuto in sottofondo; il rapporto compare nella scheda «Valutazione»."
Click="EvaluateButton_Click">
<TextBlock x:Name="EvaluateGlyph" Style="{StaticResource Glyph}" Text="&#xE9F5;"/>
</Button>
<Button x:Name="ExportDecisionsButton" Style="{StaticResource IconButton}"
ToolTip="Esporta le decisioni in CSV.&#10;Tutte le decisioni del motore, dal vivo e in shadow, con esito e motivazione per esteso (reason_detail). Punto e virgola, pronto per un foglio di calcolo. Chiede dove salvare il file."
Click="ExportDecisionsButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE9F9;"/>
</Button>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
<Button x:Name="RetrainButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Ricomincia da capo.&#10;Butta modello, profilo, sfidante, bandit e latenza appresa, e ristudia tutte le aste chiuse del database. Non tocca lo storico né i prodotti. Chiede conferma."
Click="RetrainButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE7A6;"/>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- ═══ Corpo ═══ -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<Grid Margin="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Stato -->
<Border Grid.Row="0" Grid.Column="0" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Stato" Margin="0,0,0,10"
Help="Il modello impara da ogni asta che chiude: per ogni puntata, è rimasta senza risposta? È l'unica etichetta abbondante — milioni di esempi — mentre le vittorie proprie sono poche decine. Il comportamento avversario non dipende da chi ha puntato, e questo rende utilizzabili le puntate altrui.&#10;&#10;Decide col valore atteso: probabilità appresa × margine residuo costo della puntata. Sotto zero non punta, e lo scrive nel registro con i numeri. Decide solo dopo la soglia di aste apprese: prima parla soltanto."/>
<UniformGrid Columns="3" Rows="2">
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatAuctions" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="aste apprese"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatUpdates" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="esempi visti"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatProfile" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="aste nel profilo"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatCalibration" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="calibrazione (1 = giusta)"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatThreshold" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="soglia per decidere"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatBootstrap" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="studio delle aste"/>
</StackPanel>
</UniformGrid>
<TextBlock x:Name="StatDecisions" Style="{StaticResource Hint}" Margin="0,10,0,0" Text=""/>
</StackPanel>
</Border>
<!-- Cosa ha imparato -->
<Border Grid.Row="0" Grid.Column="2" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Cosa ha imparato" Margin="0,0,0,10"
Help="I pesi del modello, dal più forte. Positivo = quella condizione rende più probabile che una puntata resti senza risposta; negativo = meno probabile. Le variabili sono a fasce e il modello è lineare proprio per poterli leggere così.&#10;&#10;Il prodotto è ridotto a 32 cassetti con una funzione di hash: due prodotti possono condividere un cassetto, ed è accettato."/>
<DataGrid x:Name="WeightsGrid" MaxHeight="260" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Condizione" Binding="{Binding Nome}" Width="*"/>
<DataGridTextColumn Header="Peso" Binding="{Binding PesoDisplay}" Width="Auto" MinWidth="70"/>
<DataGridTextColumn Header="Effetto" Binding="{Binding Effetto}" Width="Auto" MinWidth="110"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<!-- Ultime decisioni -->
<Border Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Ultime decisioni" Margin="0,0,0,10"
Help="Ogni volta che il motore era pronto a puntare ha chiesto al modello. P è la probabilità che la puntata resti senza risposta; il valore atteso è P × margine residuo costo. «Fermata» = il cancello ha detto no. «Parere» = il modello non aveva ancora appreso abbastanza per decidere, e si è limitato a dirlo."/>
<DataGrid x:Name="DecisionsGrid" MaxHeight="280" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Ora" Binding="{Binding Ora}" Width="Auto" MinWidth="80"/>
<DataGridTextColumn Header="Asta" Binding="{Binding Asta}" Width="*"/>
<DataGridTextColumn Header="Prezzo" Binding="{Binding Prezzo}" Width="Auto" MinWidth="70"/>
<DataGridTextColumn Header="P senza risposta" Binding="{Binding Prob}" Width="Auto" MinWidth="110"/>
<DataGridTextColumn Header="Valore atteso" Binding="{Binding Ev}" Width="Auto" MinWidth="100"/>
<DataGridTextColumn Header="Esito" Binding="{Binding Esito}" Width="Auto" MinWidth="90"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<!-- Profilo per prodotto -->
<Border Grid.Row="2" Grid.Column="0" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Profilo per prodotto e fascia oraria" Margin="0,0,0,10"
Help="Cosa aspettarsi prima che un'asta cominci: puntate del vincitore e prezzo di chiusura in percentuale del valore, per prodotto, fascia oraria e tipo di giorno. Solo le combinazioni con aste osservate; quando i dati sono pochi il motore restringe verso il prodotto e poi verso la media generale.&#10;&#10;Fasce: 0-8 notte e ore sospese · 9 · 10-12 · 13-17 · 18-20 · 21-23."/>
<DataGrid x:Name="ProfileGrid" MaxHeight="320" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Prodotto" Binding="{Binding Prodotto}" Width="*"/>
<DataGridTextColumn Header="Fascia" Binding="{Binding Fascia}" Width="Auto" MinWidth="60"/>
<DataGridTextColumn Header="Giorno" Binding="{Binding Giorno}" Width="Auto" MinWidth="60"/>
<DataGridTextColumn Header="Aste" Binding="{Binding Aste}" Width="Auto" MinWidth="50"/>
<DataGridTextColumn Header="Punt. vincitore" Binding="{Binding Puntate}" Width="Auto" MinWidth="100"/>
<DataGridTextColumn Header="Chiusura" Binding="{Binding Chiusura}" Width="Auto" MinWidth="80"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<!-- Valutazione -->
<Border Grid.Row="2" Grid.Column="2" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Ultima valutazione" Margin="0,0,0,10"
Help="Il rapporto dell'ultima valutazione sulle aste registrate in questo database. Le misure che contano: il sollevamento (quanto più spesso le puntate a probabilità alta erano davvero finali, rispetto al caso), la calibrazione per fasce (la probabilità prevista deve somigliare a quella osservata), e cosa il cancello avrebbe fatto sulle tue puntate vere."/>
<TextBox x:Name="EvaluationBox" IsReadOnly="True" TextWrapping="NoWrap"
MinHeight="200" MaxHeight="320"
FontFamily="Consolas" FontSize="11.5"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
BorderBrush="{DynamicResource Brush.Border}"
Text="Nessuna valutazione ancora. Premi «Valuta ora»."/>
</StackPanel>
</Border>
<!-- Autonomia sul momento -->
<Border Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="3" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Autonomia sul momento" Margin="0,0,0,10"
Help="Due cose che il sistema decide da solo sulla sessione in corso, dentro i paletti delle impostazioni.&#10;&#10;ANTICIPO: margine di sicurezza più la coda alta (p99) della latenza misurata adesso. Una puntata arrivata tardi alza il margine di 150 ms subito; venti puntate in tempo lo abbassano di 25. Il risultato resta fra il minimo e il massimo impostati; un anticipo scritto a mano su una singola asta vince sempre.&#10;&#10;REGIME: per ogni asta seguita. Calmo = punta se il valore atteso è positivo. Sfogo = gli altri si stanno battendo, si aspetta che si sfoghino: si rientra solo dopo «pazienza» cicli buoni di fila. Sondaggio = una puntata di prova; se viene coperta entro 9 s si torna a Sfogo e la pazienza raddoppia (fino a 24)."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="2*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Anticipo adattivo" FontWeight="SemiBold" Margin="0,0,0,8"
Foreground="{DynamicResource Brush.Text}"/>
<UniformGrid Columns="3" Rows="2">
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatLeadNow" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="anticipo adesso"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatLatP99" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="coda del ping (p99)"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatLatMargin" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="margine"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatLatP50" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="ping mediano"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatLatSamples" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="campioni"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatLatLate" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="puntate tardive / totali"/>
</StackPanel>
</UniformGrid>
<TextBlock x:Name="StatLeadHint" Style="{StaticResource Hint}" Margin="0,8,0,0" Text=""/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="Regime per asta" FontWeight="SemiBold" Margin="0,0,0,8"
Foreground="{DynamicResource Brush.Text}"/>
<DataGrid x:Name="RegimeGrid" MaxHeight="240" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Asta" Binding="{Binding Asta}" Width="*"/>
<DataGridTextColumn Header="Stato" Binding="{Binding StatoAsta}" Width="Auto" MinWidth="60"/>
<DataGridTextColumn Header="Regime" Binding="{Binding Regime}" Width="Auto" MinWidth="80"/>
<DataGridTextColumn Header="Pazienza" Binding="{Binding Pazienza}" Width="Auto" MinWidth="70"/>
<DataGridTextColumn Header="Sondaggi coperti" Binding="{Binding Sondaggi}" Width="Auto" MinWidth="110"/>
<DataGridTextColumn Header="P senza risposta" Binding="{Binding Prob}" Width="Auto" MinWidth="110"/>
<DataGridTextColumn Header="Valore atteso" Binding="{Binding Ev}" Width="Auto" MinWidth="100"/>
<DataGridTextColumn Header="Duello" Binding="{Binding Duello}" Width="Auto" MinWidth="60"/>
</DataGrid.Columns>
</DataGrid>
<TextBlock x:Name="RegimeHint" Style="{StaticResource Hint}" Margin="0,8,0,0" Text=""/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- Shadow: cosa avrebbe reso -->
<Border Grid.Row="4" Grid.Column="0" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Shadow: cosa avrebbero reso le decisioni" Margin="0,0,0,10"
Help="Ogni decisione registrata (in Osserva come in Attiva) riceve un esito alla chiusura dell'asta: se il prezzo non è più salito dopo quell'istante, una puntata lì sarebbe stata l'ultima. Con questo si giudicano sugli stessi istanti la policy in uso (modello + regime), il bandit Thompson che gira in shadow, «punta sempre» e «non punta mai».&#10;&#10;ROI per puntata = netto / puntate. L'intervallo di confidenza dice se il null-model (ROI = 0) è battuto davvero o per caso. Chi risponde al motore fra modello logistico e rete neurale lo decide il punteggio di Brier prequenziale qui sotto."/>
<UniformGrid Columns="3" Rows="1" Margin="0,0,0,8">
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatChampion" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="chi risponde al motore"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatBrier" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="Brier logistico / rete"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatBandit" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="bandit: contesti / osservazioni"/>
</StackPanel>
</UniformGrid>
<TextBox x:Name="ShadowBox" IsReadOnly="True" TextWrapping="NoWrap"
MinHeight="180" MaxHeight="300"
FontFamily="Consolas" FontSize="11.5"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
BorderBrush="{DynamicResource Brush.Border}"
Text="Nessuna decisione con esito ancora."/>
</StackPanel>
</Border>
<!-- Simulatore -->
<Border Grid.Row="4" Grid.Column="2" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Simulatore: le policy a confronto" Margin="0,0,0,10"
Help="Aste sintetiche con avversari tarati sul database (puntatori per asta, profondità dei cicli, quota di autopuntate, chiusura mediana): aggressivi, cecchini, autopuntate a 2 s, principianti, ognuno con il costo affondato degli umani. Le policy giocano le stesse aste (stessi semi): non puntare mai, puntare sempre, la regola del valore atteso, il bandit che impara giocando, e l'agente Q addestrato qui dentro con penalità conservativa.&#10;&#10;Il simulatore non è Bidoo: i numeri assoluti non contano, il confronto sì. Ed è l'unico posto dove si vede come reagirebbero gli altri a una nostra puntata."/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Button x:Name="SimulateButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Content="Simula 500 aste per policy" Margin="0,0,8,0"
Click="SimulateButton_Click"/>
<TextBlock x:Name="SimulateHint" Style="{StaticResource Hint}" VerticalAlignment="Center" Text=""/>
</StackPanel>
<TextBox x:Name="SimulationBox" IsReadOnly="True" TextWrapping="NoWrap"
MinHeight="180" MaxHeight="300"
FontFamily="Consolas" FontSize="11.5"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
BorderBrush="{DynamicResource Brush.Border}"
Text="Premi «Simula» per mettere le policy a confronto."/>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</Grid>
</UserControl>
+376
View File
@@ -0,0 +1,376 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using AutoBidder.Ml;
using AutoBidder.Utilities;
namespace AutoBidder.Controls
{
/// <summary>
/// La scheda dell'apprendimento: cosa sa il modello, cosa ha deciso di recente, cosa
/// aspettarsi per prodotto e ora, e l'ultima valutazione.
///
/// <para>Legge direttamente da <see cref="LearningService"/>: non ha bisogno della
/// finestra principale per niente, e si aggiorna da sola ogni pochi secondi mentre è
/// visibile. Tutto il resto dell'applicazione non sa che esiste.</para>
/// </summary>
public partial class LearningControl : UserControl
{
private readonly DispatcherTimer _refresh;
private CancellationTokenSource? _evaluation;
private sealed class PesoRow
{
public string Nome { get; init; } = "";
public double Peso { get; init; }
public string PesoDisplay => Peso.ToString("+0.000;-0.000");
public string Effetto => Math.Abs(Peso) < 0.05 ? "quasi nullo"
: Peso > 0 ? "più senza risposta" : "più risposte";
}
private sealed class ProfiloRow
{
public string Prodotto { get; init; } = "";
public string Fascia { get; init; } = "";
public string Giorno { get; init; } = "";
public long Aste { get; init; }
public string Puntate { get; init; } = "";
public string Chiusura { get; init; } = "";
}
private sealed class RegimeRow
{
public string Asta { get; init; } = "";
public string StatoAsta { get; init; } = "";
public string Regime { get; init; } = "";
public string Pazienza { get; init; } = "";
public string Sondaggi { get; init; } = "";
public string Prob { get; init; } = "";
public string Ev { get; init; } = "";
public string Duello { get; init; } = "";
}
/// <summary>
/// Da dove prendere le aste seguite adesso, per la tabella dei regimi. Lo imposta
/// la finestra principale: la scheda non conosce il monitor, e non deve.
/// </summary>
public Func<IReadOnlyList<Models.AuctionInfo>>? AuctionsProvider { get; set; }
private sealed class DecisioneRow
{
public string Ora { get; init; } = "";
public string Asta { get; init; } = "";
public string Prezzo { get; init; } = "";
public string Prob { get; init; } = "";
public string Ev { get; init; } = "";
public string Esito { get; init; } = "";
}
public LearningControl()
{
InitializeComponent();
_refresh = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
_refresh.Tick += (_, _) => { if (IsVisible) Refresh(); };
IsVisibleChanged += (_, e) =>
{
if ((bool)e.NewValue) { Refresh(); _refresh.Start(); }
else _refresh.Stop();
};
}
/// <summary>Rilegge tutto dal servizio. Costa poco: sono numeri già in memoria.</summary>
public void Refresh()
{
try
{
var settings = SettingsManager.Load();
var apprese = LearningService.AuctionsLearned;
var soglia = Math.Max(1, settings.LearningMinAuctions);
var pronto = LearningService.IsReady(settings);
var calib = LearningService.CalibrationFactor;
PillAuctions.Text = $"{apprese:N0} aste";
PillUpdates.Text = $"{LearningService.ModelUpdates:N0} esempi";
PillCalibration.Text = $"calibrazione {calib:N2}";
// L'interruttore in barra: spento, il modello studia ma non decide.
PillReady.Text = !settings.LearningEnabled ? "SPENTO (interruttore in barra)"
: pronto ? "pronto" : $"in ascolto ({apprese}/{soglia})";
PillReady.Foreground = (System.Windows.Media.Brush)FindResource(
!settings.LearningEnabled ? "Brush.Danger" : pronto ? "Brush.Success" : "Brush.Warning");
StatAuctions.Text = apprese.ToString("N0");
StatUpdates.Text = LearningService.ModelUpdates.ToString("N0");
StatProfile.Text = LearningService.ProfileAuctions.ToString("N0");
StatCalibration.Text = calib.ToString("N2");
StatThreshold.Text = soglia.ToString("N0");
StatBootstrap.Text = LearningService.BootstrapRunning ? "in corso" : "fermo";
RefreshAutonomy(settings);
RefreshShadow(settings);
var decisioni = LearningService.RecentDecisions();
var fermate = decisioni.Count(d => d.Blocked);
StatDecisions.Text = decisioni.Count == 0
? "Nessuna decisione ancora: il modello risponde quando il motore è pronto a puntare."
: $"Ultime {decisioni.Count} decisioni: {fermate} fermate, {decisioni.Count - fermate} lasciate passare" +
(settings.LearningGateEnabled ? "." : ". Il cancello è spento nelle impostazioni: il modello parla ma non decide.");
WeightsGrid.ItemsSource = LearningService.Pesi()
.Take(30)
.Select(p => new PesoRow { Nome = p.Nome, Peso = p.Peso })
.ToList();
DecisionsGrid.ItemsSource = decisioni
.Select(d => new DecisioneRow
{
Ora = d.At.ToString("HH:mm:ss"),
Asta = d.Auction,
Prezzo = d.Price.ToString("F2") + " €",
Prob = d.Probability.ToString("P2"),
Ev = d.ExpectedValue.ToString("+0.000;-0.000") + " €",
Esito = !d.Ready ? "parere" : d.Blocked ? "fermata" : "passa"
})
.ToList();
ProfileGrid.ItemsSource = LearningService.ProfileRows(150)
.Select(r => new ProfiloRow
{
Prodotto = r.Product,
Fascia = FasciaLabel(r.HourBand),
Giorno = r.Weekend ? "festivo" : "feriale",
Aste = r.Auctions,
Puntate = double.IsNaN(r.WinnerBids) ? "—" : r.WinnerBids.ToString("N0"),
Chiusura = double.IsNaN(r.CloseRatio) ? "—" : r.CloseRatio.ToString("P1")
})
.ToList();
if (_evaluation == null)
{
var ultima = LearningService.LastEvaluation();
if (!string.IsNullOrWhiteSpace(ultima)) EvaluationBox.Text = ultima;
}
}
catch (Exception ex)
{
StatDecisions.Text = $"Aggiornamento non riuscito: {ex.Message}";
}
}
private static string FasciaLabel(int band) => band switch
{
0 => "0-8", 1 => "9", 2 => "10-12", 3 => "13-17", 4 => "18-20", _ => "21-23"
};
/// <summary>La sezione «Autonomia sul momento»: modello di latenza e regime per asta.</summary>
private void RefreshAutonomy(AppSettings settings)
{
var lat = LatencyModel.Snapshot();
var lead = LatencyModel.RecommendedLeadMs(settings);
StatLeadNow.Text = $"{lead} ms";
StatLatP99.Text = lat.Samples > 0 ? $"{lat.P99} ms" : "—";
StatLatP50.Text = lat.Samples > 0 ? $"{lat.P50} ms" : "—";
StatLatMargin.Text = $"{lat.MarginMs} ms";
StatLatSamples.Text = lat.Samples.ToString("N0");
StatLatLate.Text = $"{lat.LateBids} / {lat.Bids}";
StatLeadHint.Text = !settings.AdaptiveLeadEnabled
? $"Anticipo adattivo spento nelle impostazioni: vale l'anticipo fisso di {settings.DefaultBidBeforeDeadlineMs} ms."
: lat.Samples < 10
? $"Ancora pochi campioni: finché non ce ne sono dieci vale l'anticipo fisso di {settings.DefaultBidBeforeDeadlineMs} ms."
: $"Margine {lat.MarginMs} + coda {lat.P99} = {lat.MarginMs + lat.P99} ms, tenuto fra {settings.LeadMinMs} e {settings.LeadMaxMs}.";
var aste = AuctionsProvider?.Invoke() ?? Array.Empty<Models.AuctionInfo>();
var righe = aste
.Where(a => a.State != Models.RunState.Stopped)
.Select(a => new RegimeRow
{
Asta = a.Name,
StatoAsta = a.State == Models.RunState.Active ? "Attiva" : "Osserva",
Regime = a.Regime.StatoAttuale switch
{
CompetitionRegime.Stato.Sfogo => $"Sfogo ({a.Regime.CicliBuoni}/{a.Regime.Pazienza})",
CompetitionRegime.Stato.Sondaggio => "Sondaggio",
_ => "Calmo"
},
Pazienza = a.Regime.Pazienza.ToString(),
Sondaggi = a.Regime.SondaggiFalliti.ToString(),
Prob = a.LearnedUnansweredProbability is { } p ? p.ToString("P1") : "—",
Ev = a.LearnedExpectedValue is { } ev ? ev.ToString("+0.000;-0.000") + " €" : "—",
Duello = a.AutoBidDuelDetected ? "sì" : a.AutoResponsesInARow > 0 ? $"{a.AutoResponsesInARow}/5" : ""
})
.ToList();
RegimeGrid.ItemsSource = righe;
var sfogo = righe.Count(r => r.Regime.StartsWith("Sfogo"));
RegimeHint.Text = righe.Count == 0
? "Nessuna asta seguita in questo momento."
: $"{righe.Count} aste seguite: {sfogo} in Sfogo, {righe.Count(r => r.Regime == "Sondaggio")} in Sondaggio, {righe.Count - sfogo - righe.Count(r => r.Regime == "Sondaggio")} Calme.";
}
private DateTime _shadowRefreshedAt = DateTime.MinValue;
/// <summary>Campione, Brier, bandit e il rapporto shadow (ogni mezzo minuto: interroga il database).</summary>
private void RefreshShadow(AppSettings settings)
{
var (bl, bn, samples) = LearningService.BrierScores;
StatChampion.Text = LearningService.ChallengerLeads ? "rete neurale" : "logistico";
StatBrier.Text = samples == 0 ? "—" : $"{bl:F5} / {bn:F5}";
StatBandit.Text = $"{LearningService.Bandit.Arms:N0} / {LearningService.Bandit.Observations:N0}";
if ((DateTime.Now - _shadowRefreshedAt).TotalSeconds < 30) return;
_shadowRefreshedAt = DateTime.Now;
try
{
var report = ShadowReport.Compute(Data.AuctionDatabase.Instance, settings, 30);
ShadowBox.Text = report.WithOutcome == 0
? $"Decisioni registrate negli ultimi 30 giorni: {report.TotalDecisions:N0}, nessuna con esito ancora (arriva alla chiusura dell'asta)."
: report.Text;
}
catch (Exception ex)
{
ShadowBox.Text = $"Rapporto non disponibile: {ex.Message}";
}
}
private void ExportDecisionsButton_Click(object sender, RoutedEventArgs e)
{
string? path;
try
{
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = "Esporta le decisioni in CSV",
FileName = $"decisioni-{DateTime.Now:yyyyMMdd-HHmm}.csv",
Filter = "CSV (foglio di calcolo)|*.csv",
OverwritePrompt = true,
AddExtension = true
};
path = dialog.ShowDialog() == true ? dialog.FileName : null;
}
catch { path = null; }
if (path == null) return;
var result = DecisionExporter.ExportCsv(path);
if (result.Success)
{
try { Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(result.Path)!, UseShellExecute = true }); } catch { }
MessageBox.Show($"{result.Message}\n{result.Path}", "Decisioni", MessageBoxButton.OK, MessageBoxImage.Information);
}
else
{
MessageBox.Show(result.Message, "Decisioni", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private CancellationTokenSource? _simulation;
private async void SimulateButton_Click(object sender, RoutedEventArgs e)
{
if (_simulation != null) { _simulation.Cancel(); return; }
_simulation = new CancellationTokenSource();
var ct = _simulation.Token;
SimulateButton.Content = "Annulla";
SimulationBox.Text = "Simulazione in corso…";
var settings = SettingsManager.Load();
try
{
var text = await Task.Run(() => SimulationLab.Run(Data.AuctionDatabase.Instance, settings, 500,
msg => Dispatcher.BeginInvoke(() => SimulateHint.Text = msg), ct), ct);
SimulationBox.Text = text;
SimulateHint.Text = $"fatto alle {DateTime.Now:HH:mm}";
}
catch (OperationCanceledException)
{
SimulationBox.Text = "Simulazione annullata.";
SimulateHint.Text = "";
}
catch (Exception ex)
{
SimulationBox.Text = $"Simulazione non riuscita: {ex.Message}";
SimulateHint.Text = "";
}
finally
{
_simulation = null;
SimulateButton.Content = "Simula 500 aste per policy";
}
}
private void RefreshButton_Click(object sender, RoutedEventArgs e) => Refresh();
private async void EvaluateButton_Click(object sender, RoutedEventArgs e)
{
if (_evaluation != null)
{
_evaluation.Cancel();
return;
}
_evaluation = new CancellationTokenSource();
// Il pulsante è solo icona: mentre gira diventa una X, e il tooltip lo dice.
EvaluateGlyph.Text = "\uE711";
EvaluateButton.ToolTip = "Annulla la valutazione in corso.";
EvaluationBox.Text = "Valutazione in corso: lettura delle aste…";
try
{
// Al massimo tremila aste, le più recenti: bastano a giudicare, e la
// lettura di tutto l'archivio in sottofondo mentre le aste corrono non serve.
var report = await LearningService.EvaluateAsync(3000,
msg => Dispatcher.BeginInvoke(() => EvaluationBox.Text = "Valutazione in corso: " + msg),
_evaluation.Token);
EvaluationBox.Text = report.Text;
}
catch (OperationCanceledException)
{
EvaluationBox.Text = "Valutazione annullata.";
}
catch (Exception ex)
{
EvaluationBox.Text = $"Valutazione non riuscita: {ex.Message}";
}
finally
{
_evaluation = null;
EvaluateGlyph.Text = "\uE9F5";
EvaluateButton.ToolTip = "Valuta ora.\nAddestra sulle aste più vecchie del database e giudica sulle più recenti, mai viste. Poi la prova prequenziale: ogni asta prima prevista e poi appresa, come fa il motore. Qualche minuto in sottofondo; il rapporto compare nella scheda «Valutazione».";
}
}
private async void RetrainButton_Click(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(
"Butto modello, profilo, sfidante, bandit e contatore delle aste studiate, e ricomincio a studiare tutte le aste chiuse del database.\n\n" +
"Lo storico e i prodotti non vengono toccati. Ci vorranno un paio di minuti in sottofondo.\n\nProcedo?",
"Ricomincia da capo", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
RetrainButton.IsEnabled = false;
try
{
await LearningService.RetrainFromScratchAsync(SettingsManager.Load());
}
finally
{
RetrainButton.IsEnabled = true;
Refresh();
}
}
}
}
+51 -35
View File
@@ -155,7 +155,7 @@
</Border>
</StackPanel>
<!-- Ricerca + legenda "dove posso scrivere?" -->
<!-- Ricerca -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"
Margin="14,0,10,0">
<Border Background="{DynamicResource Brush.Bg}"
@@ -182,56 +182,67 @@
</Grid>
</Border>
<Border Background="{DynamicResource Brush.Bg}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1"
CornerRadius="4" Width="18" Height="14" VerticalAlignment="Center"/>
<TextBlock Text="= modificabile"
Margin="6,0,0,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextFaint}"/>
</StackPanel>
<!-- Solo icone, come nel monitor: il tooltip dice esattamente cosa succede. -->
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="ScanNowButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Cerca aste ora" Margin="0,0,8,0"
ToolTip="Cerca subito aste nuove dei prodotti seguiti, senza aspettare il prossimo giro"
Click="ScanNowButton_Click"/>
<Button x:Name="ScanNowButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Accent}"
ToolTip="Cerca aste ora.&#10;Interroga subito il catalogo di Bidoo per i prodotti con la stellina accesa e mette nel monitor le aste nuove che trova, con i limiti del prodotto. Senza aspettare il prossimo giro automatico."
Click="ScanNowButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE721;"/>
</Button>
<Button x:Name="ReapplyButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Accent}"
ToolTip="Riapplica i limiti.&#10;Riscrive prezzo minimo e massimo, puntate massime, avvio e valore reale del prodotto selezionato sulle aste di quel prodotto già presenti nel monitor. Serve dopo aver cambiato i limiti in questa scheda."
Click="ReapplyButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE895;"/>
</Button>
<Button x:Name="ApplySuggestedButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Usa consigliati" Margin="0,0,8,0"
ToolTip="Scrive prezzo minimo e massimo consigliati nel prodotto selezionato.&#10;&#10;Il consiglio nasce da come è andata finora: il massimo copre l'85% delle chiusure passate (regolabile in Impostazioni), abbassato se il budget non lo regge — valore del prodotto meno il costo delle puntate massime. Il minimo è il decimo percentile: sotto quel prezzo l'asta non si chiude quasi mai."
Click="ApplySuggestedButton_Click"/>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
<Button x:Name="ApplyAllSuggestedButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Usa consigliati su tutti" Margin="0,0,8,0"
ToolTip="Scrive i limiti consigliati su tutti i prodotti che hanno abbastanza aste concluse. Chiede conferma e dice quante righe cambierebbe."
Click="ApplyAllSuggestedButton_Click"/>
<Button x:Name="ApplySuggestedButton" Style="{StaticResource IconButton}"
ToolTip="Usa i limiti consigliati sul prodotto selezionato.&#10;Scrive prezzo minimo e massimo consigliati dallo storico: il massimo copre l'85% delle chiusure passate (regolabile in Impostazioni), abbassato se il budget non lo regge — valore del prodotto meno il costo delle puntate massime. Il minimo è il decimo percentile: sotto quel prezzo l'asta non si chiude quasi mai."
Click="ApplySuggestedButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8EF;"/>
</Button>
<Button x:Name="ApplyAllSuggestedButton" Style="{StaticResource IconButton}"
ToolTip="Usa i limiti consigliati su tutti i prodotti.&#10;Scrive i limiti consigliati su ogni prodotto che ha abbastanza aste concluse per ricavarli. Chiede conferma e dice prima quante righe cambierebbe."
Click="ApplyAllSuggestedButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE762;"/>
</Button>
<Button x:Name="ReapplyButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Content="Riapplica" Margin="0,0,8,0"
ToolTip="Riscrive i limiti del prodotto selezionato sulle aste già presenti nel monitor"
Click="ReapplyButton_Click"/>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
<Button x:Name="RemoveButton" Style="{StaticResource IconButton}"
ToolTip="Togli dall'elenco il prodotto selezionato"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Togli il prodotto selezionato.&#10;Lo elimina dall'elenco con i suoi limiti. Le aste già nel monitor restano dove sono; le statistiche del prodotto restano."
Click="RemoveButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE738;"/>
</Button>
<Button x:Name="UnwatchAllButton" Style="{StaticResource IconButton}"
ToolTip="Spegni tutte le stelline.&#10;Nessun prodotto viene più cercato in automatico. I prodotti restano in elenco con i loro limiti: si riaccendono una a una."
Click="UnwatchAllButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE734;"/>
</Button>
<Button x:Name="RemoveUnwatchedButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Togli i prodotti senza stellina.&#10;Elimina dall'elenco tutti i prodotti non seguiti, con i loro limiti. Restano solo quelli con la stellina accesa. Le statistiche per prodotto restano."
Click="RemoveUnwatchedButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8D9;"/>
</Button>
<Button x:Name="ClearButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Svuota l'elenco dei prodotti"
ToolTip="Svuota l'elenco.&#10;Toglie tutti i prodotti e i loro limiti. Le statistiche per prodotto (aste concluse, prezzi tipici, consigli) restano: se il prodotto ricompare, i consigli ci sono ancora."
Click="ClearButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74D;"/>
</Button>
<Button x:Name="ClearAllButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Pulizia completa.&#10;Toglie tutti i prodotti, i loro limiti E le statistiche per prodotto. Lo storico delle aste non viene toccato: le statistiche si possono ricostruire da lì con «Pulisci dati incompleti» nello Storico."
Click="ClearAllButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE894;"/>
</Button>
</StackPanel>
</Grid>
</Border>
@@ -298,6 +309,11 @@
CellStyle="{StaticResource EditableCell}"
HeaderTemplate="{StaticResource EditableHeader}"
ElementStyle="{StaticResource EditableText}"/>
<DataGridTextColumn Header="Valore reale €" Binding="{Binding MarketValueText}"
Width="Auto" MinWidth="96"
CellStyle="{StaticResource EditableCell}"
HeaderTemplate="{StaticResource EditableHeader}"
ElementStyle="{StaticResource EditableText}"/>
<DataGridTextColumn Header="Entro min" Binding="{Binding MaxStartMinutesText}"
Width="Auto" MinWidth="86"
CellStyle="{StaticResource EditableCell}"
+36
View File
@@ -135,6 +135,15 @@ namespace AutoBidder.Controls
private void ClearButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearClickedEvent, this));
private void ClearAllButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearAllClickedEvent, this));
private void UnwatchAllButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(UnwatchAllClickedEvent, this));
private void RemoveUnwatchedButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(RemoveUnwatchedClickedEvent, this));
private void ApplySuggestedButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ApplySuggestedClickedEvent, this));
@@ -158,6 +167,15 @@ namespace AutoBidder.Controls
public static readonly RoutedEvent ClearClickedEvent = EventManager.RegisterRoutedEvent(
"ClearClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ClearAllClickedEvent = EventManager.RegisterRoutedEvent(
"ClearAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent UnwatchAllClickedEvent = EventManager.RegisterRoutedEvent(
"UnwatchAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent RemoveUnwatchedClickedEvent = EventManager.RegisterRoutedEvent(
"RemoveUnwatchedClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ApplySuggestedClickedEvent = EventManager.RegisterRoutedEvent(
"ApplySuggestedClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
@@ -200,6 +218,24 @@ namespace AutoBidder.Controls
remove { RemoveHandler(ClearClickedEvent, value); }
}
public event RoutedEventHandler ClearAllClicked
{
add { AddHandler(ClearAllClickedEvent, value); }
remove { RemoveHandler(ClearAllClickedEvent, value); }
}
public event RoutedEventHandler UnwatchAllClicked
{
add { AddHandler(UnwatchAllClickedEvent, value); }
remove { RemoveHandler(UnwatchAllClickedEvent, value); }
}
public event RoutedEventHandler RemoveUnwatchedClicked
{
add { AddHandler(RemoveUnwatchedClickedEvent, value); }
remove { RemoveHandler(RemoveUnwatchedClickedEvent, value); }
}
public event RoutedEventHandler ApplySuggestedClicked
{
add { AddHandler(ApplySuggestedClickedEvent, value); }
+165 -154
View File
@@ -44,6 +44,31 @@
</StackPanel>
</Border>
<!-- SEZIONE 0-quater: Fascia oraria e pulizia dell'elenco -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Fascia oraria sospesa e pulizia dell'elenco"
Margin="0,0,0,14"
Help="Fascia oraria: su 6060 aste concluse la stessa asta chiude al 4,6% del valore alle 12 e al 5,3% fra le 10 e le 13, ma all&apos;8,6% a mezzanotte e al 9,7% alle 9 — quasi il doppio, e con 26-29 puntate per vincere invece di 21-24. Nella fascia le puntate si sospendono ma l&apos;asta resta Attiva e riprende da sola. Inizio incluso, fine esclusa; una fascia a cavallo di mezzanotte (22-6) funziona; inizio uguale a fine spegne il filtro.&#10;&#10;Pulizia dell&apos;elenco: le aste concluse spariscono da sole dal monitor, tranne quelle che vale la pena riguardare."/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<CheckBox x:Name="QuietHoursCheckBox" Content="Sospendi le puntate dalle" VerticalAlignment="Center"/>
<TextBox x:Name="QuietHoursStartTextBox" Text="0" Width="56" Margin="8,0" VerticalAlignment="Center"/>
<TextBlock Text="alle" VerticalAlignment="Center" Foreground="{DynamicResource Brush.Text}"/>
<TextBox x:Name="QuietHoursEndTextBox" Text="10" Width="56" Margin="8,0" VerticalAlignment="Center"/>
<TextBlock Text="(ore, 0-23)" VerticalAlignment="Center" Foreground="{DynamicResource Brush.TextMuted}"/>
</StackPanel>
<CheckBox x:Name="AutoRemoveFinishedCheckBox" Content="Togli da sole le aste concluse dall'elenco" Margin="0,4"/>
<CheckBox x:Name="AutoRemoveKeepMyBidsCheckBox" Content="…ma trattieni quelle su cui ho puntato" Margin="20,2,0,0"
ToolTip="Sono le uniche con il costo reale dentro: restano per controllarle a mano."/>
<CheckBox x:Name="AutoRemoveKeepWonCheckBox" Content="…ma trattieni quelle vinte" Margin="20,2,0,0"
ToolTip="C'è da confermare l'acquisto su Bidoo: una vittoria sparita dall'elenco è il modo più semplice di dimenticarsene."/>
<CheckBox x:Name="AutoRemoveKeepUnclearCheckBox" Content="…ma trattieni quelle di cui non si è vista la fine" Margin="20,2,0,0"
ToolTip="Esito da controllare: sono esattamente quelle da guardare."/>
</StackPanel>
</Border>
<!-- SEZIONE 0-bis: Sessione / Autenticazione -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
@@ -115,20 +140,32 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Anticipo Puntata (millisecondi)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quanti millisecondi prima della scadenza parte la puntata. È il parametro che decide quanto costa vincere: il motore punta solo nei cicli che arrivano fin qui senza che nessun altro abbia puntato.&#10;&#10;Dalla rigiocata di 2.095 aste concluse: sotto il secondo basta in mediana 1 puntata per asta, sotto i 2 s ne servono 15, sotto i 3 s 46. Ma sotto i 600 ms un picco di rete fa arrivare la puntata a giochi chiusi (il ping tocca 444 ms nel p99,9).&#10;&#10;Fascia consigliata: 6002000 ms. Predefinito 1000."/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="DefaultBidBeforeDeadlineMsTextBox" Text="200" Margin="10,10"/>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Anticipo deciso dalla rete" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Acceso: l'anticipo lo sceglie il modello di latenza — margine di sicurezza più la coda alta del ping di questa sessione — e si corregge da solo a ogni puntata tardiva. Si muove solo fra il minimo e il massimo qui sotto.&#10;&#10;Spento: vale l'anticipo fisso della riga seguente.&#10;&#10;In entrambi i casi un anticipo scritto a mano su una singola asta vince sempre."/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="AdaptiveLeadCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Anticipo minimo (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Il modello non scende mai sotto questo valore. Sotto i 300 ms un picco di rete fa arrivare la puntata a giochi chiusi: il ping tocca 444 ms nel p99,9."/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="LeadMinMsTextBox" Text="300" Margin="10,10"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Anticipo massimo (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Il modello non sale mai sopra questo valore. Ogni millisecondo in più è tempo regalato agli avversari: dalla rigiocata di 2.095 aste, sotto il secondo basta in mediana 1 puntata per asta, sotto i 2 s ne servono 15."/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="LeadMaxMsTextBox" Text="1500" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Anticipo fisso (millisecondi)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Usato quando l'anticipo deciso dalla rete è spento, e come punto di partenza finché il modello non ha misurato abbastanza ping. Quanti millisecondi prima della scadenza parte la puntata: il motore punta solo nei cicli che arrivano fin qui senza che nessun altro abbia puntato."/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="DefaultBidBeforeDeadlineMsTextBox" Text="500" Margin="10,10"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Prezzo Minimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="DefaultMinPriceTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="Prezzo Minimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="5" Grid.Column="1" x:Name="DefaultMinPriceTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Prezzo Massimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="DefaultMaxPriceTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="Prezzo Massimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="6" Grid.Column="1" x:Name="DefaultMaxPriceTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Max Click" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quante puntate al massimo spendere su una singola asta. Entra anche nel calcolo dei limiti consigliati per prodotto: ogni puntata in più è denaro che non si può mettere sul prezzo. 0 = illimitate."/>
<TextBox Grid.Row="4" Grid.Column="1" x:Name="DefaultMaxClicksTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="7" Grid.Column="0" Text="Max Click" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quante puntate al massimo spendere su una singola asta. Entra anche nel calcolo dei limiti consigliati per prodotto: ogni puntata in più è denaro che non si può mettere sul prezzo. 0 = illimitate."/>
<TextBox Grid.Row="7" Grid.Column="1" x:Name="DefaultMaxClicksTextBox" Text="0" Margin="10,10"/>
</Grid>
</StackPanel>
</Border>
@@ -158,7 +195,7 @@
<TextBlock Grid.Row="1" Grid.Column="0" Text="Costo medio di una puntata (€)"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Quanto ti costa una puntata. Serve a calcolare il tetto di spesa: valore del prodotto meno le puntate massime moltiplicate per questo costo."/>
ToolTip="Quanto ti costa una puntata. Serve a calcolare il tetto di spesa: valore del prodotto meno le puntate massime moltiplicate per questo costo.&#10;&#10;Il valore predefinito è 0,20 €, cioè il pacchetto meno vantaggioso in vendita su Bidoo: tenerlo così fa stimare per eccesso, e una stima per eccesso al massimo ti fa rinunciare a un affare, mentre una per difetto ti fa spendere più del valore del prodotto.&#10;&#10;Se compri pacchetti più grandi, abbassalo al costo che paghi davvero."/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="AverageBidCostTextBox"
Text="0,20" Margin="10,10"/>
</Grid>
@@ -232,7 +269,7 @@ Consiglio: usa Fermata per caricare senza avviare, poi avvia a mano quelle che v
<StackPanel>
<ctl:HelpHeader Title="Motore di Precisione"
Margin="0,0,0,14"
Help="Ogni asta ha il proprio motore indipendente. Il polling si infittisce solo avvicinandosi alla scadenza: un'asta lontana non consuma richieste inutili, una vicina sì."/>
Help="Ogni asta ha il proprio motore indipendente e interroga Bidoo al massimo che la rete concede: nessuna cadenza da impostare. Se il server chiede di rallentare (429/503) il motore frena da solo e riparte appena può."/>
<Grid>
<Grid.ColumnDefinitions>
@@ -242,33 +279,13 @@ Consiglio: usa Fermata per caricare senza avviare, poi avvia a mano quelle che v
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Polling oltre 60s (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Cadenza quando la scadenza è lontana"/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="PollIntervalFarMsTextBox" Text="2000" Margin="10,10"/>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Limite richieste al secondo" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="0 = nessun tetto (consigliato: si va al massimo che la rete concede). Le puntate hanno corsia preferenziale e non sono mai soggette a questo limite."/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="MaxRequestsPerSecondTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Polling 10-60s (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="PollIntervalMidMsTextBox" Text="900" Margin="10,10"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Polling sotto 10s (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="PollIntervalNearMsTextBox" Text="400" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Polling in finestra critica (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Usato solo dalle aste in stato Attiva: in Osserva non c'è puntata da azzeccare"/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="PollIntervalCriticalMsTextBox" Text="220" Margin="10,10"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Ampiezza finestra critica (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="4" Grid.Column="1" x:Name="CriticalWindowMsTextBox" Text="4000" Margin="10,10"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="Limite richieste al secondo" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Le puntate hanno corsia preferenziale e non sono soggette a questo limite"/>
<TextBox Grid.Row="5" Grid.Column="1" x:Name="MaxRequestsPerSecondTextBox" Text="40" Margin="10,10"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="Timer di sistema a 1 ms" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Senza, ogni attesa può sforare di ~15 ms e l'anticipo perde significato"/>
<CheckBox Grid.Row="6" Grid.Column="1" x:Name="PrecisionTimerCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Timer di sistema a 1 ms" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Senza, ogni attesa può sforare di ~15 ms e l'anticipo perde significato"/>
<CheckBox Grid.Row="1" Grid.Column="1" x:Name="PrecisionTimerCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
</Grid>
<Border Style="{StaticResource InfoBox}" Margin="0,15,0,0">
@@ -348,7 +365,7 @@ L'elenco dei prodotti è nella scheda Prodotti: lì ogni articolo ha la stellina
<TextBlock Grid.Row="5" Grid.Column="0" Text="Solo aste non ancora cominciate"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Aggiungi da solo soltanto le aste su cui nessuno ha ancora puntato, per poterle seguire dal primo istante all&apos;ultimo.&#10;&#10;Un&apos;asta presa a metà si può seguire, ma la sua storia è già scritta per buona parte: non si sa quanto è durata davvero, chi c&apos;era prima, come è salito il prezzo. Le statistiche che ne escono sono monche e i dossier raccontano solo la seconda metà.&#10;&#10;Il prezzo da pagare è che si perdono le occasioni già in corso."/>
ToolTip="Aggiungi da solo soltanto le aste su cui nessuno ha ancora puntato, per poterle seguire dal primo istante all&apos;ultimo.&#10;&#10;Un&apos;asta presa a metà si può seguire, ma la sua storia è già scritta per buona parte: non si sa quanto è durata davvero, chi c&apos;era prima, come è salito il prezzo. Le statistiche che ne escono sono monche e la registrazione racconta solo la seconda metà.&#10;&#10;Il prezzo da pagare è che si perdono le occasioni già in corso."/>
<CheckBox Grid.Row="5" Grid.Column="1" x:Name="AutoAddOnlyNotStartedCheckBox"
Margin="10,10" VerticalAlignment="Center"/>
@@ -363,66 +380,6 @@ L'elenco dei prodotti è nella scheda Prodotti: lì ogni articolo ha la stellina
</StackPanel>
</Border>
<!-- SEZIONE: Anticipo puntata - misura e taratura -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Anticipo Puntata (misura e taratura)"
Margin="0,0,0,14"
Help="L'anticipo impostato è una scommessa; quello che conta è dove la puntata arriva davvero. L'applicazione misura ogni puntata riuscita e, con abbastanza dati, dice se c'è margine per correggere.
Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in mediana 1 puntata per asta, sotto i 2 s ne servono 15, sotto i 3 s 46. Dall'altro lato il ping tocca 444 ms nel p99,9, quindi sotto i 600 ms la puntata rischia di arrivare a giochi chiusi. La fascia sensata è 6002000 ms."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="200"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Misura l'anticipo effettivo"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="BidLeadTrackingCheckBox"
Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Proponi correzioni"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Il consiglio non viene mai applicato da solo"/>
<CheckBox Grid.Row="1" Grid.Column="1" x:Name="BidLeadSuggestionsCheckBox"
Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Puntate minime per un consiglio"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="BidLeadMinSamplesTextBox"
Text="15" Margin="10,10"/>
</Grid>
<Border Style="{StaticResource InfoBox}" Margin="0,12,0,10">
<StackPanel>
<TextBlock Text="Misure raccolte" FontWeight="Bold"
Foreground="{DynamicResource Brush.Accent}" Margin="0,0,0,8"/>
<TextBlock x:Name="BidLeadSummaryText" TextWrapping="Wrap"
Foreground="{DynamicResource Brush.Text}" FontSize="12" LineHeight="18"
Text="Nessuna puntata registrata finora."/>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal">
<Button Content="Calcola consiglio" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Margin="0,0,8,0" Click="ComputeBidLeadAdviceButton_Click"/>
<Button Content="Azzera misure" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Danger}"
Click="ClearBidLeadStatsButton_Click"/>
</StackPanel>
</StackPanel>
</Border>
<!-- SEZIONE: Aste programmate -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
@@ -481,98 +438,75 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
</StackPanel>
</Border>
<!-- SEZIONE: Cartelle dei dati -->
<!-- SEZIONE: Database -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Cartelle dei Dati"
<ctl:HelpHeader Title="Database"
Margin="0,0,0,14"
Help="Ogni campo mostra il percorso in uso: cambialo per spostare i dati altrove, svuotalo per tornare alla posizione predefinita. Impostazioni e sessione restano sempre nella cartella dell'applicazione, perché è lì che si legge dove stanno tutti gli altri."/>
Help="Tutto ciò che l'applicazione registra sta in due file SQLite in questa cartella: le osservazioni (aste, puntate, interrogazioni, decisioni, misure) e l'esercizio (aste nel monitor, prodotti seguiti, modelli appresi, registri). Le impostazioni e la login restano nelle loro cartelle.&#10;&#10;Se cambi cartella, al salvataggio ti chiedo se spostare i database esistenti; in ogni caso l'applicazione si riavvia. Mai una cartella sincronizzata (OneDrive, Dropbox): un database aperto sotto sincronizzazione si corrompe."/>
<TextBlock Text="Dati d'esercizio (aste monitorate, prodotti seguiti)"
Style="{StaticResource FieldLabel}"/>
<TextBlock Text="Cartella dei database" Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" x:Name="DataFolderTextBox"/>
<TextBox Grid.Column="0" x:Name="DatabaseFolderTextBox"/>
<Button Grid.Column="1" Content="Sfoglia…" Margin="8,0,0,0"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Click="BrowseDataFolderButton_Click"/>
Click="BrowseDatabaseFolderButton_Click"/>
<Button Grid.Column="2" Margin="6,0,0,0"
Style="{StaticResource IconButton}"
ToolTip="Apri questa cartella"
Click="OpenDataFolderButton_Click">
ToolTip="Apri la cartella dei database"
Click="OpenDatabaseFolderButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button>
</Grid>
</StackPanel>
</Border>
<TextBlock Text="Storico ed esportazioni" Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" x:Name="StatsFolderTextBox"/>
<Button Grid.Column="1" Content="Sfoglia…" Margin="8,0,0,0"
Style="{StaticResource SmallRoundedButton}"
<!-- SEZIONE: Manutenzione -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Manutenzione"
Margin="0,0,0,14"
Help="Le due operazioni estreme. «Elimina tutti i dati» cancella i database, le copie di sicurezza e la vecchia cartella Dati: restano la login e le impostazioni. «Impostazioni di fabbrica» cancella solo il file delle impostazioni: i dati e la login restano. Entrambe chiedono conferma e riavviano l'applicazione."/>
<StackPanel Orientation="Horizontal">
<Button Content="Elimina tutti i dati (tranne la login)" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Danger}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Margin="0,0,8,0" Padding="14,6"
ToolTip="Cancella i due database (aste, puntate, decisioni, prodotti seguiti, aste nel monitor, modelli appresi, registri), le copie di sicurezza e la vecchia cartella Dati. Restano sessione, riferimenti del sito, browser e impostazioni. Doppia conferma, poi riavvio."
Click="WipeAllDataButton_Click"/>
<Button Content="Impostazioni di fabbrica" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Click="BrowseStatsFolderButton_Click"/>
<Button Grid.Column="2" Margin="6,0,0,0"
Style="{StaticResource IconButton}"
ToolTip="Apri questa cartella"
Click="OpenStatsFolderSettingsButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button>
</Grid>
<TextBlock Text="Registri (log applicativo, riscatto puntate, dossier delle aste)"
Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" x:Name="LogFolderTextBox"/>
<Button Grid.Column="1" Content="Sfoglia…" Margin="8,0,0,0"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Click="BrowseLogFolderButton_Click"/>
<Button Grid.Column="2" Margin="6,0,0,0"
Style="{StaticResource IconButton}"
ToolTip="Apri questa cartella"
Click="OpenLogFolderButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button>
</Grid>
<CheckBox x:Name="DetailedStatsCheckBox" IsChecked="True" Margin="0,0,0,6"
Content="Registra una scheda dettagliata per ogni asta conclusa (serie prezzi, puntate per utente, durata)"/>
Foreground="{DynamicResource Brush.Danger}"
Margin="0,0,8,0" Padding="14,6"
ToolTip="Cancella il file delle impostazioni e riavvia: tutto torna ai predefiniti. Dati e login non vengono toccati."
Click="ResetSettingsButton_Click"/>
</StackPanel>
</StackPanel>
</Border>
<!-- SEZIONE: Registri su file -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Registri su file"
<ctl:HelpHeader Title="Registri"
Margin="0,0,0,14"
Help="I registri si scrivono mentre l'applicazione lavora, non alla chiusura: quello che vedi nelle schede è già sul disco. Servono a rileggere cosa è successo mentre non guardavi — e il dossier delle aste è il materiale su cui si studiano le strategie, perché Bidoo non espone nulla di tutto questo a posteriori. È anche ciò che legge backtest.ps1 per dire quanto costerebbe ciascun anticipo."/>
Help="I registri si scrivono mentre l'applicazione lavora, nel database d'esercizio: quello che vedi nelle schede è già sul disco. Servono a rileggere cosa è successo mentre non guardavi. Si esportano in testo con il pulsante qui sotto e scadono dopo il numero di giorni indicato."/>
<CheckBox x:Name="WriteAppLogCheckBox" IsChecked="True" Margin="0,4"
Content="Registro applicativo su file (un file per giorno)"/>
Content="Registro applicativo nel database"/>
<CheckBox x:Name="WriteFreeBidsLogCheckBox" IsChecked="True" Margin="0,4"
Content="Registro del riscatto puntate su file"/>
Content="Registro del riscatto puntate nel database"/>
<CheckBox x:Name="WriteDossiersCheckBox" IsChecked="True" Margin="0,4"
Content="Dossier per ogni asta (eventi, puntate di ogni utente, ping)"/>
Content="Registra ogni asta seguita nel database (puntate di ogni utente, reset, mie puntate, decisioni)"/>
<CheckBox x:Name="RawPollsCheckBox" IsChecked="True" Margin="0,4"
Content="Nel dossier registra anche ogni singola interrogazione a Bidoo"
Content="Registra anche le interrogazioni a Bidoo (una riga quando cambia qualcosa, e almeno una al secondo)"
ToolTip="Risoluzione massima: prezzo, timer e ping a ogni poll. Nella finestra critica sono quattro righe al secondo, quindi i file crescono in fretta."/>
<Grid Margin="0,10,0,0">
@@ -587,6 +521,13 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
ToolTip="0 = tienili per sempre"/>
<TextBox Grid.Column="1" x:Name="LogRetentionTextBox" Text="90" Margin="10,10"/>
</Grid>
<Button Content="Esporta i registri in testo…" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
HorizontalAlignment="Left" Margin="0,6,0,0" Padding="14,5"
ToolTip="Scrive il registro applicativo e quello del riscatto puntate in due file di testo, nella cartella che scegli."
Click="ExportAppLogButton_Click"/>
</StackPanel>
</Border>
@@ -664,6 +605,76 @@ Se il valore è maggiore di zero, il sistema non punta quando le puntate residue
</StackPanel>
</Border>
<!-- SEZIONE: Gestione del rischio -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Gestione del rischio"
Margin="0,0,0,14"
Help="I paletti duri che nessuna strategia può scavalcare. Ogni puntata dal vivo passa da qui prima di partire.&#10;&#10;Il tetto di spesa del giorno ferma le puntate per oggi e domani si riparte. Lo stop-loss (perdita netta del giorno) e il drawdown (discesa dal picco del saldo cumulato) mettono invece il sistema in HALT: resta fermo anche dopo un riavvio, finché non lo togli tu da qui. Le vincite si contano al valore reale del prodotto (colonna «Valore reale» nella scheda Prodotti), meno prezzo, fee e spedizione.&#10;&#10;La copertura «Compralo Ora» presume che, perdendo, si compri al listino recuperando le puntate come credito: la perdita si limita a listino valore reale. Per i buoni al nominale copre tutto dal primo centesimo, e il modello punterà più spesso. Se non intendi esercitarla, spegnila."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Tetti di puntate attivi" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Applica i due tetti di puntate qui sotto (per asta e per sessione)."/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="BankrollEnabledCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Puntate massime per asta" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="0 = nessun limite. Vale per le aste senza un tetto proprio."/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="MaxBidsPerAuctionTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Puntate massime per sessione" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="0 = nessun limite. Si azzera a ogni avvio."/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="MaxBidsPerSessionTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Tetto di spesa del giorno (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quanto spendere in puntate al massimo in un giorno. 0 = nessun limite. Non è un HALT: domani si riparte."/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="DailyBudgetTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Stop-loss del giorno (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Perdita netta del giorno (puntate spese meno vincite) oltre la quale il sistema entra in HALT. 0 = mai."/>
<TextBox Grid.Row="4" Grid.Column="1" x:Name="DailyStopLossTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="Drawdown massimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Discesa del saldo cumulato dal suo picco oltre la quale il sistema entra in HALT. 0 = mai."/>
<TextBox Grid.Row="5" Grid.Column="1" x:Name="MaxDrawdownTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="Aste in gioco insieme" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Su quante aste al massimo spendere nello stesso momento. Un'asta è «in gioco» quando ha già ricevuto una tua puntata in questa sessione. 0 = nessun limite."/>
<TextBox Grid.Row="6" Grid.Column="1" x:Name="MaxConcurrentTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="7" Grid.Column="0" Text="Fee di transazione (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quanto Bidoo aggiunge alla vincita. Entra nel valore atteso di ogni puntata e nel conto delle vincite."/>
<TextBox Grid.Row="7" Grid.Column="1" x:Name="TransactionFeeTextBox" Text="1,00" Margin="10,10"/>
<TextBlock Grid.Row="8" Grid.Column="0" Text="Copertura «Compralo Ora»" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Vedi la spiegazione della sezione. Accesa: le puntate oltre la perdita coperta (Compralo Ora meno valore reale) non costano nulla nel ramo di sconfitta.&#10;&#10;Vale solo per i prodotti con «Valore reale €» scritto nella scheda Prodotti: senza, il valore coinciderebbe con il listino, la perdita coperta sarebbe zero e ogni puntata risulterebbe gratis — si punterebbe sempre."/>
<CheckBox Grid.Row="8" Grid.Column="1" x:Name="HedgeCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
</Grid>
<Border Style="{StaticResource InfoBox}" Margin="0,15,0,0">
<StackPanel>
<TextBlock x:Name="RiskStatusText" TextWrapping="Wrap"
Foreground="{DynamicResource Brush.Text}"
FontSize="12" LineHeight="18"
Text="Nessun HALT in corso."/>
<Button x:Name="ResumeHaltButton" Content="Togli l'HALT e riparti" Margin="0,10,0,0"
HorizontalAlignment="Left" Padding="14,5" Visibility="Collapsed"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Danger}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Click="ResumeHaltButton_Click"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- SEZIONE 4: Limiti Log -->
<Border Background="{DynamicResource Brush.Surface}"
BorderBrush="{DynamicResource Brush.Border}"
+63 -73
View File
@@ -62,103 +62,93 @@ namespace AutoBidder.Controls
// ===== ANTICIPO, CARTELLE, ESPORTAZIONE =====
private void ComputeBidLeadAdviceButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ComputeBidLeadAdviceClickedEvent, this));
private void BrowseDatabaseFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(BrowseDatabaseFolderClickedEvent, this));
private void ClearBidLeadStatsButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearBidLeadStatsClickedEvent, this));
private void OpenDatabaseFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenDatabaseFolderClickedEvent, this));
private void BrowseDataFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(BrowseDataFolderClickedEvent, this));
public static readonly RoutedEvent BrowseDatabaseFolderClickedEvent = EventManager.RegisterRoutedEvent(
"BrowseDatabaseFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
private void BrowseStatsFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(BrowseStatsFolderClickedEvent, this));
public static readonly RoutedEvent OpenDatabaseFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenDatabaseFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
private void OpenDataFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenDataFolderClickedEvent, this));
private void OpenStatsFolderSettingsButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenStatsFolderClickedEvent, this));
private void BrowseLogFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(BrowseLogFolderClickedEvent, this));
private void OpenLogFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenLogFolderClickedEvent, this));
/// <summary>Riepilogo delle misure sull'anticipo, mostrato nel riquadro informativo.</summary>
public void SetBidLeadSummary(string text) => BidLeadSummaryText.Text = text;
public static readonly RoutedEvent ComputeBidLeadAdviceClickedEvent = EventManager.RegisterRoutedEvent(
"ComputeBidLeadAdviceClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent ClearBidLeadStatsClickedEvent = EventManager.RegisterRoutedEvent(
"ClearBidLeadStatsClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent BrowseDataFolderClickedEvent = EventManager.RegisterRoutedEvent(
"BrowseDataFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent BrowseStatsFolderClickedEvent = EventManager.RegisterRoutedEvent(
"BrowseStatsFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent OpenDataFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenDataFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent OpenStatsFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenStatsFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent BrowseLogFolderClickedEvent = EventManager.RegisterRoutedEvent(
"BrowseLogFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent OpenLogFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenLogFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler ComputeBidLeadAdviceClicked
public event RoutedEventHandler BrowseDatabaseFolderClicked
{
add { AddHandler(ComputeBidLeadAdviceClickedEvent, value); }
remove { RemoveHandler(ComputeBidLeadAdviceClickedEvent, value); }
add { AddHandler(BrowseDatabaseFolderClickedEvent, value); }
remove { RemoveHandler(BrowseDatabaseFolderClickedEvent, value); }
}
public event RoutedEventHandler ClearBidLeadStatsClicked
public event RoutedEventHandler OpenDatabaseFolderClicked
{
add { AddHandler(ClearBidLeadStatsClickedEvent, value); }
remove { RemoveHandler(ClearBidLeadStatsClickedEvent, value); }
add { AddHandler(OpenDatabaseFolderClickedEvent, value); }
remove { RemoveHandler(OpenDatabaseFolderClickedEvent, value); }
}
public event RoutedEventHandler BrowseDataFolderClicked
// ── Gestione del rischio: kill-switch e HALT si applicano subito ──
private void WipeAllDataButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(WipeAllDataClickedEvent, this));
private void ResetSettingsButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ResetSettingsClickedEvent, this));
private void ExportAppLogButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ExportAppLogClickedEvent, this));
public static readonly RoutedEvent WipeAllDataClickedEvent = EventManager.RegisterRoutedEvent(
"WipeAllDataClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent ResetSettingsClickedEvent = EventManager.RegisterRoutedEvent(
"ResetSettingsClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent ExportAppLogClickedEvent = EventManager.RegisterRoutedEvent(
"ExportAppLogClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler WipeAllDataClicked
{
add { AddHandler(BrowseDataFolderClickedEvent, value); }
remove { RemoveHandler(BrowseDataFolderClickedEvent, value); }
add { AddHandler(WipeAllDataClickedEvent, value); }
remove { RemoveHandler(WipeAllDataClickedEvent, value); }
}
public event RoutedEventHandler BrowseStatsFolderClicked
public event RoutedEventHandler ResetSettingsClicked
{
add { AddHandler(BrowseStatsFolderClickedEvent, value); }
remove { RemoveHandler(BrowseStatsFolderClickedEvent, value); }
add { AddHandler(ResetSettingsClickedEvent, value); }
remove { RemoveHandler(ResetSettingsClickedEvent, value); }
}
public event RoutedEventHandler OpenDataFolderClicked
public event RoutedEventHandler ExportAppLogClicked
{
add { AddHandler(OpenDataFolderClickedEvent, value); }
remove { RemoveHandler(OpenDataFolderClickedEvent, value); }
add { AddHandler(ExportAppLogClickedEvent, value); }
remove { RemoveHandler(ExportAppLogClickedEvent, value); }
}
public event RoutedEventHandler OpenStatsFolderClicked
private void ResumeHaltButton_Click(object sender, RoutedEventArgs e)
{
add { AddHandler(OpenStatsFolderClickedEvent, value); }
remove { RemoveHandler(OpenStatsFolderClickedEvent, value); }
Services.RiskManager.Resume();
RefreshRiskStatus();
}
public event RoutedEventHandler BrowseLogFolderClicked
/// <summary>Riallinea lo stato dell'HALT e la contabilità a quello che dice il database.</summary>
public void RefreshRiskStatus()
{
add { AddHandler(BrowseLogFolderClickedEvent, value); }
remove { RemoveHandler(BrowseLogFolderClickedEvent, value); }
}
try
{
var halt = Services.RiskManager.HaltReason;
var s = Services.RiskManager.Snapshot();
var conto = $"Oggi: spesi {s.TodaySpent:F2} €, incassati {s.TodayGained:F2} € (netto {s.TodayNet:+0.00;-0.00} €). " +
$"Saldo cumulato {s.Cumulative:+0.00;-0.00} €, picco {s.Peak:F2} €, drawdown {s.Drawdown:F2} €.";
public event RoutedEventHandler OpenLogFolderClicked
{
add { AddHandler(OpenLogFolderClickedEvent, value); }
remove { RemoveHandler(OpenLogFolderClickedEvent, value); }
RiskStatusText.Text = string.IsNullOrEmpty(halt)
? "Nessun HALT in corso.\n" + conto
: $"HALT in corso: {halt}\n{conto}";
ResumeHaltButton.Visibility = string.IsNullOrEmpty(halt) ? Visibility.Collapsed : Visibility.Visible;
}
catch { }
finally { }
}
// ===== AGGIUNTA AUTOMATICA =====
@@ -21,6 +21,9 @@ namespace AutoBidder
// Carica impostazioni predefinite aste
DefaultBidBeforeDeadlineMs.Text = settings.DefaultBidBeforeDeadlineMs.ToString();
Settings.AdaptiveLeadCheckBox.IsChecked = settings.AdaptiveLeadEnabled;
Settings.LeadMinMsTextBox.Text = settings.LeadMinMs.ToString();
Settings.LeadMaxMsTextBox.Text = settings.LeadMaxMs.ToString();
DefaultMinPrice.Text = settings.DefaultMinPrice.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
DefaultMaxPrice.Text = settings.DefaultMaxPrice.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
DefaultMaxClicks.Text = settings.DefaultMaxClicks.ToString();
@@ -63,11 +66,6 @@ namespace AutoBidder
}
// Motore di precisione
Settings.PollIntervalFarMsTextBox.Text = settings.PollIntervalFarMs.ToString();
Settings.PollIntervalMidMsTextBox.Text = settings.PollIntervalMidMs.ToString();
Settings.PollIntervalNearMsTextBox.Text = settings.PollIntervalNearMs.ToString();
Settings.PollIntervalCriticalMsTextBox.Text = settings.PollIntervalCriticalMs.ToString();
Settings.CriticalWindowMsTextBox.Text = settings.CriticalWindowMs.ToString();
Settings.MaxRequestsPerSecondTextBox.Text = settings.MaxRequestsPerSecond.ToString("F0", System.Globalization.CultureInfo.InvariantCulture);
Settings.PrecisionTimerCheckBox.IsChecked = settings.PrecisionTimerEnabled;
@@ -86,11 +84,7 @@ namespace AutoBidder
Settings.CatalogMaxAuctionsTextBox.Text = settings.CatalogMaxAuctions.ToString();
Settings.CatalogAutoRefreshCheckBox.IsChecked = settings.CatalogAutoRefresh;
// Anticipo, aste programmate, notifiche, cartelle
Settings.BidLeadTrackingCheckBox.IsChecked = settings.BidLeadTrackingEnabled;
Settings.BidLeadSuggestionsCheckBox.IsChecked = settings.BidLeadSuggestionsEnabled;
Settings.BidLeadMinSamplesTextBox.Text = settings.BidLeadMinSamples.ToString();
// Aste programmate, notifiche, cartelle
Settings.ScheduledBackoffCheckBox.IsChecked = settings.ScheduledAuctionBackoffEnabled;
Settings.ScheduledPollFarTextBox.Text = settings.ScheduledPollFarSeconds.ToString();
Settings.ScheduledPollMidTextBox.Text = settings.ScheduledPollMidSeconds.ToString();
@@ -104,16 +98,34 @@ namespace AutoBidder
// ed è quello che serve sapere.
RefreshDataFolderFields();
Settings.DetailedStatsCheckBox.IsChecked = settings.DetailedStatsEnabled;
Settings.CatalogCacheSecondsTextBox.Text = settings.CatalogCacheSeconds.ToString();
Settings.WriteAppLogCheckBox.IsChecked = settings.WriteAppLogFile;
Settings.WriteFreeBidsLogCheckBox.IsChecked = settings.WriteFreeBidsLogFile;
Settings.WriteDossiersCheckBox.IsChecked = settings.WriteAuctionDossiers;
Settings.RawPollsCheckBox.IsChecked = settings.DossierIncludeRawPolls;
Settings.LogRetentionTextBox.Text = settings.LogRetentionDays.ToString();
Settings.QuietHoursCheckBox.IsChecked = settings.QuietHoursEnabled;
Settings.QuietHoursStartTextBox.Text = settings.QuietHoursStart.ToString();
Settings.QuietHoursEndTextBox.Text = settings.QuietHoursEnd.ToString();
RefreshBidLeadSummary();
Settings.AutoRemoveFinishedCheckBox.IsChecked = settings.AutoRemoveFinished;
Settings.AutoRemoveKeepMyBidsCheckBox.IsChecked = settings.AutoRemoveKeepWithMyBids;
Settings.AutoRemoveKeepWonCheckBox.IsChecked = settings.AutoRemoveKeepWon;
Settings.AutoRemoveKeepUnclearCheckBox.IsChecked = settings.AutoRemoveKeepUnclear;
Settings.WriteAppLogCheckBox.IsChecked = settings.WriteAppLog;
Settings.WriteFreeBidsLogCheckBox.IsChecked = settings.WriteFreeBidsLog;
Settings.WriteDossiersCheckBox.IsChecked = settings.RecordAuctions;
Settings.RawPollsCheckBox.IsChecked = settings.RecordPolls;
Settings.LogRetentionTextBox.Text = settings.LogRetentionDays.ToString();
Settings.DatabaseFolderTextBox.Text = AppPaths.DatabaseFolder;
Settings.BankrollEnabledCheckBox.IsChecked = settings.BankrollManagerEnabled;
Settings.MaxBidsPerAuctionTextBox.Text = settings.MaxBidsPerAuction.ToString();
Settings.MaxBidsPerSessionTextBox.Text = settings.MaxBidsPerSession.ToString();
Settings.DailyBudgetTextBox.Text = settings.DailyBudgetEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.DailyStopLossTextBox.Text = settings.DailyStopLossEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.MaxDrawdownTextBox.Text = settings.MaxDrawdownEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.MaxConcurrentTextBox.Text = settings.MaxConcurrentActiveAuctions.ToString();
Settings.TransactionFeeTextBox.Text = settings.TransactionFeeEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.HedgeCheckBox.IsChecked = settings.BuyNowHedgeEnabled;
Settings.RefreshRiskStatus();
// Aggiorna indicatore visivo
UpdateMinBidsIndicator(settings.MinimumRemainingBids);
@@ -181,6 +193,16 @@ namespace AutoBidder
Log("[ERRORE] Valore anticipo puntata non valido (deve essere 0-5000ms)", LogLevel.Error);
return;
}
// Paletti dell'anticipo adattivo: minimo sotto il massimo, entrambi sensati.
settings.AdaptiveLeadEnabled = Settings.AdaptiveLeadCheckBox.IsChecked == true;
settings.LeadMinMs = ReadBounded(Settings.LeadMinMsTextBox.Text, settings.LeadMinMs, 100, 5000, "anticipo minimo", " ms");
settings.LeadMaxMs = ReadBounded(Settings.LeadMaxMsTextBox.Text, settings.LeadMaxMs, 100, 5000, "anticipo massimo", " ms");
if (settings.LeadMaxMs < settings.LeadMinMs)
{
Log($"[ERRORE] Anticipo massimo ({settings.LeadMaxMs} ms) sotto il minimo ({settings.LeadMinMs} ms): riportato al minimo", LogLevel.Error);
settings.LeadMaxMs = settings.LeadMinMs;
}
if (double.TryParse(DefaultMinPrice.Text.Replace(',', '.'), System.Globalization.NumberStyles.Any,
@@ -311,11 +333,6 @@ namespace AutoBidder
// === SEZIONE: Motore di precisione ===
// Ogni cadenza ha un minimo sensato: sotto i 100 ms si spreca banda senza
// guadagnare precisione, perché la puntata la decide il cecchino, non il poll.
settings.PollIntervalFarMs = ReadBounded(Settings.PollIntervalFarMsTextBox.Text, settings.PollIntervalFarMs, 200, 30000, "polling lontano");
settings.PollIntervalMidMs = ReadBounded(Settings.PollIntervalMidMsTextBox.Text, settings.PollIntervalMidMs, 150, 10000, "polling medio");
settings.PollIntervalNearMs = ReadBounded(Settings.PollIntervalNearMsTextBox.Text, settings.PollIntervalNearMs, 100, 5000, "polling vicino");
settings.PollIntervalCriticalMs = ReadBounded(Settings.PollIntervalCriticalMsTextBox.Text, settings.PollIntervalCriticalMs, 100, 3000, "polling critico");
settings.CriticalWindowMs = ReadBounded(Settings.CriticalWindowMsTextBox.Text, settings.CriticalWindowMs, 1000, 60000, "finestra critica");
if (double.TryParse(Settings.MaxRequestsPerSecondTextBox.Text.Replace(',', '.'),
System.Globalization.NumberStyles.Any,
@@ -381,12 +398,6 @@ namespace AutoBidder
settings.CatalogCacheSeconds = ReadBounded(Settings.CatalogCacheSecondsTextBox.Text,
settings.CatalogCacheSeconds, 0, 3600, "cache catalogo", " s");
// === SEZIONE: Anticipo puntata ===
settings.BidLeadTrackingEnabled = Settings.BidLeadTrackingCheckBox.IsChecked ?? true;
settings.BidLeadSuggestionsEnabled = Settings.BidLeadSuggestionsCheckBox.IsChecked ?? true;
settings.BidLeadMinSamples = ReadBounded(Settings.BidLeadMinSamplesTextBox.Text,
settings.BidLeadMinSamples, 5, 500, "puntate minime per il consiglio", "");
// === SEZIONE: Aste programmate ===
settings.ScheduledAuctionBackoffEnabled = Settings.ScheduledBackoffCheckBox.IsChecked ?? true;
settings.ScheduledPollFarSeconds = ReadBounded(Settings.ScheduledPollFarTextBox.Text,
@@ -401,29 +412,43 @@ namespace AutoBidder
settings.NotifyOnLoss = Settings.NotifyOnLossCheckBox.IsChecked ?? false;
// === SEZIONE: Cartelle dei dati ===
var previousData = AppPaths.DataFolder;
var previousStats = AppPaths.StatsFolder;
var previousDatabaseFolder = AppPaths.DatabaseFolder;
// Il campo contiene il percorso risolto: se coincide con il predefinito si
// salva vuoto, così spostando la cartella dati i registri la seguono invece
// di restare inchiodati a un percorso che nessuno ha mai scelto davvero.
settings.DataFolder = NormalizeFolderChoice(Settings.DataFolderTextBox.Text, AppPaths.DataFolder, settings.DataFolder);
settings.StatsFolder = NormalizeFolderChoice(Settings.StatsFolderTextBox.Text, AppPaths.StatsFolder, settings.StatsFolder);
settings.LogFolder = NormalizeFolderChoice(Settings.LogFolderTextBox.Text, AppPaths.LogFolder, settings.LogFolder);
settings.DetailedStatsEnabled = Settings.DetailedStatsCheckBox.IsChecked ?? true;
settings.QuietHoursEnabled = Settings.QuietHoursCheckBox.IsChecked ?? true;
if (int.TryParse(Settings.QuietHoursStartTextBox.Text?.Trim(), out var qStart) && qStart is >= 0 and <= 23)
settings.QuietHoursStart = qStart;
if (int.TryParse(Settings.QuietHoursEndTextBox.Text?.Trim(), out var qEnd) && qEnd is >= 0 and <= 24)
settings.QuietHoursEnd = qEnd;
settings.WriteAppLogFile = Settings.WriteAppLogCheckBox.IsChecked ?? true;
settings.WriteFreeBidsLogFile = Settings.WriteFreeBidsLogCheckBox.IsChecked ?? true;
settings.WriteAuctionDossiers = Settings.WriteDossiersCheckBox.IsChecked ?? true;
settings.DossierIncludeRawPolls = Settings.RawPollsCheckBox.IsChecked ?? true;
settings.AutoRemoveFinished = Settings.AutoRemoveFinishedCheckBox.IsChecked ?? true;
settings.AutoRemoveKeepWithMyBids = Settings.AutoRemoveKeepMyBidsCheckBox.IsChecked ?? true;
settings.AutoRemoveKeepWon = Settings.AutoRemoveKeepWonCheckBox.IsChecked ?? true;
settings.AutoRemoveKeepUnclear = Settings.AutoRemoveKeepUnclearCheckBox.IsChecked ?? true;
settings.WriteAppLog = Settings.WriteAppLogCheckBox.IsChecked ?? true;
settings.WriteFreeBidsLog = Settings.WriteFreeBidsLogCheckBox.IsChecked ?? true;
settings.RecordAuctions = Settings.WriteDossiersCheckBox.IsChecked ?? true;
settings.RecordPolls = Settings.RawPollsCheckBox.IsChecked ?? true;
if (int.TryParse(Settings.LogRetentionTextBox.Text?.Trim(), out var retention) && retention >= 0)
settings.LogRetentionDays = retention;
settings.DatabaseFolder = NormalizeFolderChoice(Settings.DatabaseFolderTextBox.Text, AppPaths.DatabaseFolder, settings.DatabaseFolder);
settings.BankrollManagerEnabled = Settings.BankrollEnabledCheckBox.IsChecked ?? true;
settings.MaxBidsPerAuction = ReadBounded(Settings.MaxBidsPerAuctionTextBox.Text, settings.MaxBidsPerAuction, 0, 100000, "puntate massime per asta", "");
settings.MaxBidsPerSession = ReadBounded(Settings.MaxBidsPerSessionTextBox.Text, settings.MaxBidsPerSession, 0, 1000000, "puntate massime per sessione", "");
settings.DailyBudgetEuro = ReadEuro(Settings.DailyBudgetTextBox.Text, settings.DailyBudgetEuro, "tetto di spesa del giorno");
settings.DailyStopLossEuro = ReadEuro(Settings.DailyStopLossTextBox.Text, settings.DailyStopLossEuro, "stop-loss del giorno");
settings.MaxDrawdownEuro = ReadEuro(Settings.MaxDrawdownTextBox.Text, settings.MaxDrawdownEuro, "drawdown massimo");
settings.MaxConcurrentActiveAuctions = ReadBounded(Settings.MaxConcurrentTextBox.Text, settings.MaxConcurrentActiveAuctions, 0, 1000, "aste in gioco insieme", "");
settings.TransactionFeeEuro = ReadEuro(Settings.TransactionFeeTextBox.Text, settings.TransactionFeeEuro, "fee di transazione");
settings.BuyNowHedgeEnabled = Settings.HedgeCheckBox.IsChecked ?? true;
Utilities.SettingsManager.Save(settings);
ApplyDataFolderSettings(settings, previousData, previousStats);
ApplyDatabaseFolderSetting(settings, previousDatabaseFolder);
// Il limite di ritmo si applica a caldo, senza ricreare il trasporto.
_auctionMonitor.ApplyTransportSettings(settings);
@@ -469,6 +494,17 @@ namespace AutoBidder
return current;
}
/// <summary>Un importo in euro, con virgola o punto; se non valido si tiene il valore attuale.</summary>
private double ReadEuro(string? text, double current, string label)
{
var t = (text ?? "").Trim().Replace(',', '.');
if (double.TryParse(t, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v) && v >= 0 && v < 1_000_000)
return Math.Round(v, 2);
Log($"[ERRORE] Valore {label} non valido: mantenuto {current:F2} €", LogLevel.Error);
return current;
}
/// <summary>
/// Mostra lo stato del motore: aggancio all'orologio del server e traffico prodotto.
/// </summary>
@@ -68,19 +68,21 @@ namespace AutoBidder
$"(prezzo {record.FinalPrice:F2} EUR)", LogLevel.Info);
}
// La scheda dettagliata vive solo nel riepilogo del dossier: l'archivio
// mensile che la duplicava non c'è più (vedi AuctionDetailStore).
var detail = BuildDetail(auction, state, record);
if (settings.DetailedStatsEnabled)
{
AuctionDetailStore.Append(detail);
}
// Il dossier si chiude con il riepilogo: da lì in poi quel file è una
// storia completa, ed è così che l'analisi sa di poterlo usare.
AuctionDossier.Close(auction, detail, state);
// La registrazione si chiude con il riepilogo: da lì in poi la riga è una
// storia completa, ed è così che l'analisi sa di poterla usare.
Data.AuctionRecorder.Close(auction, detail, state);
ProductStatsStore.RecordCompleted(record, detail);
// L'apprendimento impara subito da questa asta: il dossier è già su disco
// (Close lo svuota) e lo storico è già scritto. Fuori dal percorso di
// chiusura, che deve restare corto.
_ = System.Threading.Tasks.Task.Run(() => Ml.LearningService.OnAuctionClosed(record));
NotifyOutcome(record, settings);
Dispatcher.BeginInvoke(() =>
@@ -97,6 +99,12 @@ namespace AutoBidder
{
LoadStatistics();
}
// Ultimo passo, e non prima: lo storico è già scritto, il dossier
// chiuso, le statistiche del prodotto aggiornate. Togliere l'asta
// dall'elenco a questo punto non perde niente — tutto ciò che aveva
// da dire è già altrove.
RimuoviSeConclusaEDaTogliere(auction, won, settings);
});
}
catch (Exception ex)
@@ -105,6 +113,56 @@ namespace AutoBidder
}
}
/// <summary>
/// Toglie dall'elenco l'asta appena conclusa, se la regola lo consente.
///
/// <para>Con l'aggiunta automatica accesa le aste concluse si accumulano a centinaia
/// e seppelliscono quelle vive, che è il contrario di quello che serve guardando un
/// cruscotto. Restano però quelle su cui si è speso: sono le uniche che vale la pena
/// riguardare, e i soldi erano veri. Vedi <see cref="FinishedAuctionCleanup"/>.</para>
///
/// <para>Il motivo finisce sempre nel registro, anche quando l'asta resta: senza,
/// un'asta che sparisce o che non sparisce sarebbero entrambe inspiegabili.</para>
/// </summary>
private void RimuoviSeConclusaEDaTogliere(AuctionInfo auction, bool won, AppSettings settings)
{
try
{
var verdict = FinishedAuctionCleanup.Decide(auction, won, new FinishedAuctionCleanup.Options
{
Enabled = settings.AutoRemoveFinished,
KeepWithMyBids = settings.AutoRemoveKeepWithMyBids,
KeepWon = settings.AutoRemoveKeepWon,
KeepUnclear = settings.AutoRemoveKeepUnclear
});
if (!verdict.Remove)
{
if (settings.AutoRemoveFinished)
Log($"[ELENCO] '{auction.Name}' resta in elenco: {verdict.Reason}", LogLevel.Info);
return;
}
// Stesso giro della rimozione a mano: prima il motore, poi la griglia,
// poi il salvataggio. Toglierla solo dalla griglia lascerebbe il runner
// a interrogare un'asta che non esiste piu' per chi guarda.
_auctionMonitor.RemoveAuction(auction.AuctionId);
var vm = _auctionViewModels.FirstOrDefault(x => x.AuctionId == auction.AuctionId);
if (vm != null) _auctionViewModels.Remove(vm);
SaveAuctions();
UpdateTotalCount();
UpdateGlobalControlButtons();
Log($"[ELENCO] '{auction.Name}' tolta dall'elenco: {verdict.Reason}", LogLevel.Info);
}
catch (Exception ex)
{
Console.WriteLine($"[ELENCO ERROR] {ex.Message}");
}
}
/// <summary>
/// Quando l'asta è finita davvero, in ordine di attendibilità: l'istante fissato dal
/// monitor quando ha visto la conclusione dal vivo; la scadenza dichiarata dal
@@ -178,8 +236,7 @@ namespace AutoBidder
BidCostEuro = auction.BidCost,
ObservedFromStart = auction.ObservedFromStart,
ObservedToEnd = auction.ObservedToEnd,
DossierPath = AuctionDossier.For(auction.AuctionId)?.Path
ObservedToEnd = auction.ObservedToEnd
};
}
-25
View File
@@ -456,31 +456,6 @@ namespace AutoBidder
}
}
private void ExportAuctionButton_Click(object sender, RoutedEventArgs e)
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta dalla griglia", "Nessuna Selezione", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
try
{
MessageBox.Show(
$"Esportazione singola asta:\n\n{_selectedAuction.Name}\n(ID: {_selectedAuction.AuctionId})\n\nFunzionalit in sviluppo.\nUsa 'Esporta' dalla toolbar per esportare tutte le aste.",
"Export Asta",
MessageBoxButton.OK,
MessageBoxImage.Information);
Log($"[INFO] Richiesto export singolo per asta: {_selectedAuction.Name} (funzionalit in sviluppo)", LogLevel.Info);
}
catch (Exception ex)
{
Log($"[ERRORE] Export asta: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async void RefreshProductInfoButton_Click(object sender, RoutedEventArgs e)
{
try
+10 -39
View File
@@ -39,21 +39,18 @@ namespace AutoBidder
RefreshFreeBidsPanel();
}
private void TabEsporta_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(Export);
// Il conteggio si rifà all'apertura: nel frattempo possono essersi concluse
// altre aste, e un numero vecchio farebbe scegliere i filtri al buio.
Export.RefreshPreview();
}
private void TabDatiStatistici_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(StatisticsPanel);
LoadStatistics();
}
private void TabApprendimento_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(Learning);
Learning.Refresh();
}
private void TabImpostazioni_Checked(object sender, RoutedEventArgs e)
{
try
@@ -74,7 +71,8 @@ namespace AutoBidder
{
// Prevent NullReferenceException during initialization
if (AuctionMonitor == null || Browser == null || StatisticsPanel == null ||
Settings == null || FreeBids == null || Products == null || Export == null)
Settings == null || FreeBids == null || Products == null ||
Learning == null)
return;
// Hide all panels
@@ -83,8 +81,8 @@ namespace AutoBidder
Products.Visibility = Visibility.Collapsed;
FreeBids.Visibility = Visibility.Collapsed;
StatisticsPanel.Visibility = Visibility.Collapsed;
Export.Visibility = Visibility.Collapsed;
Settings.Visibility = Visibility.Collapsed;
Learning.Visibility = Visibility.Collapsed;
// Show selected panel
if (panelToShow != null)
@@ -108,29 +106,6 @@ namespace AutoBidder
StopButton_Click(sender, e);
}
private void AuctionMonitor_ExportClicked(object sender, RoutedEventArgs e)
{
// Chiama il metodo di export esistente
try
{
// Esporta tutte le aste monitorate
var auctions = _auctionMonitor.GetAuctions();
if (auctions.Count == 0)
{
System.Windows.MessageBox.Show("Nessuna asta da esportare", "Export", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Information);
return;
}
// TODO: Implementare dialog export con scelta formato
System.Windows.MessageBox.Show($"Export di {auctions.Count} aste.\n\nFunzionalit in sviluppo.\nUsa le impostazioni nella scheda Impostazioni per configurare l'export.", "Export Aste", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Information);
Log($"[INFO] Richiesto export di {auctions.Count} aste (funzionalit in sviluppo)", Utilities.LogLevel.Info);
}
catch (System.Exception ex)
{
Log($"[ERRORE] Export: {ex.Message}", Utilities.LogLevel.Error);
}
}
private void AuctionMonitor_AddUrlClicked(object sender, RoutedEventArgs e)
{
AddUrlButton_Click(sender, e);
@@ -199,11 +174,6 @@ namespace AutoBidder
OpenAuctionExternalButton_Click(sender, e);
}
private void AuctionMonitor_ExportAuctionClicked(object sender, RoutedEventArgs e)
{
ExportAuctionButton_Click(sender, e);
}
private void AuctionMonitor_ResetSettingsClicked(object sender, RoutedEventArgs e)
{
ResetSettingsButton_Click(sender, e);
@@ -246,6 +216,7 @@ namespace AutoBidder
if (_selectedAuction != null && int.TryParse(AuctionMonitor.SelectedBidBeforeDeadlineMs.Text, out int ms))
{
_selectedAuction.AuctionInfo.BidBeforeDeadlineMs = ms;
_selectedAuction.AuctionInfo.BidLeadIsManual = true;
SaveAuctions();
}
}
+131 -147
View File
@@ -2,121 +2,29 @@ using System;
using System.Diagnostics;
using System.IO;
using System.Windows;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Cartelle dei dati, misura dell'anticipo ed esportazione dello storico.
/// La cartella dei database (con la migrazione quando cambia) e le esportazioni dello
/// storico, che chiedono sempre dove salvare.
/// </summary>
public partial class MainWindow
{
// ── Anticipo: misura e consiglio ─────────────────────────────────
// ── Cartella dei database ────────────────────────────────────────
private void RefreshBidLeadSummary()
{
try
{
var s = BidLeadStats.Summarize();
if (!s.HasData)
{
Settings.SetBidLeadSummary(
"Nessuna puntata registrata finora. Le misure si raccolgono da sole a ogni puntata.");
return;
}
Settings.SetBidLeadSummary(
$"{s.Count} puntate misurate — {s.Successes} riuscite ({s.SuccessRate:F0}%), {s.TooLate} tardive.\n" +
$"Arrivano in media {s.AverageActualMs:F0} ms prima della scadenza " +
$"(mediana {s.MedianActualMs:F0} ms, caso peggiore {s.MinActualMs:F0} ms).\n" +
$"Ping medio {s.AveragePingMs:F0} ms, anticipo impostato di norma {s.TypicalConfiguredMs} ms.");
}
catch { }
}
private void Settings_ComputeBidLeadAdviceClicked(object sender, RoutedEventArgs e)
{
try
{
var settings = SettingsManager.Load();
var current = settings.DefaultBidBeforeDeadlineMs;
var advice = BidLeadStats.Suggest(current, settings.BidLeadMinSamples);
RefreshBidLeadSummary();
if (!advice.HasAdvice)
{
MessageBox.Show(this, advice.Reason, "Anticipo puntata",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
// Il consiglio non si applica mai da solo: cambia il parametro che decide
// se una puntata arriva o no, e la decisione resta dell'utente.
var answer = MessageBox.Show(this,
$"{advice.Reason}\n\nVuoi impostare l'anticipo predefinito a {advice.SuggestedLeadMs} ms?\n\n" +
"Le aste già nel monitor mantengono il proprio valore.",
"Anticipo puntata", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
settings.DefaultBidBeforeDeadlineMs = advice.SuggestedLeadMs;
SettingsManager.Save(settings);
DefaultBidBeforeDeadlineMs.Text = advice.SuggestedLeadMs.ToString();
Log($"[ANTICIPO] Anticipo predefinito portato a {advice.SuggestedLeadMs} ms", LogLevel.Success);
}
catch (Exception ex)
{
Log($"[ANTICIPO] {ex.Message}", LogLevel.Error);
}
}
private void Settings_ClearBidLeadStatsClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Vuoi azzerare tutte le misure sull'anticipo?\n\n" +
"Serviranno di nuovo diverse puntate prima di poter dare un consiglio.",
"Misure anticipo", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
BidLeadStats.Clear();
RefreshBidLeadSummary();
Log("[ANTICIPO] Misure azzerate", LogLevel.Info);
}
// ── Cartelle dei dati ────────────────────────────────────────────
/// <summary>
/// Riempie i campi dei percorsi con quelli davvero in uso.
///
/// <para>I campi non restano mai vuoti: un campo vuoto costringeva a cercare altrove
/// dove finiscono i file, ed è il motivo per cui sotto c'era una riga che ripeteva i
/// percorsi. Scriverli direttamente nel campo toglie la ripetizione e rende il valore
/// selezionabile e copiabile. Chi vuole tornare al predefinito lo svuota: al
/// salvataggio si ricompila da sé.</para>
/// </summary>
/// <summary>Il campo mostra sempre il percorso in uso: selezionabile, copiabile.</summary>
private void RefreshDataFolderFields()
{
try
{
Settings.DataFolderTextBox.Text = AppPaths.DataFolder;
Settings.StatsFolderTextBox.Text = AppPaths.StatsFolder;
Settings.LogFolderTextBox.Text = AppPaths.LogFolder;
}
try { Settings.DatabaseFolderTextBox.Text = AppPaths.DatabaseFolder; }
catch { }
}
/// <summary>
/// Traduce quello che c'è nel campo in ciò che va salvato.
///
/// <para>Il campo mostra sempre un percorso pieno, ma nelle impostazioni "vuoto"
/// ha un significato che non si vuole perdere: <i>usa il predefinito</i>. Se il
/// testo coincide con il predefinito corrente si salva vuoto — così spostando la
/// cartella dati le sottocartelle la seguono — e se il campo è stato svuotato a mano
/// si torna al predefinito. Solo un percorso davvero diverso viene registrato.</para>
/// Traduce quello che c'è nel campo in ciò che va salvato: vuoto = predefinito,
/// uguale al predefinito = vuoto, un percorso davvero diverso = quel percorso.
/// </summary>
private static string NormalizeFolderChoice(string? typed, string resolvedDefault, string previouslySaved)
{
@@ -124,7 +32,6 @@ namespace AutoBidder
if (text.Length == 0) return "";
// Uguale a ciò che è in uso, e in uso c'era il predefinito: resta predefinito.
if (string.Equals(text, resolvedDefault, StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(previouslySaved))
return "";
@@ -132,36 +39,17 @@ namespace AutoBidder
return text;
}
private void Settings_BrowseDataFolderClicked(object sender, RoutedEventArgs e)
private void Settings_BrowseDatabaseFolderClicked(object sender, RoutedEventArgs e)
{
var chosen = PickFolder("Scegli la cartella dei dati d'esercizio", AppPaths.DataFolder);
if (chosen != null) Settings.DataFolderTextBox.Text = chosen;
var chosen = PickFolder("Scegli la cartella dei database (non una cartella sincronizzata)", AppPaths.DatabaseFolder);
if (chosen != null) Settings.DatabaseFolderTextBox.Text = chosen;
}
private void Settings_BrowseStatsFolderClicked(object sender, RoutedEventArgs e)
{
var chosen = PickFolder("Scegli la cartella delle statistiche", AppPaths.StatsFolder);
if (chosen != null) Settings.StatsFolderTextBox.Text = chosen;
}
private void Settings_BrowseLogFolderClicked(object sender, RoutedEventArgs e)
{
var chosen = PickFolder("Scegli la cartella dei registri", AppPaths.LogFolder);
if (chosen != null) Settings.LogFolderTextBox.Text = chosen;
}
private void Settings_OpenDataFolderClicked(object sender, RoutedEventArgs e)
=> OpenFolder(AppPaths.DataFolder);
private void Settings_OpenStatsFolderClicked(object sender, RoutedEventArgs e)
=> OpenFolder(AppPaths.StatsFolder);
private void Settings_OpenLogFolderClicked(object sender, RoutedEventArgs e)
=> OpenFolder(AppPaths.LogFolder);
private void Settings_OpenDatabaseFolderClicked(object sender, RoutedEventArgs e)
=> OpenFolder(AppPaths.DatabaseFolder);
/// <summary>
/// Selettore di cartella. Usa quello di WinForms perché WPF non ne offre uno:
/// l'alternativa sarebbe una finestra scritta a mano, molto peggio.
/// Selettore di cartella. Usa quello di WinForms perché WPF non ne offre uno.
/// </summary>
private static string? PickFolder(string description, string startFrom)
{
@@ -199,38 +87,134 @@ namespace AutoBidder
}
/// <summary>
/// Applica i percorsi dopo un salvataggio. Se sono cambiati serve un riavvio: gli
/// archivi hanno già in memoria i dati letti dalla posizione precedente, e
/// spostarli a caldo rischierebbe di mescolarli.
/// Dopo un salvataggio con la cartella dei database cambiata: chiede se spostare i
/// file nella nuova posizione, li sposta a database chiusi, e riavvia. Senza
/// riavvio gli archivi terrebbero in memoria i dati letti dalla posizione vecchia.
/// </summary>
private void ApplyDataFolderSettings(AppSettings settings, string previousData, string previousStats)
private void ApplyDatabaseFolderSetting(AppSettings settings, string previousFolder)
{
AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder);
AppPaths.EnsureFolders();
var newFolder = settings.DatabaseFolder.Length > 0 ? settings.DatabaseFolder : AppPaths.DefaultDatabaseFolder;
// I campi tornano a mostrare i percorsi effettivi: chi ne ha svuotato uno deve
// vedere subito qual è il predefinito che ha appena scelto.
RefreshDataFolderFields();
try { newFolder = Path.GetFullPath(newFolder); } catch { }
if (previousData == AppPaths.DataFolder && previousStats == AppPaths.StatsFolder) return;
if (string.Equals(previousFolder.TrimEnd('\\'), newFolder.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
{
RefreshDataFolderFields();
return;
}
MessageBox.Show(this,
"Le cartelle dei dati sono cambiate.\n\n" +
$"Dati: {AppPaths.DataFolder}\nStatistiche: {AppPaths.StatsFolder}\n\n" +
"Riavvia l'applicazione perché vengano usate davvero. I file già scritti " +
"restano dove sono: puoi spostarli a mano nella nuova posizione.",
"Cartelle dei dati", MessageBoxButton.OK, MessageBoxImage.Information);
var answer = MessageBox.Show(this,
$"La cartella dei database cambia da\n{previousFolder}\na\n{newFolder}\n\n" +
"Vuoi spostare i database esistenti nella nuova cartella?\n\n" +
"Sì: sposto i file e riavvio l'applicazione.\n" +
"No: la nuova cartella parte vuota (i vecchi file restano dove sono) e riavvio.\n" +
"Annulla: torno alla cartella di prima.",
"Cartella dei database", MessageBoxButton.YesNoCancel, MessageBoxImage.Question);
Log($"[DATI] Nuovi percorsi impostati (riavvio necessario)", LogLevel.Warning);
if (answer == MessageBoxResult.Cancel)
{
settings.DatabaseFolder = string.Equals(previousFolder, AppPaths.DefaultDatabaseFolder, StringComparison.OrdinalIgnoreCase) ? "" : previousFolder;
SettingsManager.Save(settings);
RefreshDataFolderFields();
return;
}
try
{
if (answer == MessageBoxResult.Yes)
{
// Prima si chiudono: un file SQLite aperto non si sposta, e il giornale
// WAL va con lui.
AuctionDatabase.CloseInstance();
OperationalDatabase.CloseInstance();
Directory.CreateDirectory(newFolder);
var moved = 0;
foreach (var db in new[] { AppPaths.DatabaseFile, AppPaths.OperationalDatabaseFile })
{
foreach (var f in AppPaths.DatabaseFiles(db))
{
var target = Path.Combine(newFolder, Path.GetFileName(f));
if (File.Exists(target)) File.Delete(target);
File.Move(f, target);
moved++;
}
}
Log($"[DATI] Database spostati in {newFolder} ({moved} file). Riavvio.", LogLevel.Warning);
}
else
{
Log($"[DATI] Nuova cartella dei database: {newFolder} (vuota). Riavvio.", LogLevel.Warning);
}
}
catch (Exception ex)
{
MessageBox.Show(this, $"Spostamento non riuscito: {ex.Message}\n\nLa cartella resta quella di prima.",
"Cartella dei database", MessageBoxButton.OK, MessageBoxImage.Error);
settings.DatabaseFolder = string.Equals(previousFolder, AppPaths.DefaultDatabaseFolder, StringComparison.OrdinalIgnoreCase) ? "" : previousFolder;
SettingsManager.Save(settings);
RefreshDataFolderFields();
return;
}
RestartApplication();
}
// ── Esportazione dello storico ───────────────────────────────────
/// <summary>Riavvia l'applicazione: lo stesso eseguibile, e questa istanza si chiude.</summary>
private void RestartApplication()
{
try
{
var exe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exe))
Process.Start(new ProcessStartInfo { FileName = exe, UseShellExecute = true });
}
catch (Exception ex)
{
Log($"[APP] Riavvio non riuscito: {ex.Message}. Riapri l'applicazione a mano.", LogLevel.Error);
}
Application.Current.Shutdown();
}
// ── Esportazione dello storico: chiede sempre dove ───────────────
private void ExportStatsCsvButton_Click(object sender, RoutedEventArgs e)
=> RunExport(StatsExporter.ExportCsv(), "CSV");
{
var path = AskWhereToSave("Esporta lo storico in CSV", $"storico-aste-{DateTime.Now:yyyyMMdd-HHmm}.csv", "CSV (foglio di calcolo)|*.csv");
if (path == null) return;
RunExport(StatsExporter.ExportCsv(path), "CSV");
}
private void ExportStatsJsonButton_Click(object sender, RoutedEventArgs e)
=> RunExport(StatsExporter.ExportJson(), "JSON");
{
var path = AskWhereToSave("Esporta lo storico in JSON", $"storico-aste-{DateTime.Now:yyyyMMdd-HHmm}.json", "JSON|*.json");
if (path == null) return;
RunExport(StatsExporter.ExportJson(path), "JSON");
}
/// <summary>La finestra «Salva con nome»: nessun percorso predefinito, decide l'utente.</summary>
private string? AskWhereToSave(string title, string fileName, string filter)
{
try
{
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = title,
FileName = fileName,
Filter = filter,
OverwritePrompt = true,
AddExtension = true
};
return dialog.ShowDialog(this) == true ? dialog.FileName : null;
}
catch (Exception ex)
{
Log($"[ESPORTA] Finestra di salvataggio non disponibile: {ex.Message}", LogLevel.Error);
return null;
}
}
private void RunExport(StatsExporter.Result result, string format)
{
@@ -250,7 +234,7 @@ namespace AutoBidder
if (answer == MessageBoxResult.Yes)
{
OpenFolder(Path.GetDirectoryName(result.Path) ?? AppPaths.ExportFolder);
OpenFolder(Path.GetDirectoryName(result.Path) ?? AppPaths.ConfigRoot);
}
}
}
-96
View File
@@ -1,96 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Windows;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Scheda Esporta: raccoglie in un unico file le aste che rispondono ai filtri.
///
/// <para>Qui c'è solo il collegamento fra il pannello e
/// <see cref="AuctionExporter"/>. La scrittura può durare qualche secondo — con gli
/// eventi inclusi si leggono decine di dossier — quindi gira su un thread di lavoro:
/// bloccare l'interfaccia mentre il motore sta seguendo un'asta non è accettabile.</para>
/// </summary>
public partial class MainWindow
{
private async void Export_ExportRequested(object sender, RoutedEventArgs e)
{
try
{
var filter = Export.BuildFilter();
Log("[ESPORTA] Esportazione in corso…", LogLevel.Info);
var result = await System.Threading.Tasks.Task
.Run(() => AuctionExporter.Export(filter))
.ConfigureAwait(true);
Export.ShowResult(result);
Log(result.Success
? $"[ESPORTA] {result.Message} → {result.Path}"
: $"[ESPORTA] {result.Message}",
result.Success ? LogLevel.Success : LogLevel.Warning);
}
catch (Exception ex)
{
Log($"[ESPORTA] Esportazione non riuscita: {ex.Message}", LogLevel.Error);
}
}
private void Export_OpenFolderClicked(object sender, RoutedEventArgs e)
{
try
{
Directory.CreateDirectory(AppPaths.ExportFolder);
OpenInExplorer(AppPaths.ExportFolder);
}
catch (Exception ex)
{
Log($"[ESPORTA] Apertura cartella non riuscita: {ex.Message}", LogLevel.Error);
}
}
private void Export_OpenLastFileClicked(object sender, RoutedEventArgs e)
{
var path = Export.LastExportPath;
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return;
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
Log($"[ESPORTA] Apertura file non riuscita: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Apre una cartella in Esplora risorse. Sta qui perché la usano più schede: i
/// pulsanti "apri" dei percorsi, le esportazioni e i registri.
/// </summary>
internal void OpenInExplorer(string folder)
{
try
{
if (string.IsNullOrWhiteSpace(folder)) return;
Directory.CreateDirectory(folder);
Process.Start(new ProcessStartInfo
{
FileName = folder,
UseShellExecute = true
});
}
catch (Exception ex)
{
Log($"[CARTELLE] Impossibile aprire {folder}: {ex.Message}", LogLevel.Error);
}
}
}
}
+27
View File
@@ -18,6 +18,7 @@ namespace AutoBidder
public partial class MainWindow
{
private DispatcherTimer? _headerTimer;
private DispatcherTimer? _timerTicker;
private void StartMonitorHeaderTimer()
{
@@ -25,9 +26,35 @@ namespace AutoBidder
_headerTimer.Tick += (_, _) => RefreshMonitorHeader();
_headerTimer.Start();
// ── Il conto alla rovescia scorre da solo ────────────────────────
//
// Il tempo mancante non ha bisogno di una risposta dal server per essere
// ricalcolato: la scadenza è ancorata all'orologio di Bidoo e il resto è
// sottrazione. Prima però veniva notificato solo all'arrivo di un poll, e
// nelle prove reali fra un poll e l'altro passava anche un secondo e mezzo:
// il numero restava fermo e sembrava tutto bloccato.
//
// Cinque battiti al secondo: la colonna mostra i decimi sotto il minuto,
// quindi si vede scorrere davvero, e restano un ventesimo delle notifiche
// che servirebbero per seguire il decimo esatto.
_timerTicker = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) };
_timerTicker.Tick += (_, _) => TickTimers();
_timerTicker.Start();
RefreshMonitorHeader();
}
private void TickTimers()
{
var vms = _auctionViewModels;
for (var i = 0; i < vms.Count; i++)
{
try { vms[i].RefreshTimeDisplay(); }
catch { /* una riga che non si aggiorna non deve fermare le altre */ }
}
}
private void RefreshMonitorHeader()
{
try
+67
View File
@@ -217,6 +217,73 @@ namespace AutoBidder
Log("[PRODOTTI] Elenco svuotato", LogLevel.Info);
}
private void Products_ClearAllClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Pulizia completa dei prodotti:\n\n" +
"• tolgo tutti i prodotti dall'elenco, con i loro limiti\n" +
"• azzero le statistiche per prodotto (aste concluse, prezzi tipici, consigli)\n\n" +
"Lo storico delle aste non viene toccato. Le aste già nel monitor restano dove sono.\n\nProcedo?",
"Prodotti", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
WatchedProductsStore.Clear();
ProductStatsStore.ClearStatistics();
LoadProducts();
MarkWatchedProducts(_catalogAuctions);
Log("[PRODOTTI] Pulizia completa: elenco e statistiche per prodotto azzerati", LogLevel.Warning);
}
private void Products_UnwatchAllClicked(object sender, RoutedEventArgs e)
{
var accese = _productViewModels.Count(p => p.IsWatched);
if (accese == 0)
{
MessageBox.Show(this, "Nessun prodotto ha la stellina accesa.",
"Prodotti", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Spengo la stellina di {accese} prodotti?\n\n" +
"Nessun prodotto verrà più cercato in automatico. Restano tutti in elenco con i loro limiti.",
"Prodotti", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
var n = WatchedProductsStore.UnwatchAll();
LoadProducts();
MarkWatchedProducts(_catalogAuctions);
Log($"[PRODOTTI] Stelline spente su {n} prodotti", LogLevel.Info);
}
private void Products_RemoveUnwatchedClicked(object sender, RoutedEventArgs e)
{
var spente = _productViewModels.Count(p => !p.IsWatched);
if (spente == 0)
{
MessageBox.Show(this, "Tutti i prodotti in elenco hanno la stellina accesa: non c'è nulla da togliere.",
"Prodotti", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Tolgo dall'elenco i {spente} prodotti senza stellina?\n\n" +
"Si perdono i limiti impostati su quei prodotti. Le statistiche per prodotto restano.",
"Prodotti", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
var n = WatchedProductsStore.RemoveUnwatched();
LoadProducts();
MarkWatchedProducts(_catalogAuctions);
Log($"[PRODOTTI] Tolti {n} prodotti senza stellina", LogLevel.Info);
}
/// <summary>
/// Scrive nei limiti del prodotto i valori consigliati dallo storico — minimo e
/// massimo insieme, perché è la coppia a definire la finestra in cui ha senso
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.IO;
using System.Windows;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// L'interruttore dell'apprendimento nella barra del monitor, e le due operazioni
/// estreme delle Impostazioni: cancellare tutti i dati tranne la login, e tornare alle
/// impostazioni di fabbrica.
/// </summary>
public partial class MainWindow
{
// ── Apprendimento acceso / spento ────────────────────────────────
private bool _syncingLearningToggle;
private void AuctionMonitor_LearningToggled(object sender, RoutedEventArgs e)
{
if (_syncingLearningToggle) return;
var on = AuctionMonitor.LearningToggle.IsChecked == true;
var settings = SettingsManager.Load();
if (settings.LearningEnabled == on) return;
settings.LearningEnabled = on;
SettingsManager.Save(settings);
Log(on
? "[APPRENDIMENTO] Acceso: il motore decide con valore atteso, regime, duello e anticipo adattivo"
: "[APPRENDIMENTO] SPENTO: si punta solo entro i limiti (prezzo, puntate, budget, fascia oraria) con l'anticipo fisso",
on ? LogLevel.Success : LogLevel.Warning);
try { Learning.Refresh(); } catch { }
}
/// <summary>Allinea l'interruttore in barra a ciò che dicono le impostazioni.</summary>
private void RefreshLearningToggle()
{
try
{
_syncingLearningToggle = true;
AuctionMonitor.LearningToggle.IsChecked = SettingsManager.Load().LearningEnabled;
}
catch { }
finally { _syncingLearningToggle = false; }
}
// ── Manutenzione ─────────────────────────────────────────────────
private void Settings_WipeAllDataClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Cancello TUTTI i dati dell'applicazione:\n\n" +
"• i due database (aste, puntate, decisioni, prodotti seguiti, aste nel monitor, modelli appresi, registri)\n" +
"• le copie di sicurezza\n" +
"• la vecchia cartella Dati delle versioni precedenti\n\n" +
"Restano la login (sessione, riferimenti del sito, browser) e le impostazioni.\n" +
"L'operazione non è reversibile e l'applicazione si riavvia subito.\n\nProcedo?",
"Elimina tutti i dati", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
var conferma = MessageBox.Show(this,
"Ultima conferma: cancellare tutto tranne la login?",
"Elimina tutti i dati", MessageBoxButton.YesNo, MessageBoxImage.Stop);
if (conferma != MessageBoxResult.Yes) return;
try { _auctionMonitor.Stop(); } catch { }
var errors = AppDataWipe.WipeAllExceptLogin();
if (errors.Count > 0)
{
MessageBox.Show(this, "Alcuni file non si sono potuti cancellare:\n\n" + string.Join("\n", errors) +
"\n\nL'applicazione si riavvia comunque.", "Elimina tutti i dati", MessageBoxButton.OK, MessageBoxImage.Warning);
}
RestartApplication();
}
private void Settings_ResetSettingsClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Riporto tutte le impostazioni ai valori di fabbrica e riavvio l'applicazione.\n\n" +
"I dati (database, prodotti, storico, apprendimento) e la login non vengono toccati.\n\nProcedo?",
"Impostazioni di fabbrica", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
try { _auctionMonitor.Stop(); } catch { }
AppDataWipe.ResetSettings();
RestartApplication();
}
private void Settings_ExportAppLogClicked(object sender, RoutedEventArgs e)
{
var path = AskWhereToSave("Esporta il registro applicativo", $"registro-{DateTime.Now:yyyyMMdd-HHmm}.txt", "Testo|*.txt");
if (path == null) return;
try
{
var n = OperationalDatabase.Instance.ExportLog(OperationalDatabase.AppLog, path);
var m = OperationalDatabase.Instance.ExportLog(OperationalDatabase.FreeBidsLog,
Path.Combine(Path.GetDirectoryName(path)!, Path.GetFileNameWithoutExtension(path) + "-puntate.txt"));
Log($"[REGISTRI] Esportate {n} righe del registro applicativo e {m} del riscatto puntate in {Path.GetDirectoryName(path)}", LogLevel.Success);
OpenFolder(Path.GetDirectoryName(path)!);
}
catch (Exception ex)
{
Log($"[REGISTRI] Esportazione non riuscita: {ex.Message}", LogLevel.Error);
}
}
}
}
+132 -16
View File
@@ -3,6 +3,7 @@ using System.Diagnostics;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder
@@ -48,12 +49,12 @@ namespace AutoBidder
LoadStatistics();
}
/// <summary>Apre la cartella contenente il file storico JSON.</summary>
/// <summary>Apre la cartella dei database.</summary>
private void OpenStatsFolderButton_Click(object sender, RoutedEventArgs e)
{
try
{
var folder = AppPaths.StatsFolder;
var folder = AppPaths.DatabaseFolder;
if (!string.IsNullOrEmpty(folder) && System.IO.Directory.Exists(folder))
{
Process.Start(new ProcessStartInfo
@@ -69,21 +70,121 @@ namespace AutoBidder
}
}
/// <summary>Svuota lo storico delle aste concluse (con conferma).</summary>
// ── Selezione multipla ───────────────────────────────────────────
private void SelectAllStatsButton_Click(object sender, RoutedEventArgs e)
{
StatsDetailGrid.Focus();
StatsDetailGrid.SelectAll();
}
private void DeleteSelectedStatsButton_Click(object sender, RoutedEventArgs e)
=> DeleteSelectedHistoryRows();
private void StatsDetailGrid_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != System.Windows.Input.Key.Delete) return;
e.Handled = true;
DeleteSelectedHistoryRows();
}
/// <summary>
/// Toglie dallo storico le righe selezionate. Prima la copia di sicurezza, poi la
/// cancellazione, poi le statistiche per prodotto ricalcolate da ciò che resta.
/// </summary>
private void DeleteSelectedHistoryRows()
{
var selected = StatsDetailGrid.SelectedItems.OfType<CompletedAuctionRecord>().ToList();
if (selected.Count == 0)
{
MessageBox.Show(this, "Seleziona prima una o più righe dello storico (Ctrl+clic, Maiusc+clic, o «Seleziona tutte»).",
"Storico", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
(selected.Count == 1
? $"Elimino dallo storico \"{selected[0].Name}\"?"
: $"Elimino dallo storico {selected.Count} aste?") +
"\n\nCon loro vanno via puntate, interrogazioni, reset e decisioni registrate. " +
"Prima salvo una copia nei backup e poi ricalcolo le statistiche per prodotto.",
"Storico", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
try
{
var backup = CompletedAuctionsStore.BackupNow();
var n = CompletedAuctionsStore.Remove(selected.Select(r => r.AuctionId).ToList());
var ricostruite = ProductStatsStore.RebuildFromHistory();
LoadStatistics();
LoadProducts();
Log($"[STORICO] Eliminate {n} aste; statistiche per prodotto ricalcolate su {ricostruite} aste rimaste. Copia di sicurezza in {backup}",
LogLevel.Success);
}
catch (Exception ex)
{
Log($"[STORICO] Eliminazione non riuscita: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Svuota tutto lo storico in un colpo: osservazioni, decisioni, misure, contabilità
/// del rischio e statistiche per prodotto. Prodotti seguiti e apprendimento restano.
/// </summary>
private void WipeHistoryButton_Click(object sender, RoutedEventArgs e)
{
var count = CompletedAuctionsStore.LoadAll().Count;
if (count == 0)
{
MessageBox.Show(this, "Lo storico è già vuoto.", "Storico", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Svuoto completamente lo storico ({count} aste)?\n\n" +
"Vanno via tutte le aste concluse con puntate, interrogazioni, reset, decisioni, misure di rete " +
"e contabilità del rischio, e le statistiche per prodotto tornano a zero.\n" +
"Prima salvo una copia nei backup. Prodotti seguiti, aste nel monitor e apprendimento restano.\n\nProcedo?",
"Storico", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
try
{
var backup = CompletedAuctionsStore.BackupNow();
CompletedAuctionsStore.Clear();
ProductStatsStore.ClearStatistics();
LoadStatistics();
LoadProducts();
Log($"[STORICO] Svuotato: {count} aste tolte. Copia di sicurezza in {backup}", LogLevel.Warning);
}
catch (Exception ex)
{
Log($"[STORICO] Svuotamento non riuscito: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Azzera le statistiche registrate: il dialogo dice cosa c'è e quanto pesa, e
/// l'utente sceglie voce per voce. Vedi <see cref="StatsWipe"/>.
/// </summary>
private void ClearStatsButton_Click(object sender, RoutedEventArgs e)
{
var res = MessageBox.Show(this,
"Vuoi eliminare tutto lo storico delle aste concluse? L'operazione non è reversibile.",
"Svuota storico",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
var dialog = new Dialogs.WipeStatsDialog { Owner = this };
if (dialog.ShowDialog() != true || dialog.Result is not { } report) return;
if (res == MessageBoxResult.Yes)
{
CompletedAuctionsStore.Clear();
LoadStatistics();
Log("[STATISTICHE] Storico svuotato", LogLevel.Info);
}
LoadStatistics();
LoadProducts();
if (Learning.IsVisible) Learning.Refresh();
Log($"[STATISTICHE] Azzeramento: {report.Summary}. " +
$"{report.FilesDeleted} file tolti, {report.BytesFreed / (1024.0 * 1024.0):F1} MB liberati",
report.Errors.Count == 0 ? LogLevel.Success : LogLevel.Warning);
}
/// <summary>
@@ -105,11 +206,26 @@ namespace AutoBidder
if (dialog.ShowDialog() != true) return;
// Le statistiche per prodotto sono un archivio a parte, alimentato asta per
// asta man mano che chiudono: dopo una pulizia descriverebbero aste che non ci
// sono più — e sono loro a decidere i limiti consigliati e i tetti di puntate.
// Si ricostruiscono da zero dallo storico appena pulito, l'unica fonte rimasta.
var ricostruite = 0;
try
{
ricostruite = ProductStatsStore.RebuildFromHistory();
}
catch (Exception ex)
{
Log($"[STATISTICHE] Ricalcolo per prodotto non riuscito: {ex.Message}", LogLevel.Warning);
}
LoadStatistics();
LoadProducts();
Log($"[STORICO] Pulizia: tolte {dialog.RemovedCount} aste. " +
$"Copia di sicurezza in {dialog.BackupPath}", LogLevel.Success);
Log($"[STORICO] Pulizia: tolte {dialog.RemovedCount} aste; statistiche per prodotto " +
$"ricalcolate su {ricostruite} aste rimaste. Copia di sicurezza in {dialog.BackupPath}",
LogLevel.Success);
}
/// <summary>
+1
View File
@@ -272,6 +272,7 @@ namespace AutoBidder
// Resetta ai valori predefiniti dalle impostazioni
_selectedAuction.AuctionInfo.BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs;
_selectedAuction.AuctionInfo.BidLeadIsManual = false; // torna all'anticipo adattivo
_selectedAuction.MinPrice = settings.DefaultMinPrice;
_selectedAuction.MaxPrice = settings.DefaultMaxPrice;
_selectedAuction.MaxClicks = settings.DefaultMaxClicks;
+328
View File
@@ -0,0 +1,328 @@
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);
""";
}
}
+521
View File
@@ -0,0 +1,521 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Data
{
/// <summary>
/// Registra nel database tutto ciò che succede a un'asta mentre la si segue: ogni
/// interrogazione, ogni puntata di ogni utente con l'ora al millisecondo, ogni reset
/// con la profondità del ciclo, ogni nostra puntata con anticipo voluto ed effettivo,
/// ogni riga di registro, e alla fine il riepilogo.
///
/// <para>È l'erede del dossier JSON per asta, con la stessa superficie: il motore
/// chiama gli stessi metodi di prima e non sa che dietro c'è SQLite. Cambia dove
/// finiscono i dati — una riga per tabella invece di una riga per file — e il fatto
/// che da lì si possano interrogare.</para>
///
/// <para>Perché tanto dettaglio: questi dati <b>non esistono a posteriori</b>. Bidoo
/// non espone lo storico dei prezzi né i tempi delle puntate di un'asta conclusa. O li
/// si raccoglie mentre l'asta va avanti, o sono persi — e sono esattamente quelli su
/// cui il modello impara.</para>
///
/// <para>Nessuna scrittura tocca il disco sul thread che segue l'asta: ogni riga va
/// nella coda del database e prosegue.</para>
/// </summary>
public sealed class AuctionRecorder
{
private static readonly ConcurrentDictionary<string, AuctionRecorder> Open = new(StringComparer.Ordinal);
private static readonly JsonSerializerOptions Json = new()
{
WriteIndented = false,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Il nostro nome utente su Bidoo. Lo imposta il motore appena conosce la
/// sessione; finisce sull'asta, così chi rilegge sa quali puntate erano nostre.
/// </summary>
public static volatile string CurrentUsername = "";
private static AuctionDatabase Db => AuctionDatabase.Instance;
private readonly DateTime _startedAt;
private readonly List<int> _pings = new();
private readonly object _sync = new();
private long _polls;
private long _events;
private bool _closed;
// Coalescenza dei poll: si scrive quando cambia qualcosa, e comunque una volta
// al secondo. Misurato sui dossier: l'88,8% delle interrogazioni ha la stessa
// faccia della precedente.
private string? _lastPollSignature;
private DateTime _lastPollWrittenAt = DateTime.MinValue;
// Del ciclo in corso: fin dove è sceso il cronometro e quando è cominciato.
private double _cycleMinTimer = double.MaxValue;
private double _cycleStartedElapsed = -1;
private double _completedCycleMinTimer = double.NaN;
private double _completedCycleSeconds = double.NaN;
private double _lastPollPrice = double.NaN;
private const int MaxPingSamples = 50_000;
public string AuctionId { get; }
/// <summary>Righe scritte finora per quest'asta.</summary>
public long EventCount => _events;
private AuctionRecorder(string auctionId, DateTime startedAt)
{
AuctionId = auctionId;
_startedAt = startedAt;
}
// ── Apertura e chiusura ──────────────────────────────────────────
/// <summary>
/// Apre (o riapre) la registrazione di quest'asta. Riaprendo l'applicazione
/// mentre l'asta è ancora viva si <b>riprende la stessa riga</b>: un'asta seguita
/// a cavallo di un riavvio deve restare una storia sola.
/// </summary>
public static AuctionRecorder? OpenFor(AuctionInfo auction, AppSettings settings)
{
if (auction == null || !settings.RecordAuctions) return null;
if (string.IsNullOrEmpty(auction.AuctionId)) return null;
return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings));
}
/// <summary>La registrazione già aperta per quest'asta, se c'è.</summary>
public static AuctionRecorder? For(string auctionId) =>
auctionId != null && Open.TryGetValue(auctionId, out var r) ? r : null;
/// <summary>Fa confluire nel database tutte le righe di registro delle aste. Una volta all'avvio.</summary>
public static void CaptureAuctionLogs()
{
AuctionInfo.LogSink = (auction, entry) =>
For(auction.AuctionId)?.Log(
entry.Level.ToString().ToLowerInvariant(),
entry.Category.ToString(),
entry.Message);
}
/// <summary>
/// L'asta è stata tolta dal monitor prima della fine: la riga resta, marcata
/// come interrotta, perché una serie di prezzi che si ferma a metà sembra
/// un'asta finita a quel prezzo.
/// </summary>
public static void Abandon(string auctionId, string reason)
{
if (auctionId == null) return;
if (!Open.TryRemove(auctionId, out var rec)) return;
rec.LogRow("info", "State", $"asta tolta dal monitor prima della conclusione: {reason}");
Db.Enqueue(
"UPDATE auctions SET status = CASE WHEN outcome IS NULL THEN 'abandoned' ELSE status END, " +
"abandon_reason = ?2, closed_at = COALESCE(closed_at, ?3), polls = ?4 WHERE auction_id = ?1",
auctionId, reason, AuctionDatabase.Now(), rec._polls);
}
/// <summary>Aspetta che tutto ciò che è in coda sia sul disco.</summary>
public static void FlushAll() => Db.Flush();
/// <summary>
/// Una decisione del motore, in Osserva (shadow) come in Attiva (live): cosa
/// avrebbe fatto o ha fatto, con che stima e per quale motivo. Restituisce l'id,
/// che la puntata porta con sé. L'esito si scrive alla chiusura dell'asta.
/// </summary>
public static long RecordDecision(
AuctionInfo auction, AuctionState state, string action, double? pWin, double? ev,
string? reason, string mode, string? regime, string? policy, double? leadMs,
string? contextKey = null, string? altAction = null, double? altP = null)
{
var id = Db.NextDecisionId();
var rec = For(auction.AuctionId);
var stateJson = JsonSerializer.Serialize(new
{
price = state.Price,
timer = Math.Round(state.Timer, 2),
lastBidder = string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder,
pingMs = state.PollingLatencyMs,
bidsUsed = auction.BidsUsedOnThisAuction,
resets = auction.ResetCount,
leadMs = leadMs ?? auction.BidBeforeDeadlineMs,
duel = auction.AutoBidDuelDetected,
autoResponses = auction.AutoResponsesInARow,
recentBidders = auction.SnapshotRecentBids(10).Select(b => b.Username).Distinct(StringComparer.OrdinalIgnoreCase).Count()
}, Json);
Db.Enqueue(
"INSERT INTO bot_decisions(decision_id, auction_id, ts, t, price, timer, action, p_win, ev_estimate, model_version, policy, regime, mode, reason_detail, state_json, context_key, alt_policy, alt_action, alt_p) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
id, auction.AuctionId, AuctionDatabase.Now(), rec?.Elapsed(), state.Price, Math.Round(state.Timer, 2), action,
pWin, ev, Ml.LearningService.ModelVersion, policy, regime, mode, reason, stateJson,
contextKey, altAction == null ? null : "bandit", altAction, altP);
return id;
}
public static int OpenCount => Open.Count;
private static AuctionRecorder Create(AuctionInfo auction, AppSettings settings)
{
var rec = new AuctionRecorder(auction.AuctionId, DateTime.Now);
var now = AuctionDatabase.Now();
var productKey = ProductKeyHelper.GenerateProductKey(auction.Name);
var config = JsonSerializer.Serialize(new
{
state = auction.State.ToString(),
bidBeforeDeadlineMs = auction.BidBeforeDeadlineMs > 0 ? auction.BidBeforeDeadlineMs : settings.DefaultBidBeforeDeadlineMs,
minPrice = auction.MinPrice,
maxPrice = auction.MaxPrice,
maxBids = auction.MaxClicks,
minResets = auction.MinResets,
maxResets = auction.MaxResets,
bidLeadIsManual = auction.BidLeadIsManual,
adaptiveLead = settings.AdaptiveLeadEnabled,
leadMinMs = settings.LeadMinMs,
leadMaxMs = settings.LeadMaxMs,
valueCheckEnabled = settings.ValueCheckEnabled,
minSavingsPercentage = settings.MinSavingsPercentage,
rawPollsIncluded = settings.RecordPolls
}, Json);
var me = string.IsNullOrEmpty(CurrentUsername) ? null : CurrentUsername;
Db.EnqueueBatch(new (string, object?[])[]
{
("INSERT INTO items(product_key, title, retail_price_bidoo, shipping, first_seen_at, updated_at) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?5) " +
"ON CONFLICT(product_key) DO UPDATE SET " +
" title = CASE WHEN excluded.title != '' THEN excluded.title ELSE title END, " +
" retail_price_bidoo = COALESCE(excluded.retail_price_bidoo, retail_price_bidoo), " +
" shipping = COALESCE(excluded.shipping, shipping), updated_at = excluded.updated_at",
new object?[] { productKey, auction.Name ?? "", auction.BuyNowPrice, auction.ShippingCost, now }),
("INSERT INTO auctions(auction_id, item_id, product_key, name, url, status, me, app_version, " +
" added_at, first_seen_at, opened_at, bid_fee, shipping, buy_now_price, market_value, has_win_limit, win_limit, " +
" configured_lead_ms, lead_is_manual, config_json) " +
"VALUES(?1, (SELECT item_id FROM items WHERE product_key = ?2), ?2, ?3, ?4, 'open', ?5, ?6, ?7, ?8, ?8, ?9, ?10, ?11, " +
" (SELECT market_price_est FROM items WHERE product_key = ?2), ?12, ?13, ?14, ?15, ?16) " +
"ON CONFLICT(auction_id) DO UPDATE SET " +
" me = COALESCE(excluded.me, me), app_version = excluded.app_version, " +
" buy_now_price = COALESCE(excluded.buy_now_price, buy_now_price), " +
" shipping = COALESCE(excluded.shipping, shipping), " +
" configured_lead_ms = excluded.configured_lead_ms, lead_is_manual = excluded.lead_is_manual, " +
" config_json = excluded.config_json",
new object?[]
{
auction.AuctionId, productKey, auction.Name ?? "", auction.OriginalUrl ?? "", me, AppInfo.Version,
AuctionDatabase.Iso(auction.AddedAt), now, auction.BidCost, auction.ShippingCost, auction.BuyNowPrice,
auction.HasWinLimit, auction.WinLimitDescription,
auction.BidBeforeDeadlineMs > 0 ? auction.BidBeforeDeadlineMs : settings.DefaultBidBeforeDeadlineMs,
auction.BidLeadIsManual, config
})
});
rec.LogRow("info", "State", "registrazione aperta");
return rec;
}
/// <summary>
/// Chiude la registrazione con il riepilogo. Da qui in avanti la riga non cambia
/// più: è ciò che dice a chi analizza che la storia è completa.
/// </summary>
public static void Close(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
if (auction == null) return;
if (!Open.TryRemove(auction.AuctionId, out var rec)) return;
rec.WriteSummary(auction, detail, finalState);
}
private void WriteSummary(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
lock (_sync)
{
if (_closed) return;
_closed = true;
}
var pings = PingStats();
var series = JsonSerializer.Serialize(detail.PriceSeries.Select(p => new { t = Math.Round(p.T, 2), price = p.Price }), Json);
var byUser = JsonSerializer.Serialize(detail.BidsByUser, Json);
Db.EnqueueBatch(new (string, object?[])[]
{
("UPDATE auctions SET status = 'closed', closed_at = ?2, end_time = ?3, outcome = ?4, won_by_me = ?5, winner_user = ?6, " +
" final_price = ?7, final_status = ?8, buy_now_price = COALESCE(?9, buy_now_price), shipping = COALESCE(?10, shipping), " +
" bid_fee = ?11, my_bids = ?12, resets = ?13, distinct_bidders = ?14, total_observed_bids = ?15, top_bidder_share = ?16, " +
" observed_from_start = ?17, observed_to_end = ?18, observed_minutes = ?19, first_seen_at = ?20, " +
" avg_ping_ms = ?21, p95_ping_ms = ?22, polls = ?23, poll_errors = ?24, configured_lead_ms = ?25, lead_is_manual = ?26, " +
" timer_expired = ?27, successful_bids = ?28, failed_bids = ?29, price_velocity_per_minute = ?30, " +
" price_series_json = ?31, bids_by_user_json = ?32 " +
"WHERE auction_id = ?1",
new object?[]
{
AuctionId, AuctionDatabase.Now(), AuctionDatabase.Iso(detail.EndedAt), detail.Outcome, detail.WonByMe, detail.Winner,
detail.FinalPrice, finalState?.Status.ToString(), detail.BuyNowPrice, detail.ShippingCost,
auction.BidCost, detail.MyBids, detail.Resets, detail.DistinctBidders, detail.TotalObservedBids, Math.Round(detail.TopBidderShare, 4),
auction.ObservedFromStart, auction.ObservedToEnd, Math.Round(detail.ObservedMinutes, 2), AuctionDatabase.Iso(detail.FirstSeenAt),
pings.Avg, pings.P95, _polls, detail.PollErrors, detail.ConfiguredLeadMs, auction.BidLeadIsManual,
auction.TimerExpiredCount, auction.SuccessfulBidCount, auction.FailedBidCount, Math.Round(detail.PriceVelocityPerMinute, 4),
series, byUser
}),
// Chi ha puntato in quest'asta: aggregato una volta, alla chiusura.
("INSERT INTO bidders(username, first_seen, last_seen, n_auctions, n_bids, n_auto, n_manual, win_count) " +
"SELECT username, MIN(local_ts), MAX(local_ts), 1, COUNT(*), " +
" SUM(CASE WHEN bid_type = 'Auto' THEN 1 ELSE 0 END), SUM(CASE WHEN bid_type = 'Manuale' THEN 1 ELSE 0 END), 0 " +
"FROM bids WHERE auction_id = ?1 GROUP BY username " +
"ON CONFLICT(username) DO UPDATE SET last_seen = excluded.last_seen, first_seen = COALESCE(first_seen, excluded.first_seen), " +
" n_auctions = n_auctions + 1, n_bids = n_bids + excluded.n_bids, n_auto = n_auto + excluded.n_auto, n_manual = n_manual + excluded.n_manual",
new object?[] { AuctionId }),
("UPDATE bidders SET win_count = win_count + 1 WHERE username = ?1 AND ?1 != ''",
new object?[] { detail.Winner ?? "" }),
("UPDATE items SET retail_price_bidoo = COALESCE(?2, retail_price_bidoo), shipping = COALESCE(?3, shipping), updated_at = ?4 " +
"WHERE product_key = ?1",
new object?[] { detail.ProductKey, detail.BuyNowPrice, detail.ShippingCost, AuctionDatabase.Now() }),
// L'esito di ogni decisione, ora che si sa come è finita. Per una puntata
// partita davvero il prezzo dopo di lei era price + 0,01; per una
// decisione shadow o un NO-OP il prezzo è quello del momento. Se il prezzo
// finale non l'ha superato, nessuno ha più puntato: quella sarebbe stata
// (o è stata) la puntata vincente.
("UPDATE bot_decisions SET outcome = CASE " +
" WHEN price + 0.01 >= ?2 - 0.005 THEN 'won' ELSE 'answered' END " +
"WHERE auction_id = ?1 AND executed = 1 AND action = 'BID' AND (outcome IS NULL OR outcome = 'sent')",
new object?[] { AuctionId, detail.FinalPrice }),
("UPDATE bot_decisions SET outcome = CASE " +
" WHEN price >= ?2 - 0.005 THEN (CASE WHEN action = 'BID' THEN 'would_win' ELSE 'missed' END) " +
" ELSE (CASE WHEN action = 'BID' THEN 'would_be_answered' ELSE 'right_skip' END) END " +
"WHERE auction_id = ?1 AND executed = 0 AND outcome IS NULL",
new object?[] { AuctionId, detail.FinalPrice })
});
_events++;
}
// ── Eventi ───────────────────────────────────────────────────────
/// <summary>Una singola interrogazione a Bidoo.</summary>
public void Poll(AuctionState state, bool includeRaw)
{
_polls++;
lock (_sync)
{
if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs);
var now = Elapsed();
if (!double.IsNaN(_lastPollPrice) && state.Price > _lastPollPrice)
{
_completedCycleMinTimer = _cycleMinTimer == double.MaxValue ? double.NaN : _cycleMinTimer;
_completedCycleSeconds = _cycleStartedElapsed >= 0 ? now - _cycleStartedElapsed : double.NaN;
_cycleMinTimer = double.MaxValue;
_cycleStartedElapsed = now;
}
else if (_cycleStartedElapsed < 0)
{
_cycleStartedElapsed = now;
}
_lastPollPrice = state.Price;
if (state.Timer > 0 && state.Timer < _cycleMinTimer) _cycleMinTimer = state.Timer;
}
NetworkSampler.Note(state);
if (!includeRaw) return;
var signature = string.Concat(
state.Price.ToString("F2"), '|', state.LastBidder, '|', state.Status, '|',
(int)state.Timer, '|', state.IsMyBid ? '1' : '0');
var wall = DateTime.Now;
if (signature == _lastPollSignature && (wall - _lastPollWrittenAt).TotalMilliseconds < 1000)
return;
_lastPollSignature = signature;
_lastPollWrittenAt = wall;
Write("INSERT INTO polls(auction_id, t, local_ts, price, timer, status, last_bidder, is_mine, ping_ms, expiry_unix, server_unix, clock_offset_ms) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
AuctionId, Elapsed(), AuctionDatabase.Now(), state.Price, Math.Round(state.Timer, 2), state.Status.ToString(),
string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder, state.IsMyBid, state.PollingLatencyMs,
state.ExpiryUnixSeconds, state.ServerUnixSeconds, NetworkSampler.ClockOffsetMs(state));
}
/// <summary>Una puntata altrui (o nostra, vista dallo storico), come l'ha riportata Bidoo.</summary>
public void ForeignBid(BidHistoryEntry entry, double price)
{
double? timerBefore;
lock (_sync) timerBefore = _cycleMinTimer == double.MaxValue ? null : Math.Round(_cycleMinTimer, 2);
// Il tipo dichiarato dal server vince su quello ignoto di una riga già presente;
// una puntata ricostruita da un cambio di prezzo non sovrascrive mai un tipo vero.
Write("INSERT INTO bids(auction_id, username, price_after, bid_type, is_mine, server_ts, seen_t, local_ts, timer_before) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " +
"ON CONFLICT(auction_id, price_after) DO UPDATE SET " +
" bid_type = CASE WHEN excluded.bid_type != '—' THEN excluded.bid_type ELSE bid_type END, " +
" server_ts = COALESCE(NULLIF(excluded.server_ts, 0), server_ts), " +
" is_mine = MAX(is_mine, excluded.is_mine), " +
" username = CASE WHEN excluded.username != '' THEN excluded.username ELSE username END",
AuctionId, entry.Username ?? "", price, entry.BidType ?? BidHistoryEntry.TipoSconosciuto, entry.IsMyBid,
entry.Timestamp > 0 ? entry.Timestamp : (object?)null, Elapsed(), AuctionDatabase.Now(), timerBefore);
}
/// <summary>Una mia puntata, con tutto ciò che serve a giudicarla.</summary>
public void MyBid(
double price,
int plannedLeadMs,
double actualLeadMs,
int pingMs,
bool success,
string? error,
int? bidsUsed,
int? remainingBids,
int rttMs = 0,
long? decisionId = null)
{
Write("INSERT INTO my_bids(auction_id, t, local_ts, price, planned_lead_ms, actual_lead_ms, ping_ms, rtt_ms, success, error, bids_used, remaining_bids, decision_id) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
AuctionId, Elapsed(), AuctionDatabase.Now(), price, plannedLeadMs, Math.Round(actualLeadMs, 1), pingMs, rttMs,
success, error, bidsUsed, remainingBids, decisionId);
if (decisionId is { } id)
Db.Enqueue("UPDATE bot_decisions SET executed = 1, outcome = ?2 WHERE decision_id = ?1", id, success ? "sent" : "failed:" + (error ?? "?"));
}
/// <summary>Il timer è stato azzerato da una puntata: l'asta continua.</summary>
public void Reset(int resetCount, double price, string? bidder)
{
double? minTimer, cycleSeconds;
lock (_sync)
{
minTimer = double.IsNaN(_completedCycleMinTimer) ? null : Math.Round(_completedCycleMinTimer, 2);
cycleSeconds = double.IsNaN(_completedCycleSeconds) ? null : Math.Round(_completedCycleSeconds, 2);
_completedCycleMinTimer = double.NaN;
_completedCycleSeconds = double.NaN;
}
Write("INSERT INTO resets(auction_id, t, local_ts, reset_count, price, bidder, min_timer, cycle_seconds) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
AuctionId, Elapsed(), AuctionDatabase.Now(), resetCount, price, bidder, minTimer, cycleSeconds);
}
/// <summary>Una riga del registro dell'asta.</summary>
public void Log(string level, string category, string message)
{
// Le righe "[RESET #n]" ripetono l'evento reset che sta già nella sua tabella.
if (message != null && message.StartsWith("[RESET #", StringComparison.Ordinal)) return;
LogRow(level, category, message ?? "");
}
/// <summary>Cambio di modo: Ferma, Osserva, Attiva.</summary>
public void StateChanged(string from, string to) => LogRow("info", "State", $"{from} → {to}");
private void LogRow(string level, string category, string message)
{
Write("INSERT INTO auction_log(auction_id, t, local_ts, level, category, msg) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
AuctionId, Elapsed(), AuctionDatabase.Now(), level, category, message);
}
// ── Interno ──────────────────────────────────────────────────────
private void Write(string sql, params object?[] args)
{
if (_closed) return;
Db.Enqueue(sql, args);
_events++;
}
internal double Elapsed() => Math.Round((DateTime.Now - _startedAt).TotalSeconds, 3);
private (int Count, double Avg, int P95) PingStats()
{
int[] copy;
lock (_sync) copy = _pings.Where(p => p > 0).ToArray();
if (copy.Length == 0) return (0, 0, 0);
Array.Sort(copy);
return (copy.Length, Math.Round(copy.Average(), 1), copy[Math.Min(copy.Length - 1, (int)(copy.Length * 0.95))]);
}
}
/// <summary>
/// Misure di rete aggregate: una riga ogni dieci secondi con mediana, p95, jitter e
/// scarto dell'orologio, su tutte le interrogazioni di tutte le aste. Una riga per
/// poll sarebbero milioni di righe uguali; una ogni dieci secondi racconta la stessa
/// storia in un decimillesimo dello spazio.
/// </summary>
public static class NetworkSampler
{
private static readonly object Sync = new();
private static readonly List<int> _pings = new(512);
private static long _windowStartedTicks = Environment.TickCount64;
private static int _minOffset = int.MaxValue;
private const int WindowMs = 10_000;
/// <summary>
/// Scarto fra il nostro orologio e quello del server, in ms, per questa risposta.
/// Bidoo dichiara i secondi interi, quindi il valore è quantizzato: il minimo su
/// una finestra converge al confine reale del secondo.
/// </summary>
public static int ClockOffsetMs(AuctionState state)
{
if (state.ServerUnixSeconds <= 0) return 0;
var localMs = new DateTimeOffset(state.SnapshotTime.Kind == DateTimeKind.Utc ? state.SnapshotTime : state.SnapshotTime.ToUniversalTime())
.ToUnixTimeMilliseconds() - state.PollingLatencyMs / 2;
return (int)Math.Clamp(localMs - state.ServerUnixSeconds * 1000L, int.MinValue / 2, int.MaxValue / 2);
}
public static void Note(AuctionState state)
{
if (state.PollingLatencyMs <= 0) return;
List<int>? flush = null;
int offset = 0;
lock (Sync)
{
_pings.Add(state.PollingLatencyMs);
var o = ClockOffsetMs(state);
if (state.ServerUnixSeconds > 0 && o < _minOffset) _minOffset = o;
if (Environment.TickCount64 - _windowStartedTicks >= WindowMs)
{
flush = new List<int>(_pings);
offset = _minOffset == int.MaxValue ? 0 : _minOffset;
_pings.Clear();
_minOffset = int.MaxValue;
_windowStartedTicks = Environment.TickCount64;
}
}
if (flush == null || flush.Count == 0) return;
flush.Sort();
var p50 = flush[flush.Count / 2];
var p95 = flush[Math.Min(flush.Count - 1, (int)(flush.Count * 0.95))];
AuctionDatabase.Instance.Enqueue(
"INSERT INTO network_metrics(ts, rtt_ms, jitter_ms, p95_ms, clock_offset_ms, samples, source) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'poll')",
AuctionDatabase.Now(), p50, p95 - p50, p95, offset, flush.Count);
}
}
}
+202
View File
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AutoBidder.Utilities;
namespace AutoBidder.Data
{
/// <summary>
/// Il database d'esercizio: tutto ciò che l'applicazione scrive per sé e non per
/// l'analisi. Sta in un file a parte (<c>esercizio.sqlite</c>) accanto a quello delle
/// osservazioni, così l'archivio delle aste resta un archivio e questo resta piccolo.
///
/// <para><b>Documenti.</b> Le aste nel monitor, i prodotti seguiti, le statistiche per
/// prodotto, le promozioni riscattate, il modello appreso, il profilo, la latenza:
/// ognuno era un file JSON riscritto per intero a ogni modifica. Qui sono righe di una
/// tabella <c>documents</c>, chiave → JSON: la scrittura è atomica (una riga in una
/// transazione, niente file a metà), e c'è un solo file da salvare.</para>
///
/// <para><b>Registri.</b> Il registro applicativo e quello del riscatto puntate sono
/// tabelle: una riga per evento, con data e livello. Si interrogano, si esportano in
/// testo dalle Impostazioni, e si cancellano per età senza girare cartelle.</para>
/// </summary>
public sealed class OperationalDatabase : SqliteDatabase
{
public const int SchemaVersion = 1;
private static readonly object InstanceSync = new();
private static OperationalDatabase? _instance;
public static OperationalDatabase Instance
{
get
{
lock (InstanceSync)
{
if (_instance == null || _instance.IsDisposed)
_instance = Open(AppPaths.OperationalDatabaseFile);
return _instance;
}
}
}
public static void CloseInstance()
{
lock (InstanceSync)
{
_instance?.Dispose();
_instance = null;
}
}
/// <summary>Vero quando l'istanza è aperta e pronta: chi scrive nei registri lo controlla prima.</summary>
public static bool IsOpen
{
get { lock (InstanceSync) return _instance != null && !_instance.IsDisposed; }
}
private OperationalDatabase(string path) : base(path, SchemaV1, SchemaVersion, "AutoBidder.Esercizio") { }
public static OperationalDatabase Open(string path) => new(path);
private const string SchemaV1 = """
-- Un documento JSON per chiave: aste nel monitor, prodotti seguiti,
-- statistiche per prodotto, promozioni riscattate, modelli appresi.
CREATE TABLE IF NOT EXISTS documents(
key TEXT PRIMARY KEY,
json TEXT NOT NULL,
updated_at TEXT NOT NULL);
-- Il registro applicativo: una riga per evento.
CREATE TABLE IF NOT EXISTS app_log(
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS ix_app_log_ts ON app_log(ts);
-- Il registro del riscatto puntate.
CREATE TABLE IF NOT EXISTS freebids_log(
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS ix_freebids_log_ts ON freebids_log(ts);
""";
// ── Documenti ────────────────────────────────────────────────────
/// <summary>Il JSON del documento, o null se non c'è.</summary>
public string? GetDocument(string key)
{
Flush();
return ScalarString("SELECT json FROM documents WHERE key = ?1", key);
}
/// <summary>
/// Il documento, oppure — se manca — il contenuto del vecchio file JSON indicato,
/// che viene importato una volta e da allora vive qui. Il file resta dov'è.
/// </summary>
public string? GetDocumentOrImport(string key, string? legacyFile)
{
var json = GetDocument(key);
if (json != null) return json;
try
{
if (string.IsNullOrEmpty(legacyFile) || !File.Exists(legacyFile)) return null;
json = File.ReadAllText(legacyFile);
if (string.IsNullOrWhiteSpace(json)) return null;
SetDocument(key, json);
Log($"[DATABASE] Importato {System.IO.Path.GetFileName(legacyFile)} come documento «{key}»");
return json;
}
catch (Exception ex)
{
Log($"[DATABASE] Importazione di {legacyFile} non riuscita: {ex.Message}");
return null;
}
}
public void SetDocument(string key, string json)
{
Enqueue("INSERT INTO documents(key, json, updated_at) VALUES(?1, ?2, ?3) " +
"ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at",
key, json, Now());
}
public void DeleteDocument(string key) => Enqueue("DELETE FROM documents WHERE key = ?1", key);
/// <summary>Cancella tutti i documenti con quel prefisso (es. «ml/»).</summary>
public void DeleteDocuments(string prefix) => Enqueue("DELETE FROM documents WHERE key LIKE ?1", prefix + "%");
public IReadOnlyList<(string Key, long Bytes, DateTime UpdatedAt)> Documents()
{
Flush();
return Query("SELECT key, LENGTH(json) AS bytes, updated_at FROM documents ORDER BY key")
.Select(r => (r.Str("key"), r.Long("bytes"), r.Date("updated_at") ?? DateTime.MinValue))
.ToList();
}
// ── Registri ─────────────────────────────────────────────────────
public const string AppLog = "app_log";
public const string FreeBidsLog = "freebids_log";
public void WriteLog(string table, string level, string message)
{
if (table != AppLog && table != FreeBidsLog) return;
Enqueue($"INSERT INTO {table}(ts, level, message) VALUES(?1, ?2, ?3)", Now(), level, message ?? "");
}
/// <summary>Cancella le righe più vecchie di tanti giorni. Restituisce quante.</summary>
public int PurgeLogs(int days)
{
if (days <= 0) return 0;
var cutoff = Iso(DateTime.Now.AddDays(-days));
var removed = 0;
foreach (var table in new[] { AppLog, FreeBidsLog })
{
removed += (int)ScalarLong($"SELECT COUNT(*) AS n FROM {table} WHERE ts < ?1", cutoff);
Enqueue($"DELETE FROM {table} WHERE ts < ?1", cutoff);
}
Flush();
return removed;
}
/// <summary>Le ultime righe di un registro, dalla più recente.</summary>
public List<(DateTime Ts, string Level, string Message)> ReadLog(string table, int max = 2000, DateTime? since = null)
{
Flush();
var rows = since is { } s
? Query($"SELECT ts, level, message FROM {table} WHERE ts >= ?1 ORDER BY id DESC LIMIT ?2", Iso(s), max)
: Query($"SELECT ts, level, message FROM {table} ORDER BY id DESC LIMIT ?1", max);
return rows.Select(r => (r.Date("ts") ?? DateTime.MinValue, r.Str("level"), r.Str("message"))).ToList();
}
/// <summary>Scrive un registro in un file di testo leggibile (una riga per evento, ora locale).</summary>
public int ExportLog(string table, string destination, int days = 0)
{
Flush();
var rows = days > 0
? Query($"SELECT ts, level, message FROM {table} WHERE ts >= ?1 ORDER BY id", Iso(DateTime.Now.AddDays(-days)))
: Query($"SELECT ts, level, message FROM {table} ORDER BY id");
var sb = new StringBuilder();
foreach (var r in rows)
{
var ts = r.Date("ts")?.ToLocalTime() ?? DateTime.MinValue;
sb.Append(ts.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append(" [").Append(r.Str("level")).Append("] ").AppendLine(r.Str("message"));
}
var folder = System.IO.Path.GetDirectoryName(destination);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
File.WriteAllText(destination, sb.ToString(), new UTF8Encoding(true));
return rows.Count;
}
}
}
+495
View File
@@ -0,0 +1,495 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace AutoBidder.Data
{
/// <summary>
/// SQLite senza librerie: si chiama direttamente <c>winsqlite3.dll</c>, la copia di
/// SQLite che Windows 10 e 11 tengono in <c>System32</c> per i propri componenti.
///
/// <para><b>Perché così.</b> L'applicazione non deve dipendere da pacchetti esterni.
/// Un provider ADO.NET porterebbe con sé quattro assembly e una DLL nativa; qui ci
/// sono le quindici funzioni C che servono davvero, avvolte in due classi che non
/// fanno niente di più di quello che il programma usa: aprire, preparare, legare
/// parametri, scorrere righe, chiudere.</para>
///
/// <para><b>Fili.</b> Ogni connessione è usata da un thread alla volta: il database
/// scrive da un thread suo e legge sotto un lucchetto (vedi <see cref="AuctionDatabase"/>).
/// La connessione è comunque aperta in modalità serializzata, per non dipendere dalla
/// disciplina del chiamante.</para>
/// </summary>
internal static class SqliteNative
{
private const string Lib = "winsqlite3.dll";
public const int Ok = 0;
public const int Row = 100;
public const int Done = 101;
public const int Busy = 5;
public const int Locked = 6;
public const int OpenReadWrite = 0x2;
public const int OpenCreate = 0x4;
public const int OpenFullMutex = 0x10000;
public const int TypeInteger = 1;
public const int TypeFloat = 2;
public const int TypeText = 3;
public const int TypeBlob = 4;
public const int TypeNull = 5;
/// <summary>SQLITE_TRANSIENT: SQLite copia il testo legato prima che il buffer venga liberato.</summary>
public static readonly IntPtr Transient = new(-1);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_open_v2(byte[] filename, out IntPtr db, int flags, IntPtr vfs);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_close_v2(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_prepare_v2(IntPtr db, byte[] sql, int nByte, out IntPtr stmt, out IntPtr tail);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_step(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_reset(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_clear_bindings(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_finalize(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_int64(IntPtr stmt, int index, long value);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_double(IntPtr stmt, int index, double value);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_null(IntPtr stmt, int index);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_text(IntPtr stmt, int index, byte[] text, int nBytes, IntPtr destructor);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_column_count(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_column_type(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern long sqlite3_column_int64(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern double sqlite3_column_double(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_column_text(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_column_bytes(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_column_name(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_errmsg(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern long sqlite3_last_insert_rowid(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_changes(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_busy_timeout(IntPtr db, int ms);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_libversion();
public static byte[] Utf8Z(string s)
{
var bytes = Encoding.UTF8.GetBytes(s);
var z = new byte[bytes.Length + 1];
Buffer.BlockCopy(bytes, 0, z, 0, bytes.Length);
return z;
}
public static string? Utf8(IntPtr p) => p == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(p);
}
public sealed class SqliteException : Exception
{
public int Code { get; }
public SqliteException(int code, string message) : base(message) => Code = code;
}
/// <summary>Una connessione. Da usare da un thread alla volta.</summary>
public sealed class SqliteConnection : IDisposable
{
private IntPtr _db;
private readonly Dictionary<string, SqliteStatement> _cache = new(StringComparer.Ordinal);
public string Path { get; }
public static string LibraryVersion
{
get
{
try { return SqliteNative.Utf8(SqliteNative.sqlite3_libversion()) ?? "?"; }
catch (DllNotFoundException) { return "winsqlite3.dll non trovata"; }
}
}
public SqliteConnection(string path)
{
Path = path;
var rc = SqliteNative.sqlite3_open_v2(
SqliteNative.Utf8Z(path), out _db,
SqliteNative.OpenReadWrite | SqliteNative.OpenCreate | SqliteNative.OpenFullMutex,
IntPtr.Zero);
if (rc != SqliteNative.Ok)
{
var msg = _db != IntPtr.Zero ? ErrorMessage() : $"codice {rc}";
if (_db != IntPtr.Zero) SqliteNative.sqlite3_close_v2(_db);
_db = IntPtr.Zero;
throw new SqliteException(rc, $"apertura di {path} non riuscita: {msg}");
}
// Un altro processo (o l'altra connessione) può tenere il file per qualche
// millisecondo: si aspetta invece di fallire.
SqliteNative.sqlite3_busy_timeout(_db, 5000);
}
internal IntPtr Handle => _db;
public string ErrorMessage() => SqliteNative.Utf8(SqliteNative.sqlite3_errmsg(_db)) ?? "errore sconosciuto";
public long LastInsertRowId => SqliteNative.sqlite3_last_insert_rowid(_db);
public int Changes => SqliteNative.sqlite3_changes(_db);
/// <summary>Esegue un'istruzione senza risultati (o ne scarta le righe).</summary>
public void Execute(string sql, params object?[] args)
{
using var stmt = Prepare(sql);
stmt.Bind(args);
stmt.RunToEnd();
}
/// <summary>Esegue più istruzioni separate da punto e virgola, senza parametri.</summary>
public void ExecuteScript(string script)
{
var bytes = SqliteNative.Utf8Z(script);
var offset = 0;
while (offset < bytes.Length - 1)
{
var remaining = new byte[bytes.Length - offset];
Buffer.BlockCopy(bytes, offset, remaining, 0, remaining.Length);
var rc = SqliteNative.sqlite3_prepare_v2(_db, remaining, -1, out var stmt, out var tail);
if (rc != SqliteNative.Ok) throw new SqliteException(rc, ErrorMessage());
// Il puntatore alla coda restituito da SQLite riguarda una copia nativa
// del buffer di cui non abbiamo la base: si ricava quanto è stato
// consumato scandendo fino al primo ';' fuori da apici e commenti, che è
// esattamente dove SQLite si è fermato.
var consumed = IndexAfterStatement(remaining);
if (stmt != IntPtr.Zero)
{
using var s = new SqliteStatement(this, stmt, "");
s.RunToEnd();
}
offset += Math.Max(1, consumed);
}
}
/// <summary>Fino al primo ';' fuori da apici e commenti, incluso.</summary>
private static int IndexAfterStatement(byte[] bytes)
{
var inQuote = false;
var inLineComment = false;
for (var i = 0; i < bytes.Length - 1; i++)
{
var c = (char)bytes[i];
if (inLineComment) { if (c == '\n') inLineComment = false; continue; }
if (c == '\'') inQuote = !inQuote;
else if (!inQuote && c == '-' && i + 1 < bytes.Length && bytes[i + 1] == '-') inLineComment = true;
else if (!inQuote && c == ';') return i + 1;
}
return bytes.Length - 1;
}
/// <summary>Prepara un'istruzione nuova, non condivisa. Chi la chiede la libera.</summary>
public SqliteStatement Prepare(string sql)
{
var rc = SqliteNative.sqlite3_prepare_v2(_db, SqliteNative.Utf8Z(sql), -1, out var stmt, out _);
if (rc != SqliteNative.Ok || stmt == IntPtr.Zero)
throw new SqliteException(rc, $"{ErrorMessage()} — SQL: {sql}");
return new SqliteStatement(this, stmt, sql);
}
/// <summary>
/// Un'istruzione preparata e riusata: le stesse dieci frasi vengono eseguite
/// migliaia di volte, e prepararle ogni volta costerebbe più dell'esecuzione.
/// Viene azzerata prima di ogni uso.
/// </summary>
public SqliteStatement Cached(string sql)
{
if (!_cache.TryGetValue(sql, out var stmt))
{
stmt = Prepare(sql);
stmt.Shared = true;
_cache[sql] = stmt;
}
stmt.Reset();
return stmt;
}
public List<SqliteRow> Query(string sql, params object?[] args)
{
var stmt = Cached(sql);
stmt.Bind(args);
return stmt.ReadAll();
}
public void Dispose()
{
foreach (var s in _cache.Values) s.Shared = false;
foreach (var s in _cache.Values) s.Dispose();
_cache.Clear();
if (_db != IntPtr.Zero)
{
SqliteNative.sqlite3_close_v2(_db);
_db = IntPtr.Zero;
}
}
}
/// <summary>Un'istruzione preparata.</summary>
public sealed class SqliteStatement : IDisposable
{
private readonly SqliteConnection _conn;
private IntPtr _stmt;
private string[]? _columns;
public string Sql { get; }
/// <summary>Appartiene alla cache della connessione: Dispose non la libera.</summary>
internal bool Shared { get; set; }
internal SqliteStatement(SqliteConnection conn, IntPtr stmt, string sql)
{
_conn = conn;
_stmt = stmt;
Sql = sql;
}
public void Reset()
{
SqliteNative.sqlite3_reset(_stmt);
SqliteNative.sqlite3_clear_bindings(_stmt);
}
/// <summary>Lega i parametri posizionali (?1, ?2, …) nell'ordine dato.</summary>
public void Bind(object?[] args)
{
for (var i = 0; i < args.Length; i++)
{
var idx = i + 1;
var rc = args[i] switch
{
null => SqliteNative.sqlite3_bind_null(_stmt, idx),
string s => BindText(idx, s),
bool b => SqliteNative.sqlite3_bind_int64(_stmt, idx, b ? 1 : 0),
int n => SqliteNative.sqlite3_bind_int64(_stmt, idx, n),
long l => SqliteNative.sqlite3_bind_int64(_stmt, idx, l),
double d => double.IsNaN(d) || double.IsInfinity(d)
? SqliteNative.sqlite3_bind_null(_stmt, idx)
: SqliteNative.sqlite3_bind_double(_stmt, idx, d),
float f => SqliteNative.sqlite3_bind_double(_stmt, idx, f),
decimal m => SqliteNative.sqlite3_bind_double(_stmt, idx, (double)m),
DateTime dt => BindText(idx, AuctionDatabase.Iso(dt)),
DateTimeOffset dto => BindText(idx, dto.UtcDateTime.ToString("o")),
Enum e => BindText(idx, e.ToString()),
_ => BindText(idx, args[i]!.ToString() ?? "")
};
if (rc != SqliteNative.Ok)
throw new SqliteException(rc, $"{_conn.ErrorMessage()} — parametro {idx} di: {Sql}");
}
}
private int BindText(int idx, string s)
{
var bytes = Encoding.UTF8.GetBytes(s);
return SqliteNative.sqlite3_bind_text(_stmt, idx, bytes, bytes.Length, SqliteNative.Transient);
}
/// <summary>Avanza di una riga. False alla fine.</summary>
public bool Step()
{
var rc = SqliteNative.sqlite3_step(_stmt);
if (rc == SqliteNative.Row) return true;
if (rc == SqliteNative.Done) return false;
throw new SqliteException(rc, $"{_conn.ErrorMessage()} — SQL: {Sql}");
}
public void RunToEnd()
{
while (Step()) { }
}
public string[] Columns
{
get
{
if (_columns != null) return _columns;
var n = SqliteNative.sqlite3_column_count(_stmt);
_columns = new string[n];
for (var i = 0; i < n; i++)
_columns[i] = SqliteNative.Utf8(SqliteNative.sqlite3_column_name(_stmt, i)) ?? $"c{i}";
return _columns;
}
}
public object? Value(int col)
{
switch (SqliteNative.sqlite3_column_type(_stmt, col))
{
case SqliteNative.TypeInteger: return SqliteNative.sqlite3_column_int64(_stmt, col);
case SqliteNative.TypeFloat: return SqliteNative.sqlite3_column_double(_stmt, col);
case SqliteNative.TypeText:
var p = SqliteNative.sqlite3_column_text(_stmt, col);
var n = SqliteNative.sqlite3_column_bytes(_stmt, col);
return p == IntPtr.Zero ? "" : Marshal.PtrToStringUTF8(p, n);
default: return null;
}
}
public List<SqliteRow> ReadAll()
{
var rows = new List<SqliteRow>();
var columns = Columns;
var index = SqliteRow.IndexFor(columns);
while (Step())
{
var values = new object?[columns.Length];
for (var i = 0; i < values.Length; i++) values[i] = Value(i);
rows.Add(new SqliteRow(index, values));
}
return rows;
}
public void Dispose()
{
if (Shared) return;
if (_stmt != IntPtr.Zero)
{
SqliteNative.sqlite3_finalize(_stmt);
_stmt = IntPtr.Zero;
}
}
}
/// <summary>Una riga letta, con accesso per nome di colonna e conversioni tolleranti.</summary>
public sealed class SqliteRow
{
private readonly Dictionary<string, int> _index;
private readonly object?[] _values;
private static readonly Dictionary<string, Dictionary<string, int>> IndexCache = new(StringComparer.Ordinal);
internal static Dictionary<string, int> IndexFor(string[] columns)
{
var key = string.Join("|", columns);
lock (IndexCache)
{
if (!IndexCache.TryGetValue(key, out var idx))
{
idx = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < columns.Length; i++) idx[columns[i]] = i;
IndexCache[key] = idx;
}
return idx;
}
}
internal SqliteRow(Dictionary<string, int> index, object?[] values)
{
_index = index;
_values = values;
}
public object? this[string column] => _index.TryGetValue(column, out var i) ? _values[i] : null;
/// <summary>Valore per posizione: per i risultati scalari.</summary>
public object? At(int i) => i >= 0 && i < _values.Length ? _values[i] : null;
public bool Has(string column) => _index.ContainsKey(column);
public string Str(string column) => this[column]?.ToString() ?? "";
public string? StrOrNull(string column) => this[column] as string;
public long Long(string column) => this[column] switch
{
long l => l,
double d => (long)d,
string s when long.TryParse(s, out var v) => v,
_ => 0
};
public int Int(string column) => (int)Long(column);
public bool Bool(string column) => Long(column) != 0;
public double Dbl(string column) => this[column] switch
{
double d => d,
long l => l,
string s when double.TryParse(s, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
_ => 0
};
public double? DblOrNull(string column) => this[column] == null ? null : Dbl(column);
public int? IntOrNull(string column) => this[column] == null ? null : Int(column);
public long? LongOrNull(string column) => this[column] == null ? null : Long(column);
/// <summary>Data in UTC, o null. Le date sono scritte in ISO 8601 UTC.</summary>
public DateTime? Date(string column)
{
var s = this[column] as string;
if (string.IsNullOrEmpty(s)) return null;
if (!DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind, out var d))
return null;
return d.Kind switch
{
DateTimeKind.Utc => d,
DateTimeKind.Local => d.ToUniversalTime(),
_ => DateTime.SpecifyKind(d, DateTimeKind.Utc) // scritto senza fuso: era UTC
};
}
}
}
+408
View File
@@ -0,0 +1,408 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading;
namespace AutoBidder.Data
{
/// <summary>
/// Il motore comune dei due database dell'applicazione: un file SQLite, una coda di
/// scrittura svuotata a blocchi da un thread dedicato, una connessione di lettura a
/// parte con giornale WAL.
///
/// <para><b>Come scrive.</b> Mai sul thread che chiama. Ogni scrittura finisce in coda
/// e il thread del database la esegue in una transazione ogni qualche centinaio di
/// righe: SQLite fa migliaia di inserimenti al secondo così, e nessuno sul percorso
/// della puntata aspetta il disco. Chi deve leggere ciò che ha appena scritto chiama
/// <see cref="Flush"/>.</para>
///
/// <para><b>Come legge.</b> Su una seconda connessione, sotto un lucchetto. Con il
/// giornale WAL le letture non bloccano la scrittura e viceversa.</para>
/// </summary>
public abstract class SqliteDatabase : IDisposable
{
/// <summary>Righe di registro (errori di scrittura, migrazioni): chi si aggancia le scrive dove vuole.</summary>
public static event Action<string>? OnLog;
protected static void Log(string message) => OnLog?.Invoke(message);
private readonly SqliteConnection _writer;
private readonly SqliteConnection _reader;
private readonly object _readSync = new();
private abstract class Pending { public long Seq; }
private sealed class Statement : Pending
{
public string Sql = "";
public object?[] Args = Array.Empty<object?>();
}
private sealed class Batch : Pending
{
public List<(string Sql, object?[] Args)> Ops = new();
}
private sealed class Custom : Pending
{
public Action<SqliteConnection> Action = _ => { };
public Exception? Error;
public ManualResetEventSlim Done = new(false);
}
private readonly Queue<Pending> _queue = new();
private readonly object _queueSync = new();
private long _lastEnqueued;
private long _lastCommitted;
private readonly Thread _writerThread;
private volatile bool _disposed;
private volatile bool _stop;
private long _writesCommitted;
private long _writesFailed;
public long WritesCommitted => Interlocked.Read(ref _writesCommitted);
public long WritesFailed => Interlocked.Read(ref _writesFailed);
public int QueueLength { get { lock (_queueSync) return _queue.Count; } }
public bool IsDisposed => _disposed;
public string Path { get; }
public static string LibraryVersion => SqliteConnection.LibraryVersion;
protected SqliteDatabase(string path, string schemaScript, int schemaVersion, string threadName)
{
Path = path;
var folder = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
_writer = new SqliteConnection(path);
Configure(_writer);
Migrate(_writer, schemaScript, schemaVersion);
OnOpened(_writer);
_reader = new SqliteConnection(path);
Configure(_reader);
_writerThread = new Thread(WriterLoop)
{
Name = threadName,
IsBackground = true,
Priority = ThreadPriority.BelowNormal
};
_writerThread.Start();
}
/// <summary>Chiamato con la connessione di scrittura, dopo lo schema, prima del thread.</summary>
protected virtual void OnOpened(SqliteConnection writer) { }
/// <summary>Chiamato dal thread di scrittura per ogni istruzione eseguita.</summary>
protected virtual void OnStatementExecuted(string sql) { }
private static void Configure(SqliteConnection c)
{
c.Execute("PRAGMA journal_mode=WAL");
c.Execute("PRAGMA synchronous=NORMAL");
c.Execute("PRAGMA foreign_keys=ON");
c.Execute("PRAGMA temp_store=MEMORY");
}
private void Migrate(SqliteConnection c, string schema, int target)
{
var version = (int)c.Query("PRAGMA user_version")[0].Long("user_version");
if (version >= target) return;
c.ExecuteScript(schema);
c.Execute($"PRAGMA user_version={target}");
Log($"[DATABASE] Schema versione {target} in {c.Path} — SQLite {LibraryVersion}");
}
// ── Scrittura ────────────────────────────────────────────────────
public void Enqueue(string sql, params object?[] args)
{
if (_disposed) return;
Push(new Statement { Sql = sql, Args = args });
}
public void EnqueueBatch(IEnumerable<(string Sql, object?[] Args)> ops)
{
if (_disposed) return;
var batch = new Batch();
batch.Ops.AddRange(ops);
if (batch.Ops.Count == 0) return;
Push(batch);
}
public void RunOnWriter(Action<SqliteConnection> action, int timeoutMs = 60000)
{
if (_disposed) throw new ObjectDisposedException(GetType().Name);
var custom = new Custom { Action = action };
Push(custom);
if (!custom.Done.Wait(timeoutMs)) throw new TimeoutException("il database non ha risposto in tempo");
if (custom.Error != null) throw custom.Error;
}
public void ExecuteNow(string sql, params object?[] args)
{
Enqueue(sql, args);
Flush();
}
private void Push(Pending p)
{
lock (_queueSync)
{
p.Seq = ++_lastEnqueued;
_queue.Enqueue(p);
Monitor.PulseAll(_queueSync);
}
}
public bool Flush(int timeoutMs = 15000)
{
long target;
lock (_queueSync) target = _lastEnqueued;
var deadline = Environment.TickCount64 + timeoutMs;
lock (_queueSync)
{
while (Interlocked.Read(ref _lastCommitted) < target)
{
var left = deadline - Environment.TickCount64;
if (left <= 0 || _disposed) return Interlocked.Read(ref _lastCommitted) >= target;
Monitor.Wait(_queueSync, (int)Math.Min(left, 500));
}
}
return true;
}
private void WriterLoop()
{
var buffer = new List<Pending>(512);
while (!_stop)
{
buffer.Clear();
lock (_queueSync)
{
while (_queue.Count == 0 && !_stop) Monitor.Wait(_queueSync, 250);
while (_queue.Count > 0 && buffer.Count < 2000) buffer.Add(_queue.Dequeue());
}
if (buffer.Count == 0) continue;
var i = 0;
while (i < buffer.Count)
{
switch (buffer[i])
{
case Statement:
var j = i;
while (j < buffer.Count && buffer[j] is Statement) j++;
RunStatements(buffer, i, j);
i = j;
break;
case Batch b:
RunBatch(b);
i++;
break;
case Custom c:
try { c.Action(_writer); }
catch (Exception ex) { c.Error = ex; }
finally { MarkCommitted(c.Seq); c.Done.Set(); }
i++;
break;
default:
i++;
break;
}
}
}
}
private void RunStatements(List<Pending> buffer, int from, int to)
{
long lastSeq = 0;
var inTx = false;
try
{
_writer.Execute("BEGIN IMMEDIATE");
inTx = true;
for (var k = from; k < to; k++)
{
var s = (Statement)buffer[k];
lastSeq = s.Seq;
try
{
var stmt = _writer.Cached(s.Sql);
stmt.Bind(s.Args);
stmt.RunToEnd();
Interlocked.Increment(ref _writesCommitted);
OnStatementExecuted(s.Sql);
}
catch (SqliteException ex)
{
Interlocked.Increment(ref _writesFailed);
Log($"[DATABASE] Scrittura rifiutata: {ex.Message}");
}
}
_writer.Execute("COMMIT");
inTx = false;
}
catch (Exception ex)
{
Log($"[DATABASE] Transazione non riuscita: {ex.Message}");
if (inTx) { try { _writer.Execute("ROLLBACK"); } catch { } }
Interlocked.Add(ref _writesFailed, to - from);
}
finally
{
MarkCommitted(Math.Max(lastSeq, buffer[to - 1].Seq));
}
}
private void RunBatch(Batch b)
{
var inTx = false;
try
{
_writer.Execute("BEGIN IMMEDIATE");
inTx = true;
foreach (var (sql, args) in b.Ops)
{
var stmt = _writer.Cached(sql);
stmt.Bind(args);
stmt.RunToEnd();
OnStatementExecuted(sql);
}
_writer.Execute("COMMIT");
inTx = false;
Interlocked.Add(ref _writesCommitted, b.Ops.Count);
}
catch (Exception ex)
{
Log($"[DATABASE] Blocco annullato: {ex.Message}");
if (inTx) { try { _writer.Execute("ROLLBACK"); } catch { } }
Interlocked.Add(ref _writesFailed, b.Ops.Count);
}
finally
{
MarkCommitted(b.Seq);
}
}
private void MarkCommitted(long seq)
{
lock (_queueSync)
{
if (seq > _lastCommitted) Interlocked.Exchange(ref _lastCommitted, seq);
Monitor.PulseAll(_queueSync);
}
}
// ── Lettura ──────────────────────────────────────────────────────
public List<SqliteRow> Query(string sql, params object?[] args)
{
if (_disposed) return new List<SqliteRow>();
lock (_readSync) return _reader.Query(sql, args);
}
public SqliteRow? QueryOne(string sql, params object?[] args)
{
var rows = Query(sql, args);
return rows.Count > 0 ? rows[0] : null;
}
public long ScalarLong(string sql, params object?[] args)
{
var v = QueryOne(sql, args)?.At(0);
return v switch { long l => l, double d => (long)d, string s when long.TryParse(s, out var x) => x, _ => 0 };
}
public double ScalarDouble(string sql, params object?[] args)
{
var v = QueryOne(sql, args)?.At(0);
return v switch { long l => l, double d => d, string s when double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) => x, _ => 0 };
}
public string? ScalarString(string sql, params object?[] args) => QueryOne(sql, args)?.At(0)?.ToString();
public long Count(string table) => ScalarLong($"SELECT COUNT(*) AS n FROM {table}");
// ── Manutenzione ─────────────────────────────────────────────────
/// <summary>Copia coerente dell'intero archivio in un altro file (VACUUM INTO).</summary>
public void BackupTo(string destination)
{
Flush();
if (File.Exists(destination)) File.Delete(destination);
var folder = System.IO.Path.GetDirectoryName(destination);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
RunOnWriter(c => c.Execute("VACUUM INTO ?1", destination));
}
/// <summary>Byte occupati sul disco (file principale più giornale WAL).</summary>
public long SizeBytes
{
get
{
long total = 0;
foreach (var f in new[] { Path, Path + "-wal" })
{
try { if (File.Exists(f)) total += new FileInfo(f).Length; } catch { }
}
return total;
}
}
/// <summary>Compatta il file. Fuori da ogni transazione, sul thread del database.</summary>
public void Vacuum()
{
try { RunOnWriter(c => c.Execute("VACUUM")); } catch { /* lo spazio si recupera la prossima volta */ }
}
// ── Attrezzi ─────────────────────────────────────────────────────
/// <summary>Le date si scrivono in ISO 8601 UTC: ordinano bene e non dipendono dal fuso.</summary>
public static string Iso(DateTime dt)
{
var utc = dt.Kind switch
{
DateTimeKind.Utc => dt,
DateTimeKind.Local => dt.ToUniversalTime(),
_ => DateTime.SpecifyKind(dt, DateTimeKind.Local).ToUniversalTime()
};
return utc.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
}
public static string Now() => Iso(DateTime.UtcNow);
public void Dispose()
{
if (_disposed) return;
Flush(5000);
_stop = true;
lock (_queueSync) Monitor.PulseAll(_queueSync);
try { _writerThread.Join(3000); } catch { }
_disposed = true;
lock (_readSync) _reader.Dispose();
_writer.Dispose();
}
}
}
+54
View File
@@ -46,6 +46,60 @@
ToolTip="Senza il «Compra Subito» non si può calcolare il risparmio, ma il prezzo e le puntate restano validi."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkObservedOnly" Margin="0,4"
Content="Aste solo osservate (senza mie puntate)"
ToolTip="Tiene le aste su cui hai puntato — sono le uniche con il costo reale dentro — e toglie tutte le altre. Utile per uno storico compatto delle tue partite."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkWithMyBids" Margin="0,4"
Content="Aste su cui ho puntato"
ToolTip="Il verso opposto: tiene solo il mercato. Un'asta in cui sei intervenuto ha un prezzo finale che hai contribuito a fare, e per stimare cosa succede senza di te va tolta."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox x:Name="ChkPriceRatio" VerticalAlignment="Center"
Content="Aste chiuse oltre il"
ToolTip="Una chiusura al 60% del valore su un buono da 100 € non descrive il mercato: descrive due persone che si sono incaponite. Tenerla sposta le medie e i limiti consigliati verso l'alto per tutti.&#10;&#10;Sul tuo storico la chiusura mediana sta fra il 5% e il 6% del valore, e tre aste su quattro chiudono sotto il 15%."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<TextBox x:Name="PriceRatioBox" Text="40" Width="50" Margin="8,0"
VerticalAlignment="Center" TextChanged="Option_Changed"/>
<TextBlock Text="% del valore del prodotto" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
<!-- Per prodotto: i prodotti presenti nello storico, col numero di aste -->
<Expander x:Name="ProductsExpander" Margin="0,10,0,0" IsExpanded="False"
Foreground="{DynamicResource Brush.Text}">
<Expander.Header>
<TextBlock x:Name="ProductsHeader" Text="Per prodotto: nessuno scelto"
Foreground="{DynamicResource Brush.Text}"/>
</Expander.Header>
<ListBox x:Name="ProductsList" MaxHeight="170" Margin="0,6,0,0"
Background="{DynamicResource Brush.SurfaceAlt}"
BorderBrush="{DynamicResource Brush.Border}"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<ListBox.ItemTemplate>
<DataTemplate>
<CheckBox Content="{Binding Label}"
IsChecked="{Binding Selected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Foreground="{DynamicResource Brush.Text}"
Checked="Option_Changed" Unchecked="Option_Changed"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Expander>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox x:Name="ChkCoverage" VerticalAlignment="Center"
Content="Aste seguite per meno del"
ToolTip="Toglie le aste agganciate a metà. Sono il caso peggiore: i numeri sono veri ma parziali, quindi sembrano buone e abbassano le medie senza dare segnali.&#10;&#10;Quanta asta si è vista si ricava dal prezzo finale, senza bisogno di alcun dato in più: ogni puntata vale un centesimo, quindi il prezzo in centesimi è il totale delle puntate dell'asta, e i reset osservati dicono quanti se ne sono visti.&#10;&#10;Sul tuo storico, su 5876 aste: al 75% ne resterebbero circa 78 su 100, al 90% circa 64, al 95% circa 54, al 99% circa 34."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<TextBox x:Name="CoverageBox" Text="90" Width="50" Margin="8,0"
VerticalAlignment="Center" TextChanged="Option_Changed"/>
<TextBlock Text="% della loro durata" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox x:Name="ChkOlderThan" VerticalAlignment="Center"
Content="Aste chiuse da più di"
@@ -20,10 +20,36 @@ namespace AutoBidder.Dialogs
{
private readonly List<CompletedAuctionRecord> _records;
/// <summary>Una riga dell'elenco prodotti: chiave, etichetta con il conteggio, spunta.</summary>
private sealed class ProductRow
{
public string Key { get; init; } = "";
public string Label { get; init; } = "";
public bool Selected { get; set; }
}
private readonly List<ProductRow> _products;
public StatsCleanupDialog(List<CompletedAuctionRecord> records)
{
InitializeComponent();
_records = records ?? new List<CompletedAuctionRecord>();
// I prodotti presenti nello storico, dal più numeroso: è l'ordine in cui
// uno li cerca quando vuole fare spazio.
_products = _records
.Where(r => !string.IsNullOrWhiteSpace(r.ProductKey))
.GroupBy(r => r.ProductKey, StringComparer.OrdinalIgnoreCase)
.Select(g => new ProductRow
{
Key = g.Key,
Label = $"{g.Select(r => r.Name).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)) ?? g.Key} ({g.Count()})"
})
.OrderByDescending(p => p.Label.Length > 0 ? _records.Count(r => string.Equals(r.ProductKey, p.Key, StringComparison.OrdinalIgnoreCase)) : 0)
.ToList();
ProductsList.ItemsSource = _products;
Loaded += (_, _) => Refresh();
}
@@ -45,12 +71,39 @@ namespace AutoBidder.Dialogs
RemoveWithoutValue = ChkNoValue.IsChecked == true
};
// Una soglia illeggibile o fuori scala non deve diventare "togli tutto":
// il filtro resta spento finché il numero non ha senso.
if (ChkCoverage.IsChecked == true &&
double.TryParse(CoverageBox.Text.Trim().Replace(',', '.'),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var percento) &&
percento is > 0 and <= 100)
{
options.MinObservedCoverage = percento / 100.0;
}
if (ChkOlderThan.IsChecked == true &&
int.TryParse(DaysBox.Text.Trim(), out var days) && days > 0)
{
options.OlderThan = DateTime.Now.AddDays(-days);
}
options.RemoveObservedOnly = ChkObservedOnly.IsChecked == true;
options.RemoveWithMyBids = ChkWithMyBids.IsChecked == true;
if (ChkPriceRatio.IsChecked == true &&
double.TryParse(PriceRatioBox.Text.Trim().Replace(',', '.'),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var ratio) &&
ratio > 0)
{
options.MaxPriceRatioPercent = ratio;
}
var scelti = _products.Where(p => p.Selected).Select(p => p.Key).ToList();
if (scelti.Count > 0)
options.RemoveProductKeys = new HashSet<string>(scelti, StringComparer.OrdinalIgnoreCase);
return options;
}
@@ -60,6 +113,14 @@ namespace AutoBidder.Dialogs
{
if (SummaryText == null) return;
if (ProductsHeader != null)
{
var n = _products.Count(p => p.Selected);
ProductsHeader.Text = n == 0
? $"Per prodotto: nessuno scelto ({_products.Count} nello storico)"
: $"Per prodotto: {n} scelti";
}
var report = StatsMaintenance.Preview(_records, ReadOptions());
SummaryText.Text = report.Removed == 0
+64
View File
@@ -0,0 +1,64 @@
<Window x:Class="AutoBidder.Dialogs.WipeStatsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Azzera le statistiche"
Width="600" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
Background="{DynamicResource Brush.Bg}">
<StackPanel Margin="22,18">
<TextBlock Text="Cosa azzerare"
FontSize="{StaticResource Font.Size.Lg}" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
<TextBlock Style="{StaticResource Hint}" Margin="0,6,0,14"
Text="Accanto a ogni voce c'è quanto spazio occupa adesso. Niente viene toccato finché non premi Azzera. Il database viene copiato per intero nella cartella dei backup prima di essere svuotato; il resto no."/>
<CheckBox x:Name="ChkHistory" Margin="0,4" IsChecked="True"
Foreground="{DynamicResource Brush.Text}"
ToolTip="Tutto ciò che il database ha osservato e deciso: aste, puntate di ogni utente, interrogazioni, reset, tue puntate, decisioni del motore, misure di rete, puntatori, contabilità del rischio. I prodotti con il loro valore reale restano. Prima viene fatta una copia completa del database nei backup."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkProducts" Margin="0,4" IsChecked="True"
Foreground="{DynamicResource Brush.Text}"
ToolTip="I totali per prodotto (aste viste, prezzi medi, puntate del vincitore). Le opzioni scelte nella scheda Prodotti restano: si azzerano i numeri, non le scelte."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkLegacy" Margin="0,4" IsChecked="False"
Foreground="{DynamicResource Brush.Text}"
ToolTip="La vecchia cartella Dati delle versioni precedenti: i file JSON già importati nel database, le statistiche, i registri di testo e i dossier delle aste (possono essere gigabyte). L'applicazione non li legge più."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkLearning" Margin="0,4" IsChecked="False"
Foreground="{DynamicResource Brush.Text}"
ToolTip="Il modello appreso, il profilo per prodotto e ora, lo sfidante, il bandit, il modello di latenza e il contatore delle aste già studiate. Non si perde niente di irrecuperabile: al prossimo avvio l'applicazione ristudia da capo tutte le aste chiuse del database (qualche minuto in sottofondo)."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<Border Style="{StaticResource InfoBox}" Margin="0,14,0,0">
<StackPanel>
<TextBlock x:Name="SummaryText" TextWrapping="Wrap"
Foreground="{DynamicResource Brush.Text}"
FontSize="{StaticResource Font.Size.Sm}" LineHeight="18"
Text="Calcolo…"/>
<TextBlock TextWrapping="Wrap" Margin="0,8,0,0"
Foreground="{DynamicResource Brush.TextMuted}"
FontSize="{StaticResource Font.Size.Sm}" LineHeight="17"
Text="Per cancellare tutto — database, copie di sicurezza, cartella vecchia — c'è «Elimina tutti i dati» nelle Impostazioni."/>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<Button x:Name="WipeButton" Content="Azzera" Padding="26,7" Margin="0,0,10,0"
Style="{StaticResource ModernButton}"
Background="{DynamicResource Brush.Danger}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Click="Wipe_Click"/>
<Button Content="Annulla" Padding="20,7" IsCancel="True"
Style="{StaticResource ModernButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
</StackPanel>
</Window>
+93
View File
@@ -0,0 +1,93 @@
using System;
using System.Windows;
using AutoBidder.Utilities;
namespace AutoBidder.Dialogs
{
/// <summary>
/// Sceglie quali statistiche azzerare e le azzera.
///
/// <para>Accanto a ogni voce c'è quanto pesa adesso: cancellare è irreversibile, e
/// l'unico modo di renderlo una decisione informata è dire cosa sparisce e quanto è.
/// Lo storico delle aste concluse è l'unica voce con copia di sicurezza automatica:
/// è il solo archivio che non si ricostruisce da nient'altro.</para>
/// </summary>
public partial class WipeStatsDialog : Window
{
private StatsWipe.Sizes _sizes;
public WipeStatsDialog()
{
InitializeComponent();
Loaded += (_, _) => { _sizes = StatsWipe.Measure(); Refresh(); };
}
/// <summary>Il rapporto dell'azzeramento, dopo la conferma. Null se annullato.</summary>
public StatsWipe.Report? Result { get; private set; }
private StatsWipe.Options ReadOptions() => new()
{
History = ChkHistory.IsChecked == true,
ProductStats = ChkProducts.IsChecked == true,
LegacyArchives = ChkLegacy.IsChecked == true,
Learning = ChkLearning.IsChecked == true
};
private void Option_Changed(object sender, RoutedEventArgs e) => Refresh();
private void Refresh()
{
if (!IsLoaded) return;
ChkHistory.Content = $"Osservazioni nel database: aste, puntate, interrogazioni, decisioni ({_sizes.HistoryAuctions} aste, {Fmt(_sizes.HistoryBytes)})";
ChkProducts.Content = $"Statistiche per prodotto ({Fmt(_sizes.ProductsBytes)})";
ChkLegacy.Content = $"Vecchia cartella Dati delle versioni precedenti ({_sizes.LegacyFiles} file, {Fmt(_sizes.LegacyBytes)})";
ChkLearning.Content = $"Apprendimento: modello, profilo, latenza ({Fmt(_sizes.LearningBytes)})";
var o = ReadOptions();
long totale = 0;
if (o.History) totale += _sizes.HistoryBytes;
if (o.ProductStats) totale += _sizes.ProductsBytes;
if (o.LegacyArchives) totale += _sizes.LegacyBytes;
if (o.Learning) totale += _sizes.LearningBytes;
SummaryText.Text = o.Nothing
? "Nessuna voce scelta."
: $"Si liberano circa {Fmt(totale)}." +
(o.History ? " Lo storico viene prima copiato nei backup." : "") +
(o.Learning ? " L'apprendimento ripartirà da zero e ristudierà le aste al prossimo avvio." : "");
WipeButton.IsEnabled = !o.Nothing;
}
private void Wipe_Click(object sender, RoutedEventArgs e)
{
var o = ReadOptions();
if (o.Nothing) return;
var answer = MessageBox.Show(this,
"Le voci scelte vengono cancellate. L'operazione non è reversibile" +
(o.History ? ", salvo la copia dello storico nei backup" : "") + ".\n\nProcedo?",
"Azzera le statistiche", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
try
{
Result = StatsWipe.Run(o);
DialogResult = true;
}
catch (Exception ex)
{
MessageBox.Show(this, $"Azzeramento non riuscito: {ex.Message}",
"Azzera le statistiche", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private static string Fmt(long bytes) =>
bytes >= 1L << 30 ? $"{bytes / (double)(1L << 30):F2} GB"
: bytes >= 1L << 20 ? $"{bytes / (double)(1L << 20):F1} MB"
: bytes >= 1L << 10 ? $"{bytes / (double)(1L << 10):F0} KB"
: $"{bytes} B";
}
}
+57 -20
View File
@@ -29,9 +29,10 @@ namespace AutoBidder.Engine
/// <summary>
/// Verdetto delle strategie sulla puntata. Il runner decide <i>quando</i>,
/// questo metodo decide <i>se</i>.
/// questo metodo decide <i>se</i>. In shadow (asta in Osserva) la decisione si
/// registra ma la risposta è sempre «no»: si osserva, non si punta.
/// </summary>
bool EvaluateBid(AuctionInfo auction, AuctionState state);
bool EvaluateBid(AuctionInfo auction, AuctionState state, bool shadow = false);
/// <summary>Invia la puntata e registra l'esito.</summary>
Task FireBidAsync(AuctionInfo auction, AuctionState state, int leadMs, double actualLeadMs, CancellationToken ct);
@@ -164,22 +165,28 @@ namespace AutoBidder.Engine
private int NextPollDelay(AppSettings settings)
{
// Un'asta non ancora cominciata ha una cadenza tutta sua: vedi NotStartedDelay.
// È l'unico caso rimasto in cui si aspetta, e non è una scelta di ritmo: fino
// all'apertura il server non ha niente di diverso da dire, e interrogarlo
// seicento volte al minuto per tre ore non anticipa l'inizio di un istante.
var notStarted = NotStartedDelay(settings);
if (notStarted > 0) return notStarted;
var remaining = _auction.EstimatedRemainingMs();
if (remaining == double.MaxValue) return settings.PollIntervalFarMs;
// In sola osservazione non c'è puntata da azzeccare: si risparmiano chiamate.
var critical = _auction.State == RunState.Active
? settings.PollIntervalCriticalMs
: settings.PollIntervalNearMs;
if (remaining <= settings.CriticalWindowMs) return critical;
if (remaining <= 10_000) return settings.PollIntervalNearMs;
if (remaining <= 60_000) return settings.PollIntervalMidMs;
return settings.PollIntervalFarMs;
// ── Nessuna attesa: la cadenza la detta la rete ──────────────────────
//
// Prima qui c'erano quattro soglie configurabili (lontano / medio / vicino /
// critico). Sui registri di tre giorni non hanno mai funzionato come scritto:
// con 40 richieste al secondo di tetto globale spartite fra tutte le aste
// seguite, i 220 ms della finestra critica diventavano 596 ms misurati, e con
// il timer a un secondo restava un solo sguardo prima della scadenza.
//
// Il ritmo adesso è quello del giro di andata e ritorno: si riparte appena la
// risposta è arrivata. Con ping ~57 ms misurato sono circa diciassette
// interrogazioni al secondo per asta.
//
// Il freno non è più un numero scelto a tavolino ma la risposta del server:
// vedi la reazione a 429 e 503 in PollLoopAsync. Se Bidoo dice di rallentare,
// si rallenta; finché non lo dice, si va al massimo che regge la rete.
return 0;
}
/// <summary>
@@ -222,6 +229,34 @@ namespace AutoBidder.Engine
return (int)Math.Clamp(Math.Min(desired, untilWake), 1000, 1_800_000);
}
/// <summary>
/// L'anticipo con cui parte la puntata su quest'asta.
///
/// <para>Un anticipo scritto a mano sull'asta vince sempre. Altrimenti decide il
/// modello di latenza — margine più coda del ping di questa sessione, dentro i
/// paletti delle impostazioni — e il valore scelto viene scritto sull'asta, così
/// la griglia e il dossier mostrano l'anticipo davvero in uso.</para>
/// </summary>
private int EffectiveLeadMs(AppSettings settings)
{
if (_auction.BidLeadIsManual && _auction.BidBeforeDeadlineMs > 0)
return _auction.BidBeforeDeadlineMs;
// Con l'apprendimento spento vale l'anticipo fisso: è l'interruttore in barra.
var lead = settings.AdaptiveLeadEnabled && settings.LearningEnabled
? Ml.LatencyModel.RecommendedLeadMs(settings)
: settings.DefaultBidBeforeDeadlineMs;
if (lead != _auction.BidBeforeDeadlineMs)
{
_auction.AddLog($"Anticipo adattivo: {lead} ms (era {_auction.BidBeforeDeadlineMs} ms)",
AuctionLogLevel.Debug, AuctionLogCategory.Ticker);
_auction.BidBeforeDeadlineMs = lead;
}
return lead;
}
// ── Anello del cecchino ──────────────────────────────────────────────────
private async Task SniperLoopAsync(CancellationToken ct)
@@ -230,11 +265,15 @@ namespace AutoBidder.Engine
{
try
{
if (_auction.State != RunState.Active || _host.IsFinished(_auction))
// In Osserva il cecchino arriva fino all'istante di fuoco come in Attiva,
// ma invece di sparare registra cosa avrebbe fatto: è la modalità shadow,
// ed è così che una policy si giudica prima di darle soldi veri.
if (_auction.State == RunState.Stopped || _host.IsFinished(_auction))
{
if (!await Wait.DelayAsync(150, ct).ConfigureAwait(false)) return;
continue;
}
var shadow = _auction.State == RunState.Watch;
var state = _auction.LastState;
var deadline = _auction.DeadlineTicks;
@@ -259,9 +298,7 @@ namespace AutoBidder.Engine
}
var settings = _host.Settings;
var leadMs = _auction.BidBeforeDeadlineMs > 0
? _auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
var leadMs = EffectiveLeadMs(settings);
var fireTicks = deadline - PrecisionWait.MsToTicks(leadMs);
var msToDeadline = PrecisionWait.MsUntil(deadline);
@@ -289,7 +326,7 @@ namespace AutoBidder.Engine
continue;
}
if (!_host.EvaluateBid(_auction, state))
if (!_host.EvaluateBid(_auction, state, shadow))
{
_handledCycle = cycle;
continue;
+35 -22
View File
@@ -1,16 +1,16 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder.Engine.Backtest
{
/// <summary>
/// Rigioca una cartella di dossier e riassume cosa avrebbe fatto il motore, con un
/// anticipo alla volta.
/// Rigioca le aste registrate nel database e riassume cosa avrebbe fatto il motore,
/// con un anticipo alla volta.
///
/// <para>Serve a due cose distinte. La prima è la <b>taratura</b>: si vede subito
/// quante puntate costerebbe ciascun anticipo, e dove sta il gradino oltre il quale
@@ -35,7 +35,6 @@ namespace AutoBidder.Engine.Backtest
public Dictionary<string, int> Blocks { get; init; } = new(StringComparer.Ordinal);
/// <summary>Puntate per asta: mediana e coda.</summary>
public double MedianBidsPerAuction { get; init; }
public int P90BidsPerAuction { get; init; }
public int MaxBidsPerAuction { get; init; }
@@ -49,31 +48,45 @@ namespace AutoBidder.Engine.Backtest
}
/// <summary>
/// Rigioca ogni dossier della cartella con ciascun anticipo indicato.
/// <paramref name="onProgress"/> riceve (fatti, totale).
/// Rigioca ogni asta chiusa del database con ciascun anticipo indicato.
/// <paramref name="onProgress"/> riceve (fatte, totale).
/// </summary>
public static List<Aggregate> Run(
string dossierFolder,
AuctionDatabase db,
IReadOnlyList<int> leads,
AppSettings settings,
string username = "",
int maxFiles = 0,
int maxAuctions = 0,
Action<int, int>? onProgress = null)
{
var files = Directory.Exists(dossierFolder)
? Directory.GetFiles(dossierFolder, "*.jsonl").OrderBy(f => f).ToArray()
: Array.Empty<string>();
db.Flush();
var ids = db.Query("SELECT auction_id FROM auctions WHERE outcome IS NOT NULL ORDER BY end_time")
.Select(r => r.Str("auction_id")).ToList();
if (maxAuctions > 0 && ids.Count > maxAuctions) ids = ids.Skip(ids.Count - maxAuctions).ToList();
if (maxFiles > 0 && files.Length > maxFiles) files = files.Take(maxFiles).ToArray();
var sessions = new List<DossierReader.Session>();
for (var i = 0; i < ids.Count; i++)
{
var s = DossierReader.FromDatabase(db, ids[i]);
if (s != null && s.IsUsable) sessions.Add(s);
}
return Run(sessions, leads, settings, username, onProgress);
}
/// <summary>Rigioca sessioni già in memoria (anche vecchi dossier letti da file).</summary>
public static List<Aggregate> Run(
IReadOnlyList<DossierReader.Session> sessions,
IReadOnlyList<int> leads,
AppSettings settings,
string username = "",
Action<int, int>? onProgress = null)
{
var perLead = leads.ToDictionary(l => l, _ => new List<BacktestRunner.Result>());
for (var i = 0; i < files.Length; i++)
for (var i = 0; i < sessions.Count; i++)
{
DossierReader.Session session;
try { session = DossierReader.ReadFile(files[i]); }
catch (IOException) { continue; }
var session = sessions[i];
if (!session.IsUsable) continue;
var cost = session.Header?.BidCostEuro ?? 0.20;
@@ -84,7 +97,7 @@ namespace AutoBidder.Engine.Backtest
perLead[lead].Add(BacktestRunner.Run(session, options));
}
onProgress?.Invoke(i + 1, files.Length);
onProgress?.Invoke(i + 1, sessions.Count);
}
return leads.Select(lead => Summarize(lead, perLead[lead])).ToList();
@@ -120,7 +133,7 @@ namespace AutoBidder.Engine.Backtest
var sb = new StringBuilder();
var c = CultureInfo.GetCultureInfo("it-IT");
sb.AppendLine("RIGIOCATA SUI DOSSIER DELLE ASTE CONCLUSE");
sb.AppendLine("RIGIOCATA SULLE ASTE CONCLUSE");
sb.AppendLine();
sb.AppendLine(" Il motore punta solo nei cicli che arrivano fino all'anticipo senza che");
sb.AppendLine(" nessun altro abbia puntato. Piu' l'anticipo e' largo, piu' cicli lo");
@@ -160,9 +173,9 @@ namespace AutoBidder.Engine.Backtest
}
sb.AppendLine();
sb.AppendLine(" I blocchi 'pareggio', 'tetto-spesa' e 'tetto-puntate' sono il");
sb.AppendLine(" comportamento voluto: sono le puntate che avrebbero fatto perdere");
sb.AppendLine(" soldi. Gli altri motivi sono strategie difensive, e vanno guardati.");
sb.AppendLine(" I blocchi 'pareggio', 'tetto-spesa' e 'tetto-puntate' sono i paletti");
sb.AppendLine(" dell'utente; 'duello', 'valore-atteso', 'sfogo' e 'sondaggio' sono cio'");
sb.AppendLine(" che il sistema ha imparato o misurato sul momento.");
sb.AppendLine();
sb.AppendLine(" NOTA: la rigiocata misura i costi, non l'esito. Una nostra puntata rimette");
sb.AppendLine(" in gioco l'asta, e come avrebbero reagito gli avversari non e' registrato");
+39 -10
View File
@@ -31,7 +31,37 @@ namespace AutoBidder.Engine.Backtest
int LeadMs,
AppSettings Settings,
string Username = "",
double BidCostEuro = 0.20);
double BidCostEuro = 0.20)
{
/// <summary>
/// Le impostazioni come le vede la rigiocata.
///
/// <para>Spegne la sospensione a fascia oraria, che guarda l'orologio di parete:
/// una rigiocata ripercorre aste già concluse, e l'ora in cui viene lanciata non
/// c'entra nulla con l'ora in cui quelle aste correvano. Lasciarla accesa
/// renderebbe il risultato dipendente da quando si preme il pulsante — di notte
/// il motore rifiuterebbe ogni puntata e la rigiocata direbbe zero, che è
/// esattamente il tipo di risultato che sembra vero e non lo è.</para>
///
/// <para>Vale anche per la suite di test, che gira a qualunque ora.</para>
/// </summary>
public AppSettings EffectiveSettings { get; } = Clona(Settings);
private static AppSettings Clona(AppSettings origine)
{
var copia = origine ?? new AppSettings();
// Copia superficiale via serializzazione: le impostazioni sono un sacco di
// proprietà semplici, e mutare quella dell'utente sarebbe un effetto
// collaterale che nessuno si aspetta da una rigiocata.
var json = System.Text.Json.JsonSerializer.Serialize(copia);
var clone = System.Text.Json.JsonSerializer.Deserialize<AppSettings>(json)
?? new AppSettings();
clone.QuietHoursEnabled = false;
return clone;
}
}
/// <summary>Esito su una singola asta.</summary>
public sealed class Result
@@ -66,12 +96,11 @@ namespace AutoBidder.Engine.Backtest
{
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("autopuntata avversaria", StringComparison.OrdinalIgnoreCase)) return "duello";
if (reason.StartsWith("puntata saltata: fascia", StringComparison.OrdinalIgnoreCase)) return "fascia-oraria";
if (reason.StartsWith("valore atteso", StringComparison.OrdinalIgnoreCase)) return "valore-atteso";
if (reason.StartsWith("lascio sfogare", StringComparison.OrdinalIgnoreCase)) return "sfogo";
if (reason.StartsWith("puntata di prova", StringComparison.OrdinalIgnoreCase)) return "sondaggio";
if (reason.StartsWith("Limite puntate", StringComparison.OrdinalIgnoreCase)) return "limite-puntate";
if (reason.StartsWith("Budget", StringComparison.OrdinalIgnoreCase)) return "budget";
@@ -139,7 +168,7 @@ namespace AutoBidder.Engine.Backtest
ShippingCost: null,
MaxBids: auction.MaxClicks,
MaxTotalSpendEuro: auction.MaxTotalSpendEuro,
MinSavingsPercentage: options.Settings.MinSavingsPercentage,
MinSavingsPercentage: options.EffectiveSettings.MinSavingsPercentage,
StopAtBreakEven: auction.StopAtBreakEven));
if (!budget.CanBid)
@@ -151,8 +180,7 @@ namespace AutoBidder.Engine.Backtest
continue;
}
strategy.UpdateHeatMetric(auction, options.Settings, options.Username);
var decision = strategy.ShouldPlaceBid(auction, state, options.Settings, options.Username);
var decision = strategy.ShouldPlaceBid(auction, state, options.EffectiveSettings, options.Username, allAuctions: null, shadow: true);
if (!decision.ShouldBid)
{
@@ -282,6 +310,7 @@ namespace AutoBidder.Engine.Backtest
Name = header?.Name ?? "",
BuyNowPrice = header?.BuyNowPrice,
BidBeforeDeadlineMs = options.LeadMs,
BidLeadIsManual = true, // la rigiocata prova un anticipo preciso, non quello adattivo
BidsUsedOnThisAuction = 0
};
+71 -24
View File
@@ -2,22 +2,20 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoBidder.Data;
namespace AutoBidder.Engine.Backtest
{
/// <summary>
/// Legge un dossier d'asta (JSON Lines) e lo riporta in memoria come sequenza di
/// eventi tipizzati.
/// Porta in memoria un'asta registrata come sequenza di eventi tipizzati, per
/// rigiocarla contro il motore.
///
/// <para>È volutamente separato da chi lo consuma e non conosce il motore: il dossier
/// è un formato scritto altrove e cambierà, quindi la sua interpretazione va tenuta in
/// un punto solo e verificabile su righe scritte a mano.</para>
///
/// <para>Una riga che non si capisce viene <b>saltata</b>, non fa fallire la lettura:
/// i dossier vengono chiusi dal processo mentre gira, quindi l'ultima riga di un file
/// interrotto può benissimo essere monca — e sarebbe assurdo buttare via otto ore di
/// osservazione per l'ultimo mezzo evento.</para>
/// <para>La fonte normale è il database (<see cref="FromDatabase"/>). Resta il lettore
/// dei vecchi dossier JSON Lines (<see cref="Read"/>) per i test scritti a mano e per
/// un'eventuale importazione dell'archivio precedente. Una riga che non si capisce
/// viene saltata, non fa fallire la lettura.</para>
/// </summary>
public static class DossierReader
{
@@ -61,7 +59,7 @@ namespace AutoBidder.Engine.Backtest
int DistinctBidders,
int MyBids);
/// <summary>Un dossier completo.</summary>
/// <summary>Un'asta completa.</summary>
public sealed class Session
{
public Header? Header { get; init; }
@@ -69,13 +67,73 @@ namespace AutoBidder.Engine.Backtest
public List<Poll> Polls { get; init; } = new();
public List<Bid> Bids { get; init; } = new();
/// <summary>Righe che non si è riusciti a interpretare.</summary>
/// <summary>Righe che non si è riusciti a interpretare (solo dai file).</summary>
public int SkippedLines { get; init; }
/// <summary>Un dossier senza intestazione non dice di che asta parla.</summary>
/// <summary>Senza intestazione e interrogazioni non c'è nulla da rigiocare.</summary>
public bool IsUsable => Header != null && Polls.Count > 0;
}
// ── Dal database ─────────────────────────────────────────────────
public static Session? FromDatabase(AuctionDatabase db, string auctionId)
{
var a = db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auctionId);
if (a == null) return null;
var state = "";
try
{
var cfg = a.StrOrNull("config_json");
if (!string.IsNullOrEmpty(cfg))
{
using var doc = JsonDocument.Parse(cfg);
if (doc.RootElement.TryGetProperty("state", out var s) && s.ValueKind == JsonValueKind.String)
state = s.GetString() ?? "";
}
}
catch { }
var header = new Header(
AuctionId: a.Str("auction_id"),
Name: a.Str("name"),
ProductKey: a.Str("product_key"),
BuyNowPrice: a.DblOrNull("buy_now_price"),
BidCostEuro: a.DblOrNull("bid_fee") is { } fee && fee > 0 ? fee : 0.20,
ConfiguredLeadMs: a.Int("configured_lead_ms"),
State: state);
var summary = a.StrOrNull("outcome") == null ? null : new Summary(
Outcome: a.Str("outcome"),
Winner: a.Str("winner_user"),
FinalPrice: a.Dbl("final_price"),
WonByMe: a.Bool("won_by_me"),
Resets: a.Int("resets"),
DistinctBidders: a.Int("distinct_bidders"),
MyBids: a.Int("my_bids"));
var polls = db.Query(
"SELECT t, price, timer, status, last_bidder, is_mine, expiry_unix, server_unix, ping_ms FROM polls " +
"WHERE auction_id = ?1 ORDER BY t", auctionId)
.Select(r => new Poll(
T: r.Dbl("t"), Price: r.Dbl("price"), Timer: r.Dbl("timer"), Status: r.Str("status"),
LastBidder: r.Str("last_bidder"), Mine: r.Bool("is_mine"),
ExpiryUnix: r.Long("expiry_unix"), ServerUnix: r.Long("server_unix"), PingMs: r.Dbl("ping_ms")))
.ToList();
var bids = db.Query(
"SELECT seen_t, username, server_ts, price_after, is_mine FROM bids WHERE auction_id = ?1 " +
"ORDER BY COALESCE(server_ts, 0), price_after", auctionId)
.Select(r => new Bid(
T: r.Dbl("seen_t"), User: r.Str("username"), UnixSeconds: r.LongOrNull("server_ts") ?? 0,
Price: r.Dbl("price_after"), Mine: r.Bool("is_mine")))
.ToList();
return new Session { Header = header, Summary = summary, Polls = polls, Bids = bids };
}
// ── Dai vecchi dossier JSON Lines ────────────────────────────────
public static Session ReadFile(string path) => Read(File.ReadLines(path));
public static Session Read(IEnumerable<string> lines)
@@ -90,7 +148,6 @@ namespace AutoBidder.Engine.Backtest
{
if (string.IsNullOrWhiteSpace(raw)) continue;
// Il primo file scritto porta il segnaposto di codifica in testa.
var line = raw.TrimStart('', ' ', '\t');
if (line.Length == 0 || line[0] != '{') { skipped++; continue; }
@@ -105,8 +162,6 @@ namespace AutoBidder.Engine.Backtest
case "summary": summary = ReadSummary(root); break;
case "poll": polls.Add(ReadPoll(root)); break;
case "bid": bids.Add(ReadBid(root)); break;
// reset e log non servono alla simulazione: il reset è deducibile
// dal cambio di scadenza, e il log è testo per l'utente.
}
}
catch (JsonException)
@@ -125,8 +180,6 @@ namespace AutoBidder.Engine.Backtest
};
}
// ── Lettura dei singoli eventi ───────────────────────────────────
private static Header ReadHeader(JsonElement root)
{
var product = Obj(root, "product");
@@ -174,10 +227,6 @@ namespace AutoBidder.Engine.Backtest
Price: Num(root, "price", 0),
Mine: Bool(root, "mine"));
/// <summary>
/// Le puntate portano l'istante come data ISO con fuso. Serve il secondo unix,
/// perché è nella stessa unità dei timestamp che il motore vede a runtime.
/// </summary>
private static long ParseUnix(string? iso)
{
if (string.IsNullOrWhiteSpace(iso)) return 0;
@@ -188,8 +237,6 @@ namespace AutoBidder.Engine.Backtest
: 0;
}
// ── Accessori tolleranti ─────────────────────────────────────────
private static string? Str(JsonElement owner, string name) =>
owner.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
@@ -0,0 +1,203 @@
# PROMPT PER CLAUDE CODE — Sistema "Bidoo-RL": bot analitico e auto-apprendente per aste al centesimo (Bidoo)
## TL;DR
- Questo documento è un **prompt completo e autosufficiente** da incollare in Claude Code per far costruire un sistema `bidoo-rl` di raccolta dati + ML/RL + esecuzione della puntata a bassa latenza su Bidoo, con meccanica verificata, modello matematico esplicito (valore atteso, hazard rate, MDP), schema DB, pipeline ML/RL, architettura software e piano a milestone.
- **Avvertenza sostanziale e non negoziabile**: le penny auction sono a valore atteso fortemente negativo per il bidder — Augenblick (2016), su 166.000 aste, calcola che "average profit margins actually exceed 50%" per il venditore; "75% of bidders leave the website forever before placing 50 bids, and 86% stop before placing 100 bids"; solo i bidder più esperti raggiungono profitti attesi "leggermente positivi". Inoltre l'automazione e i multi-account **violano i Termini e Condizioni di Bidoo** (un solo account, divieto di "software di monitoraggio dell'offerta"). Il sistema è progettato in modalità **SHADOW obbligatoria** con kill-switch e risk manager.
- Il valore reale del progetto è **analitico** (stimare quando un'asta è profittevole e astenersi quasi sempre), non "vincere". Il prompt istruisce esplicitamente Claude Code a costruire un sistema scettico by-design, pronto a concludere "non conviene".
## Key Findings (base di evidenza incorporata nel prompt)
1. **Meccanica Bidoo verificata**: incremento €0,01/puntata; timer breve (~1012 s, una pagina live mostrava "Timer: 12 sec") che si resetta ad ogni puntata; vince l'ultimo; puntate non rimborsabili; opzione "Compralo Ora" per recuperare le puntate spese; auto-puntata ufficiale che punta negli ultimi secondi fino a un massimo impostato; aste a prenotazione; slot/limiti di vincita ("7 ogni giorno" su pagina live); orari ~10:00/12:0024:00; fee transazione €1,00; unit-cost puntata tra ~€0,07 e ~€0,50 secondo il pacchetto (fonti pubbliche incoerenti).
2. **Teoria**: equilibrio a profitto atteso nullo per puntata (Augenblick 2016 *Review of Economic Studies* 83(1):5886; Platt-Price-Tappen 2013 *Management Science* 59:21172134; Hinnosaar 2016); deviazioni spiegate da sunk-cost, risk-loving, asimmetrie informative (Byers-Mitzenmacher-Zervas 2010).
3. **ML/RL trasferibile**: RTB come MDP (Cai et al., WSDM 2017), offline RL (CQL Kumar et al. 2020 / IQL Kostrikov et al. 2021), contextual bandit/Thompson sampling (Agrawal-Goyal 2013), gradient boosting + survival analysis (Cox/lifelines), sniping (Roth-Ockenfels 2002 AER).
4. **Dati**: dataset pubblico Swoopo (Byers/Mitzenmacher/Zervas, people.bu.edu/zg/swoopo.html): 121.419 aste "Outcomes" (8 set 200812 dic 2009) + ~2,5M bid "Traces"; repo Bidoo-Bot (Selenium). Meccanismo real-time di Bidoo (AJAX/WebSocket/SSE) **NON confermato**.
5. **Legale**: ToS Bidoo vietano bot e multi-account; AGCM (comunicato 25 settembre 2015, PS8243-PS9030) ha sanzionato operatori di aste al centesimo — €700.000 a Flamingo Intervest Ltd per pratiche commerciali scorrette e €300.000 a Marcandi Ltd (madbid.com) per "informazioni ingannevoli e omissive" e per non aver chiarito "l'aleatorietà" delle aste — ma **nessuna azione specifica contro Bidoo** risulta; in Italia le penny auction sono zona grigia (non gioco d'azzardo formale, trattate come pratica commerciale ex Codice del Consumo).
---
## IL DOCUMENTO-PROMPT (copiare da qui in Claude Code)
Sei un ingegnere ML/RL senior e sviluppatore Python esperto. Costruisci un sistema modulare chiamato `bidoo-rl` per l'analisi, la simulazione e (opzionalmente, in modalità controllata) l'automazione della puntata su aste al centesimo, con Bidoo (it.bidoo.com) come target. Il proprietario del progetto (Alberto) è esperto C++/Python e costruisce sistemi analitici AI-driven. Scrivi codice, identificatori, nomi di file e commenti in inglese; la documentazione utente in italiano. Rispetta rigorosamente le sezioni seguenti.
### 0. CONTESTO, OBIETTIVO E FILOSOFIA
Obiettivo primario: costruire un sistema che **impari dai dati quando conviene puntare e, soprattutto, quando NON puntare**, stimando il valore atteso di ogni puntata e il rischio, con esecuzione a bassa latenza. L'obiettivo NON è "vincere sempre" ma massimizzare il ROI atteso di lungo periodo con controllo del rischio; l'esito atteso onesto di default è "astenersi dalla stragrande maggioranza delle aste".
Realtà economica da codificare come prior nel sistema (fonti accademiche): nelle penny auction il margine medio del banco supera il 50% del valore del bene — Augenblick (2016), su un dataset di ~100 milioni di puntate su 166.000 aste, "calculate that average profit margins actually exceed 50%"; la maggioranza dei bidder perde denaro; "75% of bidders leave the website forever before placing 50 bids, and 86% stop before placing 100 bids"; solo i bidder più esperti/sofisticati raggiungono profitti attesi leggermente positivi. Il sistema deve essere scettico by-design e ancorare ogni stima di profittabilità al null-model teorico (profitto atteso per puntata = 0).
### 1. VINCOLI, AVVERTENZE LEGALI/ToS E GESTIONE DEL RISCHIO (PRIORITÀ MASSIMA)
Implementa questi vincoli PRIMA di ogni logica di puntata:
- **Modalità SHADOW obbligatoria di default**: il sistema decide e logga le decisioni ("avrei puntato / non puntato, con questa motivazione e questo EV") ma NON invia puntate reali. L'esecuzione live è dietro un flag esplicito `--enable-live-bidding` + variabile d'ambiente `BIDOO_LIVE_CONFIRM=I_UNDERSTAND_THE_RISKS` + doppia conferma interattiva.
- **Kill-switch** globale: file sentinella `KILL_SWITCH` e handler SIGTERM che fermano immediatamente ogni azione live.
- **Avvertenza ToS**: i Termini e Condizioni di Bidoo (it.bidoo.com/terms.php) vietano esplicitamente "l'uso di software di monitoraggio dell'offerta", la "creazione di account multipli dello stesso utente" e stabiliscono "È permesso avere un solo account per persona; se si tenta di aprire più di un account, quelli in eccesso e anche il principale, potranno essere bloccati o chiusi". La violazione comporta blocco/chiusura account e perdita delle puntate. Il README deve riportarlo in evidenza. NON implementare creazione automatica di account multipli né farming multi-account (a differenza del repo pubblico HunterStile/Bidoo-Bot che include `Crea_Account.py` e `Boosting.py`: NON replicarli).
- **Inquadramento legale**: in Italia le aste al centesimo sono in una zona grigia — non classificate formalmente come gioco d'azzardo (Art. 721 c.p. richiede vincita/perdita "interamente o quasi interamente aleatoria"; nelle penny auction è presente un elemento di abilità), trattate dall'AGCM come pratica commerciale (Codice del Consumo). L'AGCM (comunicato del 25 settembre 2015, provvedimenti PS8243 e PS9030) ha irrogato "una sanzione complessiva di un milione di euro" a due operatori: €700.000 a Flamingo Intervest Ltd per pratiche commerciali scorrette e €300.000 a Marcandi Ltd (operatore di madbid.com) per informazioni ingannevoli/omissive e per non aver chiarito "l'aleatorietà" delle aste. Non risulta alcuna azione specifica contro Bidoo. Le sanzioni AGCM per pratiche scorrette vanno da €5.000 a €10.000.000. Riportare questi fatti nel README.
- **Risk manager** con parametri hard (config `risk.yaml`): bankroll totale, budget massimo puntate per asta, budget giornaliero, stop-loss giornaliero, limite di puntate per singolo articolo/categoria, numero massimo di aste concorrenti, drawdown massimo oltre il quale il sistema entra in stato `HALT`. Ogni decisione live deve passare da `RiskManager.authorize(decision) -> bool`.
- **Hedge "Compralo Ora"**: modellare esplicitamente l'opzione buy-now come copertura (limita la perdita alla differenza tra prezzo di listino Bidoo e prezzo di mercato reale). Il risk manager deve valutare il buy-now come exit.
### 2. MECCANICA DI BIDOO DA MODELLARE (dati verificati; incertezze segnalate)
Modella una classe `AuctionMechanics` con questi parametri (default verificati, tutti overridabili da config perché possono cambiare):
- **Incremento prezzo**: `price_increment = 0.01 €` per puntata (verificato, costante su tutte le fonti).
- **Timer/countdown**: si resetta ad ogni puntata; durata breve, tipicamente 1015 secondi (una pagina asta live mostrava "Timer: 12 sec"; altre fonti citano fino a 60 s di finestra massima). Alla scadenza parte una fase di "controllo" in cui Bidoo verifica puntate valide arrivate all'ultimo secondo prima di decretare il vincitore. **INCERTEZZA**: valore esatto del timer e logica di reset/estensione non documentati ufficialmente; renderli parametrici e stimarli dai dati.
- **Costo puntata (bid fee)**: acquistata solo in pacchetti; unit-cost variabile ~€0,07–€0,50. Figure ricorrenti nelle guide: pacchetto piccolo ~150 puntate ≈ €0,20/puntata; pacchetto grande ~7000 puntate ≈ €0,070,08/puntata; alcune fonti indicano €0,100,12 medi, altre un listino nominale €0,50. **INCERTEZZA ALTA**: i prezzi esatti dei pacchetti 2025-26 non sono confermabili dalla pagina ufficiale (bloccata al fetch automatico); parametrizzare in `bid_packs.yaml` e ricalibrare all'uso reale.
- **Fee di transazione**: ~€1,00 sulla vincita (visto su pagina live it.bidoo.com/auction.php?a=<id>).
- **Vincita**: vince l'ultimo che ha puntato quando il timer arriva a 0 senza rilanci; paga prezzo finale + spedizione + fee, tenendo conto delle puntate spese.
- **Compralo Ora / buy-now**: chi non vince può comprare l'oggetto a prezzo di listino recuperando le puntate spese come credito.
- **Auto-puntata ufficiale (autobid)**: Bidoo offre una funzione nativa che punta automaticamente negli ultimi secondi fino a un massimo di puntate impostato dall'utente; attivabile su più aste. Modellare gli autobid concorrenti come avversari.
- **Tipi di asta**: aste normali, aste a prenotazione (solo utenti prenotati, con puntate prenotate scalate), aste su pacchetti di puntate, categorie prodotti (elettronica, buoni, elettrodomestici...). Aggiungere flag per aste principianti/con limitazioni per analogia con Swoopo (`flg_beginnerauction`; `flg_click_only` = "NailBiter"; `flg_fixedprice`).
- **Orari**: aste attive con finestra tipica 10:0024:00 (alcune fonti 12:0024:00), pausa notturna; nuove aste avviate di continuo.
- **Slot/limiti di vincita**: limiti settimanali/mensili e giornalieri al numero di aste vincibili da un utente (es. "7 ogni giorno" visto su pagina live). Codificare come vincolo che riduce il valore marginale di ulteriori vittorie.
- **Feed real-time — INCERTEZZA CRITICA**: NON è documentato pubblicamente se Bidoo aggiorni prezzo/timer/ultimo-offerente via AJAX polling, WebSocket, long-polling o SSE, né esistono endpoint JSON noti/confermati. L'unico bot pubblico noto (github.com/HunterStile/Bidoo-Bot) usa **Selenium** e, come da README, "parteciperà all'asta cliccando l'ultimo millisecondo prima che scada" — il che suggerisce che non sia banale colpire un endpoint di stato diretto. Pattern URL asta confermato: `https://it.bidoo.com/auction.php?a=<AUCTION_ID>`. **Istruzione a Claude Code**: implementare un `feed adapter` astratto con due backend: (a) `BrowserFeed` basato su Playwright che legge il DOM (default, robusto), e (b) `HttpFeed` da attivare SOLO se, durante una fase esplicita di reverse-engineering manuale del traffico di rete (DevTools/Network), si identifica un endpoint di stato reale. NON assumere endpoint inventati; documentare come `UNKNOWN` finché non verificato.
Includi parametri per confronto/portabilità (QuiBids, DealDash, Swoopo storico, MadBid) per riuso dei modelli.
### 3. MODELLO MATEMATICO (formule esplicite)
Implementa un modulo `models/theory.py` con le seguenti formule, ognuna documentata con la fonte.
**3.1 Valore atteso di una puntata (decisione istantanea)**
Sia `V` il valore reale di mercato del bene (prezzo Amazon/Idealo, NON il listino gonfiato Bidoo), `p_price` il prezzo corrente dell'asta, `c` il costo della puntata (bid fee), `Δ = 0,01€` l'incremento, `P_win` la probabilità stimata di vincere se punto ora (cioè che nessuno rilanci prima dello scadere del timer):
```
E[bid] = P_win · (V (p_price + Δ) fee_transaction shipping)
(1 P_win) · c
```
Regola operativa: **puntare solo se `P_win · (V p_price_next costs) > c`**, cioè se il guadagno atteso condizionato alla vittoria supera il costo della puntata. Con l'hedge buy-now, la perdita in caso di sconfitta è limitata a `max(0, prezzo_listino_bidoo V)`.
**3.2 Equilibrio a profitto atteso nullo (benchmark teorico)**
Augenblick (2016) e Platt-Price-Tappen (2013): in ogni equilibrio che non finisce subito, i giocatori puntano in modo probabilistico tale che **il profitto atteso di ogni puntata è zero**. Nel modello risk-neutral (Platt et al.) il ricavo atteso del venditore eguaglia il valore del bene ("produce the same expected revenue as standard auctions"); con preferenze risk-loving il ricavo eccede V (le penny auction sono "a mild form of gambling"). Il rapporto critico è `V/c`: se il numero di bidder attivi `n > V/c`, il payoff atteso di almeno un bidder è 0, e in ogni equilibrio simmetrico il payoff di tutti i bidder è 0 (Hinnosaar 2016). **Usa questo come null-model**: qualunque profittabilità stimata dai modelli ML che ecceda sistematicamente lo zero teorico va trattata con sospetto e attribuita ad asimmetrie/irrazionalità altrui (sunk-cost, risk-loving), non a un edge strutturale.
**3.3 Hazard rate empirico e survival analysis**
Modella la terminazione dell'asta come processo di sopravvivenza. Definisci `h(t | X)` = probabilità che l'asta termini al round `t` dato lo stato `X`. Stima con:
- **Kaplan-Meier** per la curva di sopravvivenza empirica per categoria/valore (usa `lifelines`).
- **Cox proportional hazards** e/o gradient boosting survival (XGBoost/LightGBM con obiettivo Cox, o modelli AFT) per `h(t|X)` con covariate (n. bidder attivi, sunk cost aggregato, prezzo/valore, orario). Deviazione chiave da modellare (Augenblick): l'hazard rate empirico scende sotto la predizione standard man mano che l'asta prosegue (effetto sunk-cost), riducendo il rendimento per puntata da alto a ~"1824 cent per ogni puntata da 75 cent" nelle fasi avanzate del dataset Swoopo.
**3.4 Processo puntuale per l'arrivo delle puntate**
Modella gli arrivi come processo di Poisson non-omogeneo (o Hawkes per il clustering "una puntata chiama l'altra"): intensità `λ(t | X)`. La probabilità che nessuno punti entro il timer residuo `τ` è `P_no_bid = exp(−∫_{t}^{t+τ} λ(s) ds)`, che alimenta direttamente `P_win`.
**3.5 Formulazione come Markov Decision Process (per RL)**
- **Stato** `s`: [prezzo corrente, tempo residuo nel timer, n. bidder distinti attivi, n. puntate totali, intervalli tra puntate recenti, profilo aggressività avversari, presenza autobid, valore V e prezzo listino, categoria/articolo, orario/giorno, budget residuo per asta, sunk cost proprio, latenza di rete misurata, jitter].
- **Azioni** `a`: {NO-OP (non puntare), BID (punta ora), BID@T−δ (schedula puntata a timer−δ), BUY-NOW (esci con hedge), LEAVE (abbandona)}.
- **Reward** `r`: vittoria `+(V prezzo_finale costi_totali_puntate fee shipping)`; ogni puntata `c`; buy-now `(prezzo_listino V)` limitato; abbandono `0`. Reward basato su V reale, non sul listino Bidoo.
- **Sconto** `γ` vicino a 1 entro l'asta (orizzonte breve), con reward terminale dominante.
- **Obiettivo**: massimizzare `E[Σ γ^t r_t]` sotto i vincoli del risk manager.
Riferimenti: MDP di bidding in RTB (Cai et al., WSDM 2017, "formulate the bid decision process as a reinforcement learning problem"); optimal stopping / war of attrition; bid shading dalla teoria delle aste.
### 4. PIPELINE ML/RL
Struttura `ml/` con questi componenti:
**4.1 Modelli supervisionati (feature tabellari + sequenza)**
- `final_price_predictor`: regressione del prezzo finale (LightGBM/XGBoost/CatBoost).
- `end_within_n_seconds`: classificatore **calibrato** P(asta termina entro N s | stato) — con calibrazione (Platt scaling/isotonic) perché `P_win` deve essere una probabilità onesta.
- `active_bidders_estimator`: stima del numero di bidder attivi residui (variabile latente chiave: le asimmetrie su `n` guidano il profitto del banco, Byers et al. 2010).
- `opponent_classifier`: profilazione bidder da bid history in classi {aggressivo, sniper, autobid, principiante} — feature: timing delle puntate (chi punta a inizio/fine countdown è più sofisticato, Wang & Xu 2012), frequenza, streak.
- Modelli di sequenza (LSTM/GRU/TCN/Transformer) sulla sequenza temporale delle puntate per catturare dinamica e clustering.
**4.2 Reinforcement Learning**
- **Offline RL prima di tutto**: addestra su log storici con **CQL** (Conservative Q-Learning, Kumar et al. 2020) o **IQL** (Implicit Q-Learning, Kostrikov et al. 2021) per evitare overestimation di azioni out-of-distribution. La loss CQL penalizza le Q-value delle azioni OOD (`log Σ_a exp Q(s,a) E_{aD}[Q(s,a)]`). Cruciale perché non possiamo esplorare liberamente col denaro reale.
- **Contextual bandit / Thompson sampling** (Agrawal-Goyal 2013) come policy leggera e interpretabile per la decisione BID/NO-OP a livello di singola opportunità (context = feature dello stato; ricompensa = EV realizzato). Buon trade-off exploration/exploitation con regret basso.
- **Simulatore gym-like** (`gymnasium.Env`) `PennyAuctionEnv`: avversari sintetici calibrati sui dati reali (mix di profili aggressivo/sniper/autobid/principiante con hazard e sunk-cost calibrati su Swoopo e sui dati Bidoo raccolti). Serve per (a) addestrare/valutare policy online in sicurezza, (b) stress test.
- **On-policy** (PPO) o **off-policy** (SAC per azioni continue tipo δ di timing; DQN per azioni discrete) SOLO dentro il simulatore, mai live all'inizio.
- Librerie: `stable-baselines3` + `gymnasium`; per offline RL `d3rlpy` (CQL/IQL/BCQ).
**4.3 Feature engineering** (`ml/features.py`)
Articolo/categoria; valore retail Bidoo vs prezzo di mercato reale (scraping Amazon/Idealo o input manuale); orario e giorno della settimana; prezzo corrente e rapporto prezzo/V; n. puntate totali; n. bidder distinti; intervalli tra puntate (media/varianza/ultimo); aggressività dei bidder presenti; presenza/intensità autobid; storico dello stesso articolo e di aste simili; latenza di rete e jitter; tempo residuo nel timer; posizione temporale della puntata nel countdown; sunk cost proprio e aggregato.
**4.4 Valutazione e retraining continuo**
- Metriche: **ROI per puntata**, ROI per asta, win rate, costo medio per vittoria, sconto reale vs prezzo di mercato, max drawdown, Sharpe delle sessioni, calibrazione (Brier score, reliability diagram) di `P_win`.
- Valutazione offline: **off-policy evaluation** (importance sampling, doubly robust) prima di ogni promozione di policy.
- A/B tra policy in shadow; promozione live solo se supera le soglie §8.
- Retraining schedulato (es. settimanale) + drift detection sulle feature.
- Tracking con `mlflow`; tuning con `optuna`.
### 5. RACCOLTA DATI E SCHEMA DB
**5.1 Ingestion** (`ingestion/`)
- `Playwright` (async) come default per rendering JS e lettura DOM; `httpx` per eventuali endpoint HTTP solo se verificati manualmente. Gestione sessione/login via cookie storage; rispettare rate limiting (backoff esponenziale + jitter, User-Agent realistico); gestire eventuale Cloudflare/anti-bot con attese e sessione browser persistente.
- **Campionamento sub-secondo** durante le fasi calde (timer < 20 s): polling adattivo (più frequente vicino allo zero). Registrare timestamp locale ad alta risoluzione (`time.perf_counter_ns`).
- **Sincronizzazione temporale**: sincronizzare l'orologio via NTP; stimare l'offset con l'orologio del server Bidoo (response header `Date` o timer visualizzato) e la latenza RTT; salvare offset e RTT con ogni osservazione.
- **Misura latenza end-to-end** della puntata: tempo dal comando di puntata alla conferma di registrazione; salvare per la compensazione (§6).
- Comportamento rispettoso: throttling, niente scraping distruttivo, solo dati necessari; documentare che il monitoraggio automatico viola i ToS.
**5.2 Schema DB** (SQLite in dev, PostgreSQL/Supabase in prod; SQLAlchemy + Alembic)
- `items`: item_id, title, category, retail_price_bidoo, market_price_est, market_price_source, updated_at.
- `auctions`: auction_id, item_id, type (normal/reservation/bidpack/beginner), start_time, end_time, final_price, winner_user_id, bid_increment, bid_fee_ref, shipping, transaction_fee, status.
- `bids`: bid_id, auction_id, user_id, price_after, timer_before, timer_after, server_ts, local_ts, clock_offset_ms, rtt_ms, is_autobid_flag_est.
- `bidders`: user_id, first_seen, n_auctions, n_bids, win_count, aggressiveness_score, profile_class.
- `sessions`: session_id, start, end, ip/proxy, cookies_ref, notes.
- `bot_decisions`: decision_id, auction_id, ts, state_snapshot (JSON), action, ev_estimate, p_win, model_version, shadow_or_live, executed, outcome, **reason_detail** (motivazione testuale dettagliata).
- `network_metrics`: ts, rtt_ms, jitter_ms, clock_offset_ms.
**5.3 Volumi e quanti dati servono**
Per addestrare modelli supervisionati affidabili servono migliaia di aste chiuse con storico completo. Riferimenti di scala: il dataset Swoopo pubblico contiene 121.419 aste "Outcomes" (8 set 200812 dic 2009) e ~2,5 milioni di puntate "Traces"; Augenblick usa "an auction-level dataset of nearly 100 million bids on 166,000 auctions and a bid-level dataset of 13 million bids on 18,000 auctions from more than 129,000 users". Raccomanda di raccogliere ≥ 48 settimane di dati continui e ≥ qualche migliaio di aste chiuse per categoria prima di fidarsi delle stime; fino ad allora restare in shadow.
**5.4 Fonti pubbliche da integrare**
- **Swoopo dataset** (Byers/Mitzenmacher/Zervas, people.bu.edu/zg/swoopo.html): colonne outcomes (auction_id, product_id, item, retail, price, finalprice, bidincrement, bidfee, winner, placedbids, freebids, endtime, `flg_click_only`=NailBiter, `flg_beginnerauction`, `flg_fixedprice`) + traces con tempo e utente per ogni bid. Usare per pre-training/calibrazione del simulatore.
- Repo pubblici (solo come riferimento tecnico, NON copiare la parte multi-account): HunterStile/Bidoo-Bot (Selenium, Asta.py), qix/penny-bot, plutokid/bid-o-matic.
### 6. STRATEGIA DI ESECUZIONE E COMPENSAZIONE DELLA LATENZA
- Puntare il più tardi possibile (sniping): la letteratura eBay (Roth-Ockenfels 2002 AER) mostra che il late bidding è razionale come best response a bidder incrementali; nelle penny auction puntare tardi minimizza le puntate sprecate.
- **Compensazione latenza**: `t_send = t_expiry (latency_est + safety_margin)`, dove `latency_est` = RTT/2 + tempo di elaborazione client + jitter buffer, stimato online dai `network_metrics`. `safety_margin` calibrato per una probabilità target di arrivo-in-tempo (es. 99° percentile della latenza osservata).
- **Evitare puntate sprecate**: se tra la decisione e l'invio arriva la puntata di un altro (il pulsante "Punta" ricompare), rivalutare: spesso conviene NON re-puntare subito. Logica di cancellazione/riprogrammazione.
- Gestione errori: timeout, disconnessioni, rifiuto della puntata, saldo insufficiente → stati espliciti e retry idempotenti; ogni tentativo loggato.
### 7. ARCHITETTURA SOFTWARE, REPO E STACK
```
bidoo-rl/
README.md # avvertenze ToS/legali + realtà economica (EV negativo) in apertura
pyproject.toml
config/ (risk.yaml, bid_packs.yaml, mechanics.yaml, feeds.yaml)
ingestion/ (playwright_feed.py, http_feed.py, feed_base.py, clock_sync.py, rate_limit.py)
storage/ (models.py [SQLAlchemy], migrations/ [Alembic], repo.py)
features/ (build_features.py, opponent_profiling.py)
models/ (theory.py, supervised.py, survival.py, sequence.py)
ml/ (offline_rl.py [d3rlpy CQL/IQL], bandit.py [Thompson], env.py [PennyAuctionEnv], train.py, evaluate.py, ope.py)
policy/ (decision_engine.py, risk_manager.py, executor.py, latency.py)
backtest/ (simulator.py, replay.py)
monitoring/ (dashboard.py [Streamlit], metrics.py, alerts.py)
cli.py # comandi: collect, train, backtest, shadow, live (gated)
tests/ # pytest, coverage su risk_manager, executor, theory
```
Stack: Python 3.11+, `playwright`, `asyncio`, `httpx`, `pandas`/`polars`, `numpy`, `scikit-learn`, `lightgbm`/`xgboost`/`catboost`, `lifelines` (Kaplan-Meier/Cox), `torch`, `stable-baselines3`, `gymnasium`, `d3rlpy` (CQL/IQL), `optuna`, `mlflow`, `SQLAlchemy`+`alembic`, `pydantic` (config), `streamlit` (dashboard), `pytest`.
### 8. METRICHE DI SUCCESSO E CRITERI DI STOP
- **Gate shadow → live** (tutti richiesti su ≥ 4 settimane shadow): ROI per puntata atteso > 0 con estremo inferiore dell'intervallo di confidenza > 0; calibrazione di `P_win` con Brier score sotto soglia prefissata; drawdown simulato entro limite; OPE positiva e robusta.
- **Criteri di STOP/kill definitivo** (se veri, dichiarare il progetto non profittevole e fermarsi): dopo N aste live il ROI cumulato per puntata è ≤ 0 con IC che esclude valori positivi rilevanti; oppure la varianza dei rendimenti rende il rischio di rovina inaccettabile dato il bankroll; oppure il null-model teorico (profitto atteso = 0) non viene battuto in modo statisticamente significativo. La posizione di default, coerente con Augenblick/Wang-Xu/Hinnosaar, è che un edge consistente sia improbabile; il sistema deve essere pronto a concludere "non conviene" e a limitarsi al ruolo analitico.
- Nicchie dove un margine può esistere (da testare, non garantite): aste a bassa partecipazione/orari morti, aste per principianti/con limitazioni, articoli con V ben noto e liquidabile, uso disciplinato del buy-now come hedge, sfruttamento di avversari sunk-cost/irrazionali.
### 9. LOG ED ESPORTAZIONI
- Tutte le esportazioni analitiche in **CSV con delimitatore punto e virgola (`;`)** e colonna `reason_detail` con motivazione dettagliata per ogni decisione (EV stimato, P_win, n_bidder stimati, hazard, latenza, vincolo di rischio applicato, azione scelta e perché).
- Logging strutturato (JSON) + `bot_decisions` su DB; dashboard con ROI, win rate, calibrazione, drawdown, distribuzione dei rendimenti.
### 10. PIANO DI LAVORO A FASI (milestone per Claude Code)
- **M1 — Scaffolding & risk core**: repo, config, `RiskManager`, kill-switch, modalità shadow, test. (Nessuna rete.)
- **M2 — Ingestion & storage**: Playwright feed sul DOM, clock sync, schema DB, raccolta aste chiuse + live in shadow.
- **M3 — Dati storici & simulatore**: import Swoopo dataset, `PennyAuctionEnv` con avversari calibrati, backtester.
- **M4 — Modelli supervisionati**: final price, end-within-N, active bidders, opponent profiling, survival (Cox/GBM), calibrazione.
- **M5 — Policy & offline RL**: `theory.py` (EV, hazard, MDP), bandit Thompson, CQL/IQL offline, OPE.
- **M6 — Valutazione shadow**: girare in shadow ≥ 4 settimane, misurare metriche §8, A/B policy.
- **M7 — Esecuzione & latenza (gated)**: `executor` + compensazione latenza, test in sandbox; live solo se i gate §8 sono superati e con importi minimi.
- **M8 — Monitoraggio & retraining**: dashboard, drift detection, retraining schedulato, criteri di stop automatici.
Consegna codice pulito, tipizzato (type hints), testato (pytest), con docstring e un README che apra con le avvertenze legali/ToS e la realtà economica (EV negativo atteso).
---
## Details (contesto e giustificazione delle scelte del prompt)
**Perché il null-model è centrale.** Tre lavori indipendenti convergono: Augenblick (2016) dimostra che "in any equilibria that does not end immediately, players bid probabilistically such that the expected profit from every bid is zero"; Platt-Price-Tappen (2013) mostrano che con bidder risk-neutral le pay-to-bid producono lo stesso ricavo atteso delle aste standard, ma con lievi preferenze risk-loving il ricavo del venditore eccede V ("a mild form of gambling"); Hinnosaar (2016) prova che con agenti razionali risk-neutral le penny auction non possono spiegare l'alto profitto medio del venditore. La profittabilità del banco viene quindi da deviazioni comportamentali: sunk-cost fallacy (Augenblick: la probabilità di abbandono decresce all'aumentare del sunk cost) e asimmetrie informative sul numero di bidder (Byers-Mitzenmacher-Zervas 2010). Un bot può sperare in un edge SOLO sfruttando l'irrazionalità altrui, non un vantaggio strutturale — da qui il design scettico.
**Perché offline RL e non RL online diretto.** Non si può esplorare liberamente con denaro reale; CQL/IQL imparano da log storici penalizzando le azioni out-of-distribution, e l'OPE valida una policy prima di rischiare. Il simulatore con avversari calibrati (sui dati Swoopo pubblici e sui dati Bidoo raccolti) è il banco di prova sicuro.
**Perché il timing è decisivo.** Roth-Ockenfels documentano che lo sniping (puntare all'ultimo istante) è una best response razionale in aste a chiusura "hard"; unito alla compensazione della latenza misurata, è la leva tecnica principale per non sprecare puntate. Wang & Xu (2012) osservano che i bidder che puntano a inizio/fine countdown sono i più sofisticati: utile per la profilazione avversari.
**Incertezze tecniche reali.** Il meccanismo real-time di Bidoo e gli eventuali endpoint JSON non sono confermabili da fonti pubbliche; l'unico bot noto usa Selenium con click sul DOM. Il prompt impone quindi Playwright/DOM come default e vieta endpoint inventati.
## Recommendations
1. **Costruire M1M6 (analisi/shadow) prima di tutto** e trattare l'esecuzione live come opzionale e sconsigliata finché i dati non provano un edge statisticamente significativo. Benchmark che cambia la decisione: ROI/puntata con estremo inferiore dell'IC > 0 su ≥ 4 settimane shadow.
2. **Verificare manualmente il meccanismo real-time di Bidoo (DevTools/Network)** prima di scrivere qualsiasi `HttpFeed`; default su Playwright/DOM. Se non emerge un endpoint di stato affidabile, restare su DOM.
3. **Aggiornare `bid_packs.yaml` con i prezzi reali osservati** al momento dell'uso: le fonti pubbliche divergono (€0,07–€0,50/puntata) e la pagina ufficiale non è verificabile automaticamente.
4. **Fermarsi (stop-loss di progetto) se il null-model teorico non viene battuto**: se dopo il periodo di validazione l'EV per puntata non è positivo con significatività, dichiarare il sistema strumento analitico (non di puntata) e disattivare il live.
5. **Non implementare mai multi-account/farming**: viola i ToS e concentra il rischio di chiusura dell'account (inclusa perdita delle puntate acquistate).
## Caveats
- **Prezzi pacchetti, valore V e timer esatti non confermabili da fonte ufficiale**: tutti parametrizzati in config, da ricalibrare all'uso.
- **Meccanismo real-time ed endpoint di Bidoo: sconosciuti/da verificare**; il prompt evita esplicitamente endpoint inventati.
- **Automazione e multi-account violano i Termini e Condizioni di Bidoo** (divieto di software di monitoraggio dell'offerta, un solo account): rischio concreto di blocco account e perdita puntate.
- **Evidenza accademica netta**: EV negativo per i bidder in media (margine banco > 50%; oltre il 75% dei bidder abbandona entro 50 puntate; profitto consistente improbabile). Le recensioni pubbliche (Trustpilot/Opinioni) contengono numerose accuse di "bot" che manterrebbero alte le puntate: sono affermazioni non verificate degli utenti, non fatti accertati, ma vanno tenute presenti come ipotesi da testare (comportamento anomalo degli avversari) e come rischio reputazionale/operativo.
- **Quadro legale in evoluzione**: le penny auction in Italia sono in una zona grigia; l'AGCM è già intervenuta contro operatori del settore (2015) e potrebbe intervenire in futuro; nessuna azione specifica contro Bidoo risulta ad oggi, ma ciò non costituisce garanzia.
+59 -39
View File
@@ -69,11 +69,11 @@
Style="{StaticResource VerticalTabButton}"
Checked="TabDatiStatistici_Checked"/>
<RadioButton x:Name="TabEsporta"
Content="Esporta"
Tag="&#xEDE1;"
<RadioButton x:Name="TabApprendimento"
Content="Apprendimento"
Tag="&#xE945;"
Style="{StaticResource VerticalTabButton}"
Checked="TabEsporta_Checked"/>
Checked="TabApprendimento_Checked"/>
<RadioButton x:Name="TabImpostazioni"
Content="Impostazioni"
@@ -142,7 +142,7 @@
StartClicked="AuctionMonitor_StartClicked"
PauseAllClicked="AuctionMonitor_PauseAllClicked"
StopClicked="AuctionMonitor_StopClicked"
ExportClicked="AuctionMonitor_ExportClicked"
LearningToggled="AuctionMonitor_LearningToggled"
AddUrlClicked="AuctionMonitor_AddUrlClicked"
RemoveUrlClicked="AuctionMonitor_RemoveUrlClicked"
RemoveAllClicked="AuctionMonitor_RemoveAllClicked"
@@ -153,7 +153,6 @@
CopyUrlClicked="AuctionMonitor_CopyUrlClicked"
OpenAuctionInternalClicked="AuctionMonitor_OpenAuctionInternalClicked"
OpenAuctionExternalClicked="AuctionMonitor_OpenAuctionExternalClicked"
ExportAuctionClicked="AuctionMonitor_ExportAuctionClicked"
ResetSettingsClicked="AuctionMonitor_ResetSettingsClicked"
ClearBiddersClicked="AuctionMonitor_ClearBiddersClicked"
ClearLogClicked="AuctionMonitor_ClearLogClicked"
@@ -187,6 +186,9 @@
ReapplyClicked="Products_ReapplyClicked"
RemoveClicked="Products_RemoveClicked"
ClearClicked="Products_ClearClicked"
ClearAllClicked="Products_ClearAllClicked"
UnwatchAllClicked="Products_UnwatchAllClicked"
RemoveUnwatchedClicked="Products_RemoveUnwatchedClicked"
ApplySuggestedClicked="Products_ApplySuggestedClicked"
ApplyAllSuggestedClicked="Products_ApplyAllSuggestedClicked"
ProductLimitsEdited="Products_ProductLimitsEdited"
@@ -209,12 +211,8 @@
OpenConfigClicked="FreeBids_OpenConfigClicked"
OptionsChanged="FreeBids_OptionsChanged"/>
<!-- Esporta Panel -->
<controls:ExportControl x:Name="Export"
Visibility="Collapsed"
ExportRequested="Export_ExportRequested"
OpenExportFolderClicked="Export_OpenFolderClicked"
OpenLastFileClicked="Export_OpenLastFileClicked"/>
<!-- Apprendimento Panel -->
<controls:LearningControl x:Name="Learning" Visibility="Collapsed"/>
<!-- Storico Panel -->
<Grid x:Name="StatisticsPanel" Visibility="Collapsed" Background="{DynamicResource Brush.Bg}">
@@ -260,40 +258,63 @@
<!-- La lettura per prodotto vive nella scheda Prodotti, insieme ai
limiti: qui resta lo storico asta per asta. -->
<!-- Solo icone, come nel monitor: il tooltip dice esattamente cosa succede. -->
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="CleanupStatsButton"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Pulisci dati incompleti" Margin="0,0,8,0"
ToolTip="Analizza lo storico e toglie le aste che non superano i controlli che scegli tu.&#10;&#10;Il conto si aggiorna mentre scegli, e prima di scrivere viene salvata una copia dello storico."
Click="CleanupStatsButton_Click"/>
<Button x:Name="BackfillWinnerBidsButton"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Recupera puntate dei vincitori" Margin="0,0,8,0"
ToolTip="Chiede al server quante puntate ha speso il vincitore di ogni asta già conclusa.&#10;&#10;Il dato sta in data.php e non scade: le aste registrate prima che l&apos;applicazione sapesse leggerlo non sono perse, basta richiederle. È quello che il sito mostra come «Puntate utilizzate» sulla pagina dell&apos;asta chiusa.&#10;&#10;Va piano di proposito, una richiesta alla volta in corsia di fondo: non deve rubare banda alle aste in corso."
Click="BackfillWinnerBidsButton_Click"/>
<Button x:Name="BackfillWinnerBidsButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Accent}"
ToolTip="Recupera le puntate dei vincitori.&#10;Chiede al server quante puntate ha speso il vincitore di ogni asta conclusa a cui manca il dato. Sta in data.php e non scade: le aste registrate prima che l&apos;applicazione sapesse leggerlo non sono perse. Va piano di proposito, una richiesta alla volta in corsia di fondo, per non rubare banda alle aste in corso."
Click="BackfillWinnerBidsButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE896;"/>
</Button>
<Button x:Name="CleanupStatsButton" Style="{StaticResource IconButton}"
ToolTip="Pulisci i dati incompleti.&#10;Analizza lo storico e toglie le aste che non superano i controlli che scegli tu (senza vincitore, senza prezzo, osservate solo in parte…). Il conto si aggiorna mentre scegli; prima di scrivere viene salvata una copia. Le statistiche per prodotto vengono ricalcolate."
Click="CleanupStatsButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE90F;"/>
</Button>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
<Button Style="{StaticResource IconButton}"
ToolTip="Esporta tutto in CSV (foglio di calcolo)"
ToolTip="Esporta lo storico in CSV (foglio di calcolo).&#10;Chiede dove salvare il file."
Click="ExportStatsCsvButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE9F9;"/>
</Button>
<Button Style="{StaticResource IconButton}"
ToolTip="Esporta tutto in JSON (con serie prezzi e puntate per utente)"
ToolTip="Esporta lo storico in JSON, con serie dei prezzi e puntate per utente.&#10;Chiede dove salvare il file."
Click="ExportStatsJsonButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE8B5;"/>
</Button>
<Button Style="{StaticResource IconButton}" ToolTip="Apri la cartella dei dati"
<Button Style="{StaticResource IconButton}"
ToolTip="Apri la cartella dei database."
Click="OpenStatsFolderButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
<Button Style="{StaticResource IconButton}"
ToolTip="Seleziona tutte le righe.&#10;Poi «Elimina le selezionate». Ctrl+clic e Maiusc+clic scelgono righe singole o intervalli."
Click="SelectAllStatsButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE8B3;"/>
</Button>
<Button x:Name="DeleteSelectedStatsButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Elimina le aste selezionate.&#10;Toglie dallo storico le righe selezionate con puntate, interrogazioni, reset e decisioni collegate. Le statistiche per prodotto vengono ricalcolate. Prima viene salvata una copia nei backup. Vale anche il tasto Canc sulla griglia."
Click="DeleteSelectedStatsButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE738;"/>
</Button>
<Button Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Svuota lo storico" Click="ClearStatsButton_Click">
ToolTip="Azzera a scelta.&#10;Un dialogo dice cosa c'è e quanto pesa, e scegli voce per voce: storico, statistiche per prodotto, vecchia cartella Dati, apprendimento. Lo storico viene prima copiato nei backup."
Click="ClearStatsButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE74D;"/>
</Button>
<Button Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Svuota completamente lo storico.&#10;Toglie tutte le aste concluse con puntate, interrogazioni, reset, decisioni, misure di rete e contabilità del rischio, e azzera le statistiche per prodotto. Prima viene salvata una copia nei backup. I prodotti seguiti, le aste nel monitor e l'apprendimento restano."
Click="WipeHistoryButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE894;"/>
</Button>
</StackPanel>
</Grid>
</Border>
@@ -304,7 +325,9 @@
Background="{DynamicResource Brush.Surface}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1" CornerRadius="12">
<DataGrid x:Name="StatsDetailGrid" AutoGenerateColumns="False" IsReadOnly="True"
Background="Transparent" Margin="4">
SelectionMode="Extended" SelectionUnit="FullRow"
Background="Transparent" Margin="4"
PreviewKeyDown="StatsDetailGrid_PreviewKeyDown">
<DataGrid.Columns>
<DataGridTextColumn Header="Chiusa il" Binding="{Binding EndedAtDisplay}" Width="Auto" MinWidth="94"/>
<DataGridTextColumn Header="Nome" Binding="{Binding Name}" Width="*" MinWidth="160"/>
@@ -343,14 +366,11 @@
ConnectSessionClicked="Settings_ConnectSessionClicked"
DisconnectSessionClicked="Settings_DisconnectSessionClicked"
OpenBrowserLoginClicked="Settings_OpenBrowserLoginClicked"
ComputeBidLeadAdviceClicked="Settings_ComputeBidLeadAdviceClicked"
ClearBidLeadStatsClicked="Settings_ClearBidLeadStatsClicked"
BrowseDataFolderClicked="Settings_BrowseDataFolderClicked"
BrowseStatsFolderClicked="Settings_BrowseStatsFolderClicked"
OpenDataFolderClicked="Settings_OpenDataFolderClicked"
OpenStatsFolderClicked="Settings_OpenStatsFolderClicked"
BrowseLogFolderClicked="Settings_BrowseLogFolderClicked"
OpenLogFolderClicked="Settings_OpenLogFolderClicked"/>
BrowseDatabaseFolderClicked="Settings_BrowseDatabaseFolderClicked"
WipeAllDataClicked="Settings_WipeAllDataClicked"
ResetSettingsClicked="Settings_ResetSettingsClicked"
ExportAppLogClicked="Settings_ExportAppLogClicked"
OpenDatabaseFolderClicked="Settings_OpenDatabaseFolderClicked"/>
</Grid>
</Grid>
</Window>
+50 -6
View File
@@ -101,6 +101,8 @@ namespace AutoBidder
// Inizializza servizi
_auctionMonitor = new AuctionMonitor();
Learning.AuctionsProvider = () => _auctionMonitor.GetAuctions();
RefreshLearningToggle();
// ? NUOVO: Inizializza SessionService
InitializeSessionService();
@@ -218,25 +220,35 @@ namespace AutoBidder
/// <summary>
/// Costruisce le schede prodotto la prima volta, ricavandole dallo storico già
/// raccolto.
/// raccolto, e le ricostruisce una volta sola quando cambia il modo di contare.
///
/// <para>Senza, chi ha già mesi di aste alle spalle aprirebbe un file vuoto e
/// dovrebbe aspettare le aste future per vedere qualcosa — proprio i numeri che
/// servono adesso per decidere i limiti. Gira una sola volta e su un thread di
/// lavoro: rileggere l'archivio all'avvio non deve ritardare la finestra.</para>
/// servono adesso per decidere i limiti. Gira su un thread di lavoro: rileggere
/// l'archivio all'avvio non deve ritardare la finestra.</para>
///
/// <para>Il contrassegno nel database d'esercizio dice con quale «generazione» le
/// schede sono state contate. La 2 è quella in cui la chiusura di un'asta arriva
/// una volta sola: prima ogni asta conclusa veniva contata due volte, e le schede
/// costruite allora vanno rifatte dallo storico, che è sempre stato giusto.</para>
/// </summary>
private void EnsureProductCardsBuilt()
{
const string Marker = "products-generation";
const string Generation = "2";
try
{
if (System.IO.File.Exists(AppPaths.ProductsFile)) return;
var op = Data.OperationalDatabase.Instance;
if (ProductStatsStore.GetAll().Count > 0 && op.GetDocument(Marker) == Generation) return;
_ = System.Threading.Tasks.Task.Run(() =>
{
var count = ProductStatsStore.RebuildFromHistory();
op.SetDocument(Marker, Generation);
Dispatcher.BeginInvoke(() =>
Log($"[PRODOTTI] Schede prodotto costruite dallo storico ({count} aste)", LogLevel.Info));
Log($"[PRODOTTI] Schede prodotto ricostruite dallo storico ({count} aste)", LogLevel.Info));
});
}
catch (Exception ex)
@@ -273,14 +285,46 @@ namespace AutoBidder
// ===== AUCTION MONITOR EVENT HANDLERS =====
// ── Coalescenza degli aggiornamenti verso l'interfaccia ──────────
//
// Con il tetto delle richieste tolto ogni asta produce ~17 interrogazioni al
// secondo, e ognuna arrivava qui: un BeginInvoke, una scansione lineare dei
// ViewModel, e venti notifiche di proprietà. Con trenta aste sono diecimila
// notifiche al secondo sul thread dell'interfaccia, con cinquantasette quasi
// ventimila. È questo che rende l'applicazione lenta — non il disco, la cui
// scrittura era già asincrona e costa mezzo punto di CPU.
//
// La stragrande maggioranza di quei poll non porta niente di nuovo da mostrare:
// stesso prezzo, stesso puntatore, stesso stato, stesso secondo sul cronometro.
// Il conto alla rovescia scorre da solo grazie al battito da 200 ms, che legge la
// scadenza ancorata e non ha bisogno del poll. Quindi si manda all'interfaccia
// solo il poll in cui cambia qualcosa di visibile, e comunque uno al secondo per
// asta perché ping e contatori si rinfreschino.
//
// Il motore non è toccato: la decisione di puntare vede ogni poll come prima.
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, (string Firma, long Tick)> _ultimoInviato = new();
private static string FirmaVisibile(AuctionState s) =>
string.Concat(s.Price.ToString("F2"), '|', s.LastBidder, '|', s.Status, '|', (int)s.Timer, '|', s.IsMyBid ? '1' : '0');
private void AuctionMonitor_OnAuctionUpdated(AuctionState state)
{
var firma = FirmaVisibile(state);
var adesso = Environment.TickCount64;
if (_ultimoInviato.TryGetValue(state.AuctionId, out var prec) &&
prec.Firma == firma && adesso - prec.Tick < 1000)
{
return; // niente di nuovo da mostrare, e l'ultimo rinfresco è recente
}
_ultimoInviato[state.AuctionId] = (firma, adesso);
Dispatcher.BeginInvoke(() =>
{
var vm = _auctionViewModels.FirstOrDefault(a => a.AuctionId == state.AuctionId);
if (vm == null) return;
// Sempre: aggiorna la riga in griglia (poche proprietà, costo trascurabile).
vm.UpdateState(state);
// I pannelli di dettaglio mostrano SOLO l'asta selezionata: ricostruirli
+194
View File
@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace AutoBidder.Ml
{
/// <summary>
/// Cosa aspettarsi da un'asta prima che cominci: puntate del vincitore e prezzo di
/// chiusura in rapporto al valore, per prodotto, fascia oraria e tipo di giorno.
///
/// <para><b>Il problema dei cassetti vuoti.</b> Prodotto × fascia oraria × feriale/festivo
/// fa centinaia di combinazioni, e molte hanno tre aste o nessuna. Una media su tre
/// aste non è una stima, è un aneddoto. Per questo ogni stima è un <i>restringimento</i>:
/// si parte dalla media globale, la si corregge verso quella del prodotto quanto più il
/// prodotto ha esempi, e poi verso quella della combinazione esatta quanto più ne ha
/// lei. Con dieci esempi il cassetto pesa metà; con cento quasi tutto; con uno quasi
/// niente. È lo stimatore di Bayes empirico, e non serve nessuna libreria.</para>
///
/// <para>Si aggiorna a ogni asta chiusa con la formula di Welford, senza tenere gli
/// esempi: media e varianza corrono, i dati no.</para>
/// </summary>
public sealed class AuctionProfileStats
{
/// <summary>Esempi oltre i quali un cassetto pesa più del livello sopra.</summary>
public const double Shrink = 10.0;
private sealed class Cell
{
public long N { get; set; }
public double MeanBids { get; set; }
public double M2Bids { get; set; }
public long NBids { get; set; }
public double MeanRatio { get; set; }
public double M2Ratio { get; set; }
public long NRatio { get; set; }
}
private readonly Dictionary<string, Cell> _cells = new(StringComparer.Ordinal);
private readonly object _sync = new();
public long AuctionsObserved { get; private set; }
/// <summary>Stima per una combinazione, con quanti esempi la sorreggono davvero.</summary>
public readonly record struct Estimate(double WinnerBids, double CloseRatio, long ExactSupport, long ProductSupport);
/// <summary>
/// Registra un'asta chiusa. Le puntate del vincitore possono mancare (dato non
/// attendibile o non registrato): in quel caso alimentano solo il prezzo.
/// </summary>
public void Observe(string? productKey, DateTime closedAtLocal, int? winnerBids, double? closeRatio)
{
var keys = KeysFor(productKey, closedAtLocal);
lock (_sync)
{
AuctionsObserved++;
foreach (var k in keys)
{
if (!_cells.TryGetValue(k, out var c)) _cells[k] = c = new Cell();
c.N++;
if (winnerBids is { } b && b > 0)
{
c.NBids++;
var d = b - c.MeanBids;
c.MeanBids += d / c.NBids;
c.M2Bids += d * (b - c.MeanBids);
}
if (closeRatio is { } r && r > 0)
{
c.NRatio++;
var d = r - c.MeanRatio;
c.MeanRatio += d / c.NRatio;
c.M2Ratio += d * (r - c.MeanRatio);
}
}
}
}
/// <summary>Cosa aspettarsi da un'asta di questo prodotto che chiude a quest'ora.</summary>
public Estimate? Expect(string? productKey, DateTime whenLocal)
{
var keys = KeysFor(productKey, whenLocal); // [globale, prodotto, esatta]
lock (_sync)
{
if (!_cells.TryGetValue(keys[0], out var global) || global.N == 0) return null;
_cells.TryGetValue(keys[1], out var product);
_cells.TryGetValue(keys[2], out var exact);
var bids = Shrunk(global.MeanBids, global.NBids, product?.MeanBids, product?.NBids ?? 0, exact?.MeanBids, exact?.NBids ?? 0);
var ratio = Shrunk(global.MeanRatio, global.NRatio, product?.MeanRatio, product?.NRatio ?? 0, exact?.MeanRatio, exact?.NRatio ?? 0);
return new Estimate(bids, ratio, exact?.N ?? 0, product?.N ?? 0);
}
}
/// <summary>
/// Globale → prodotto → esatta, ogni livello pesato dai propri esempi.
/// </summary>
private static double Shrunk(double g, long ng, double? p, long np, double? e, long ne)
{
var stima = g;
if (p is { } pv && np > 0) stima = (np * pv + Shrink * stima) / (np + Shrink);
if (e is { } ev && ne > 0) stima = (ne * ev + Shrink * stima) / (ne + Shrink);
return stima;
}
private static string[] KeysFor(string? productKey, DateTime when)
{
var prod = (productKey ?? "").Trim().ToLowerInvariant();
var band = BidFeatures.HourBand(when.Hour);
var day = BidFeatures.IsWeekend(when.DayOfWeek) ? "fest" : "fer";
return new[] { "g", "p:" + prod, "p:" + prod + "|h" + band + "|" + day };
}
/// <summary>Una riga del profilo, leggibile: per mostrarlo.</summary>
public readonly record struct Row(string Product, int HourBand, bool Weekend, long Auctions, double WinnerBids, double CloseRatio);
/// <summary>
/// Le combinazioni esatte con almeno un'asta, dalla più numerosa. Solo le esatte:
/// le righe di prodotto e quella globale sono medie delle esatte, e mostrarle
/// insieme confonderebbe.
/// </summary>
public IReadOnlyList<Row> Rows(int max = 200)
{
var list = new List<Row>();
lock (_sync)
{
foreach (var kv in _cells)
{
// chiave esatta: "p:{prodotto}|h{fascia}|{fer|fest}"
var k = kv.Key;
if (!k.StartsWith("p:", StringComparison.Ordinal)) continue;
var h = k.LastIndexOf("|h", StringComparison.Ordinal);
if (h < 0) continue;
var product = k.Substring(2, h - 2);
var rest = k[(h + 2)..]; // "{fascia}|{fer|fest}"
var bar = rest.IndexOf('|');
if (bar < 0 || !int.TryParse(rest[..bar], out var band)) continue;
var weekend = rest[(bar + 1)..] == "fest";
var c = kv.Value;
list.Add(new Row(product, band, weekend, c.N,
c.NBids > 0 ? c.MeanBids : double.NaN,
c.NRatio > 0 ? c.MeanRatio : double.NaN));
}
}
return list.OrderByDescending(r => r.Auctions).Take(max).ToList();
}
// ── Persistenza ──────────────────────────────────────────────────
private sealed class Snapshot
{
public int Version { get; set; } = 1;
public long AuctionsObserved { get; set; }
public Dictionary<string, Cell> Cells { get; set; } = new();
}
public string ToJson()
{
lock (_sync)
{
return JsonSerializer.Serialize(new Snapshot
{
AuctionsObserved = AuctionsObserved,
Cells = new Dictionary<string, Cell>(_cells)
});
}
}
public static AuctionProfileStats FromJson(string json)
{
var s = new AuctionProfileStats();
try
{
var snap = JsonSerializer.Deserialize<Snapshot>(json);
if (snap?.Cells != null)
{
foreach (var kv in snap.Cells) s._cells[kv.Key] = kv.Value;
s.AuctionsObserved = snap.AuctionsObserved;
}
}
catch (JsonException) { /* si riparte da vuoto */ }
return s;
}
}
}
+185
View File
@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
namespace AutoBidder.Ml
{
/// <summary>
/// Lo stato di un'asta nell'istante di una puntata, ridotto a ciò che serve per
/// prevedere se quella puntata resterà senza risposta.
/// </summary>
/// <param name="CycleSeconds">Quanto è durato il ciclo che questa puntata chiude: secondi dalla puntata precedente. Negativo = ignoto.</param>
/// <param name="AutoShareRecent">Quota di autopuntate fra le ultime dieci, da 0 a 1. Negativo = ignoto.</param>
/// <param name="DistinctBiddersRecent">Puntatori distinti fra le ultime venti puntate.</param>
/// <param name="PriceRatio">Prezzo corrente in rapporto al valore del prodotto. Null = valore ignoto.</param>
/// <param name="Hour">Ora locale in cui avviene la puntata.</param>
/// <param name="Day">Giorno della settimana.</param>
/// <param name="OpenHour">Ora locale in cui l'asta è stata agganciata (apertura, o quando si è cominciato a seguirla).</param>
/// <param name="IsAutoBid">La puntata è dell'autopuntata del sito.</param>
/// <param name="CyclesSinceManual">Puntate automatiche di fila prima di questa. Negativo = nessuna manuale in vista.</param>
/// <param name="AuctionDepthCents">Prezzo in centesimi: è il numero di puntate fatte da tutti nell'asta.</param>
/// <param name="SameBidderAsTwoAgo">Chi punta è lo stesso di due puntate fa: la firma del duello.</param>
/// <param name="ProductKey">Chiave normalizzata del prodotto.</param>
public readonly record struct BidContext(
double CycleSeconds,
double AutoShareRecent,
int DistinctBiddersRecent,
double? PriceRatio,
int Hour,
DayOfWeek Day,
int OpenHour,
bool IsAutoBid,
int CyclesSinceManual,
int AuctionDepthCents,
bool SameBidderAsTwoAgo,
string ProductKey);
/// <summary>
/// Trasforma un <see cref="BidContext"/> in un vettore di numeri per il modello.
///
/// <para><b>Perché quasi tutto a fasce.</b> Le variabili non sono lineari: un ciclo di
/// 9 secondi non è «tre volte» uno di 3, è un mondo diverso — chi ha aspettato 9
/// secondi ha visto passare la soglia dell'autopuntata senza che nessuno sparasse. A
/// fasce, ogni fascia impara il suo peso e il modello resta lineare, cioè leggibile:
/// si può aprire il file dei pesi e vedere cosa ha imparato.</para>
///
/// <para><b>Le fasce orarie</b> ricalcano la curva misurata sulle chiusure: notte e
/// ore di confine (0-1, 9) care, 10-13 le migliori, poi tre fasce fino a mezzanotte.
/// Il giorno è solo feriale/festivo: sette valori distinti sarebbero troppo pochi
/// esempi per ciascuno.</para>
///
/// <para><b>Il prodotto</b> è ridotto a 32 cassetti con una funzione di hash stabile.
/// Due prodotti possono finire nello stesso cassetto, ed è accettato: il modello impara
/// una tendenza di gruppo; il profilo esatto per prodotto sta altrove.</para>
/// </summary>
public static class BidFeatures
{
private const int GapBuckets = 6; // <2, <4, <6, <8, <10, 10+
private const int BiddersBuckets = 5; // 1, 2, 3, 4-5, 6+
private const int RatioBuckets = 6; // <2%, <5%, <10%, <20%, 20%+, ignoto
private const int HourBands = 6;
private const int DayTypes = 2;
private const int BidTypes = 2;
private const int SinceManualBuckets = 4;
private const int DepthBuckets = 4; // <50, <150, <400, 400+
private const int ProductBuckets = 32;
public const int Size =
GapBuckets + 2 /* quota auto + ignota */ + BiddersBuckets + RatioBuckets +
HourBands + DayTypes + BidTypes + SinceManualBuckets + DepthBuckets +
1 /* stesso puntatore */ + HourBands /* ora di aggancio */ + ProductBuckets;
/// <summary>Nomi delle variabili, nello stesso ordine del vettore: per leggere i pesi.</summary>
public static readonly string[] Names = BuildNames();
public static double[] Build(in BidContext c)
{
var x = new double[Size];
var o = 0;
// durata del ciclo
if (c.CycleSeconds >= 0) x[o + GapBucket(c.CycleSeconds)] = 1;
o += GapBuckets;
// quota di autopuntate recenti (numerica) + bandiera "ignota"
if (c.AutoShareRecent >= 0) x[o] = Math.Clamp(c.AutoShareRecent, 0, 1);
else x[o + 1] = 1;
o += 2;
x[o + BiddersBucket(c.DistinctBiddersRecent)] = 1;
o += BiddersBuckets;
x[o + RatioBucket(c.PriceRatio)] = 1;
o += RatioBuckets;
x[o + HourBand(c.Hour)] = 1;
o += HourBands;
x[o + (IsWeekend(c.Day) ? 1 : 0)] = 1;
o += DayTypes;
x[o + (c.IsAutoBid ? 1 : 0)] = 1;
o += BidTypes;
x[o + SinceManualBucket(c.CyclesSinceManual)] = 1;
o += SinceManualBuckets;
x[o + DepthBucket(c.AuctionDepthCents)] = 1;
o += DepthBuckets;
if (c.SameBidderAsTwoAgo) x[o] = 1;
o += 1;
x[o + HourBand(c.OpenHour)] = 1;
o += HourBands;
x[o + ProductBucket(c.ProductKey)] = 1;
return x;
}
// ── Fasce ────────────────────────────────────────────────────────
public static int GapBucket(double s) => s < 2 ? 0 : s < 4 ? 1 : s < 6 ? 2 : s < 8 ? 3 : s < 10 ? 4 : 5;
public static int BiddersBucket(int n) => n <= 1 ? 0 : n == 2 ? 1 : n == 3 ? 2 : n <= 5 ? 3 : 4;
public static int RatioBucket(double? r) =>
r is not { } v ? 5 : v < 0.02 ? 0 : v < 0.05 ? 1 : v < 0.10 ? 2 : v < 0.20 ? 3 : 4;
/// <summary>
/// 0: notte e ore sospese (0-8) · 1: le 9 · 2: 10-12 · 3: 13-17 · 4: 18-20 · 5: 21-23.
/// </summary>
public static int HourBand(int h)
{
h = ((h % 24) + 24) % 24;
if (h <= 8) return 0;
if (h == 9) return 1;
if (h <= 12) return 2;
if (h <= 17) return 3;
if (h <= 20) return 4;
return 5;
}
public static bool IsWeekend(DayOfWeek d) => d is DayOfWeek.Saturday or DayOfWeek.Sunday;
public static int SinceManualBucket(int n) => n < 0 ? 3 : n == 0 ? 0 : n <= 2 ? 1 : n <= 5 ? 2 : 3;
public static int DepthBucket(int cents) => cents < 50 ? 0 : cents < 150 ? 1 : cents < 400 ? 2 : 3;
/// <summary>
/// Hash stabile fra un avvio e l'altro. <c>string.GetHashCode</c> cambia a ogni
/// processo per scelta di .NET: usarlo qui farebbe rileggere pesi riferiti a
/// cassetti diversi da quelli in cui erano stati imparati.
/// </summary>
public static int ProductBucket(string? key)
{
if (string.IsNullOrWhiteSpace(key)) return 0;
uint h = 2166136261;
foreach (var ch in key.Trim().ToLowerInvariant())
{
h ^= ch;
h *= 16777619;
}
return (int)(h % ProductBuckets);
}
private static string[] BuildNames()
{
var n = new List<string>();
n.AddRange(new[] { "ciclo<2s", "ciclo<4s", "ciclo<6s", "ciclo<8s", "ciclo<10s", "ciclo≥10s" });
n.AddRange(new[] { "quotaAuto", "quotaAutoIgnota" });
n.AddRange(new[] { "puntatori=1", "puntatori=2", "puntatori=3", "puntatori4-5", "puntatori≥6" });
n.AddRange(new[] { "prezzo<2%", "prezzo<5%", "prezzo<10%", "prezzo<20%", "prezzo≥20%", "valoreIgnoto" });
n.AddRange(new[] { "ora0-8", "ora9", "ora10-12", "ora13-17", "ora18-20", "ora21-23" });
n.AddRange(new[] { "feriale", "festivo" });
n.AddRange(new[] { "manuale", "automatica" });
n.AddRange(new[] { "auto di fila=0", "auto di fila1-2", "auto di fila3-5", "auto di fila≥6" });
n.AddRange(new[] { "profondità<50", "profondità<150", "profondità<400", "profondità≥400" });
n.Add("stessoPuntatoreDiDueFa");
n.AddRange(new[] { "aggancio0-8", "aggancio9", "aggancio10-12", "aggancio13-17", "aggancio18-20", "aggancio21-23" });
for (var i = 0; i < ProductBuckets; i++) n.Add($"prodotto#{i}");
return n.ToArray();
}
}
}
+168
View File
@@ -0,0 +1,168 @@
using System;
namespace AutoBidder.Ml
{
/// <summary>
/// Il regime di concorrenza di un'asta: quando lasciar sfogare gli altri, e quando
/// tornare a puntare. Sostituisce «calore», «ritiro morbido», «avversari aggressivi» e
/// il vecchio rilevamento del duello con una sola macchina a stati, guidata da ciò
/// che il modello ha imparato invece che da soglie scritte a mano.
///
/// <para><b>Gli stati.</b></para>
/// <list type="bullet">
/// <item><b>Calmo</b>: si punta quando il valore atteso è positivo. È il regime
/// normale.</item>
/// <item><b>Sfogo</b>: gli altri si stanno battendo e ogni nostra puntata verrebbe
/// coperta. Non si punta. Ci si entra quando il valore atteso resta negativo per
/// qualche decisione di fila, o quando si riconosce un'autopuntata armata.</item>
/// <item><b>Sondaggio</b>: le condizioni sono tornate buone da abbastanza cicli. Si
/// concede <i>una</i> puntata di prova. Se resta senza risposta si torna Calmo; se
/// viene coperta subito si torna a Sfogo, e la pazienza richiesta per il prossimo
/// sondaggio raddoppia.</item>
/// </list>
///
/// <para><b>Perché l'isteresi.</b> Il valore atteso oscilla da un ciclo all'altro: un
/// cancello che punta al primo segno positivo e si ferma al primo negativo farebbe
/// avanti e indietro, e ogni «avanti» è una puntata pagata. Servono più segnali
/// buoni di fila per rientrare che segnali cattivi per uscire: sbagliare a rientrare
/// costa soldi, sbagliare a uscire costa al massimo un'attesa.</para>
///
/// <para><b>Perché la pazienza raddoppia.</b> Un sondaggio coperto subito dice che
/// dall'altra parte c'è ancora qualcuno che risponde: insistere con la stessa
/// pazienza sarebbe rifare lo stesso errore allo stesso prezzo. Raddoppiare fino a un
/// tetto è il modo più semplice di «capire da soli» che quell'asta, oggi, non si
/// lascia prendere — senza deciderlo una volta per tutte.</para>
///
/// <para>Classe pura con stato proprio: riceve i segnali, non legge niente da fuori.
/// Così si prova a tavolino e si rigioca sui dossier con la stessa logica del motore.</para>
/// </summary>
public sealed class CompetitionRegime
{
public enum Stato { Calmo, Sfogo, Sondaggio }
/// <summary>Decisioni negative di fila prima di andare in Sfogo.</summary>
public const int NegativiPerSfogo = 3;
/// <summary>Cicli buoni di fila richiesti al primo rientro; raddoppia a ogni sondaggio coperto.</summary>
public const int PazienzaIniziale = 3;
public const int PazienzaMassima = 24;
/// <summary>Secondi entro cui una risposta alla puntata di prova conta come «coperta subito».</summary>
public const double RispostaRapidaSecondi = 9.0;
public Stato StatoAttuale { get; private set; } = Stato.Calmo;
/// <summary>Quanti cicli buoni di fila servono adesso per rientrare.</summary>
public int Pazienza { get; private set; } = PazienzaIniziale;
/// <summary>Cicli buoni di fila visti finora durante lo Sfogo.</summary>
public int CicliBuoni { get; private set; }
/// <summary>Decisioni negative di fila viste finora in Calmo.</summary>
public int Negativi { get; private set; }
/// <summary>Sondaggi coperti subito, in quest'asta.</summary>
public int SondaggiFalliti { get; private set; }
private bool _sondaggioInCorso;
/// <summary>
/// Una decisione del motore: il valore atteso di puntare adesso era positivo?
/// Restituisce true se in questo regime si può puntare.
/// </summary>
public bool Osserva(bool valoreAttesoPositivo)
{
switch (StatoAttuale)
{
case Stato.Calmo:
if (valoreAttesoPositivo) { Negativi = 0; return true; }
if (++Negativi >= NegativiPerSfogo) EntraInSfogo();
return false;
case Stato.Sfogo:
if (!valoreAttesoPositivo) { CicliBuoni = 0; return false; }
if (++CicliBuoni < Pazienza) return false;
// Abbastanza cicli buoni di fila: una puntata di prova.
StatoAttuale = Stato.Sondaggio;
_sondaggioInCorso = false;
return true;
case Stato.Sondaggio:
// Finché la prova non è stata piazzata si può ancora puntare (una volta);
// dopo, si aspetta l'esito.
return !_sondaggioInCorso && valoreAttesoPositivo;
}
return true;
}
/// <summary>La nostra puntata è partita.</summary>
public void PuntataPiazzata()
{
if (StatoAttuale == Stato.Sondaggio) _sondaggioInCorso = true;
}
/// <summary>
/// Qualcuno ha risposto alla nostra ultima puntata dopo tanti secondi. Se eravamo
/// in Sondaggio e la risposta è rapida, il sondaggio è fallito.
/// </summary>
public void RispostaAvversaria(double secondiDopoLaMiaPuntata)
{
if (StatoAttuale != Stato.Sondaggio || !_sondaggioInCorso) return;
if (secondiDopoLaMiaPuntata <= RispostaRapidaSecondi)
{
SondaggiFalliti++;
Pazienza = Math.Min(PazienzaMassima, Pazienza * 2);
EntraInSfogo();
}
else
{
// Risposta lenta: non era una macchina, si torna a giocare.
Riprendi();
}
}
/// <summary>La nostra ultima puntata è rimasta senza risposta: siamo in testa da un pezzo.</summary>
public void NessunaRisposta()
{
if (StatoAttuale == Stato.Sondaggio) Riprendi();
}
/// <summary>Riconosciuta un'autopuntata armata: non c'è niente da sondare per ora.</summary>
public void AutopuntataRiconosciuta()
{
if (StatoAttuale != Stato.Sfogo)
{
Pazienza = Math.Max(Pazienza, PazienzaIniziale * 2);
EntraInSfogo();
}
}
private void EntraInSfogo()
{
StatoAttuale = Stato.Sfogo;
CicliBuoni = 0;
Negativi = 0;
_sondaggioInCorso = false;
}
private void Riprendi()
{
StatoAttuale = Stato.Calmo;
Negativi = 0;
CicliBuoni = 0;
_sondaggioInCorso = false;
// La pazienza non si azzera del tutto: chi ci ha coperto una volta può tornare.
Pazienza = Math.Max(PazienzaIniziale, Pazienza / 2);
}
/// <summary>Una riga per il registro.</summary>
public string Spiegazione() => StatoAttuale switch
{
Stato.Sfogo => $"lascio sfogare gli altri: rientro dopo {Pazienza} cicli buoni di fila (visti {CicliBuoni})",
Stato.Sondaggio => "puntata di prova: se viene coperta subito torno ad aspettare",
_ => "regime calmo"
};
}
}
+282
View File
@@ -0,0 +1,282 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoBidder.Data;
namespace AutoBidder.Ml
{
/// <summary>
/// Porta un'asta conclusa nella forma che serve ad addestrare: la sequenza delle
/// puntate in ordine di tempo dichiarato dal server, chi ha vinto, a quanto.
///
/// <para>La fonte normale è il database (<see cref="FromDatabase"/>). Resta anche il
/// lettore dei vecchi dossier JSON Lines (<see cref="Read"/>): serve ai test scritti
/// a mano e a un'eventuale importazione dell'archivio precedente.</para>
/// </summary>
public static class DossierTrainingReader
{
public sealed record Bid(double T, string User, long UnixSeconds, string BidType, bool Mine, double Price);
public sealed class Session
{
public string AuctionId { get; init; } = "";
public string ProductKey { get; init; } = "";
public double? BuyNowPrice { get; init; }
public DateTime AddedAtLocal { get; init; }
public List<Bid> Bids { get; init; } = new();
public string Winner { get; init; } = "";
public double FinalPrice { get; init; }
public bool HasSummary { get; init; }
/// <summary>Il nostro nome utente, dall'asta o da fuori.</summary>
public string Me { get; init; } = "";
/// <summary>
/// Utilizzabile per l'addestramento: chiusa, con almeno qualche puntata, e con
/// l'ultima puntata registrata che sia davvero del vincitore. Se non lo è, la
/// registrazione ha un buco proprio in fondo, e l'etichetta sarebbe sbagliata.
/// </summary>
public bool IsUsable =>
HasSummary && Bids.Count >= 3 && !string.IsNullOrEmpty(Winner) &&
string.Equals(Bids[^1].User, Winner, StringComparison.OrdinalIgnoreCase);
}
// ── Dal database ─────────────────────────────────────────────────
/// <summary>L'asta indicata, dal database. Null se non esiste.</summary>
public static Session? FromDatabase(AuctionDatabase db, string auctionId, string? me = null)
{
var a = db.QueryOne(
"SELECT auction_id, product_key, buy_now_price, added_at, winner_user, final_price, outcome, me " +
"FROM auctions WHERE auction_id = ?1", auctionId);
if (a == null) return null;
var chiSonoIo = a.StrOrNull("me") ?? me ?? "";
var bids = db.Query(
"SELECT username, price_after, bid_type, is_mine, server_ts, seen_t FROM bids " +
"WHERE auction_id = ?1 ORDER BY COALESCE(server_ts, 0), price_after", auctionId)
.Select(r => new Bid(
T: r.Dbl("seen_t"),
User: r.Str("username"),
UnixSeconds: r.LongOrNull("server_ts") ?? 0,
BidType: r.Str("bid_type"),
Mine: r.Bool("is_mine"),
Price: r.Dbl("price_after")))
.ToList();
return Finish(new Session
{
AuctionId = a.Str("auction_id"),
ProductKey = a.Str("product_key"),
BuyNowPrice = a.DblOrNull("buy_now_price"),
AddedAtLocal = (a.Date("added_at") ?? DateTime.MinValue) == DateTime.MinValue
? DateTime.MinValue
: a.Date("added_at")!.Value.ToLocalTime(),
Bids = bids,
Winner = a.Str("winner_user"),
FinalPrice = a.Dbl("final_price"),
HasSummary = a.StrOrNull("outcome") != null,
Me = chiSonoIo
});
}
/// <summary>Gli id delle aste concluse, dalla più vecchia alla più recente.</summary>
public static List<string> ClosedAuctionIds(AuctionDatabase db, int max = 0)
{
var ids = db.Query("SELECT auction_id FROM auctions WHERE outcome IS NOT NULL ORDER BY end_time")
.Select(r => r.Str("auction_id")).ToList();
return max > 0 && ids.Count > max ? ids.Skip(ids.Count - max).ToList() : ids;
}
// ── Dai vecchi dossier JSON Lines ────────────────────────────────
public static Session? ReadFile(string path, string? me = null)
{
try { return Read(File.ReadLines(path), me); }
catch (IOException) { return null; }
catch (UnauthorizedAccessException) { return null; }
}
public static Session Read(IEnumerable<string> lines, string? me = null)
{
string auctionId = "", productKey = "", winner = "";
var chiSonoIo = me ?? "";
double? buyNow = null;
double finalPrice = 0;
var addedAt = DateTime.MinValue;
var hasSummary = false;
var bids = new List<Bid>();
foreach (var raw in lines)
{
if (raw.Length < 20 || raw.Contains("\"type\":\"poll\"", StringComparison.Ordinal)) continue;
if (raw.Contains("\"type\":\"log\"", StringComparison.Ordinal)) continue;
if (raw.Contains("\"type\":\"reset\"", StringComparison.Ordinal)) continue;
var line = raw.TrimStart('', ' ', '\t');
if (line.Length == 0 || line[0] != '{') continue;
try
{
using var doc = JsonDocument.Parse(line);
var r = doc.RootElement;
var type = Str(r, "type");
if (type == "header")
{
auctionId = Str(r, "auctionId") ?? "";
productKey = Str(r, "productKey") ?? "";
var headerMe = Str(r, "me");
if (!string.IsNullOrWhiteSpace(headerMe)) chiSonoIo = headerMe;
if (r.TryGetProperty("product", out var p) && p.ValueKind == JsonValueKind.Object)
buyNow = NullableNum(p, "buyNowPrice");
if (DateTime.TryParse(Str(r, "addedAt"), CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out var a))
addedAt = a.Kind == DateTimeKind.Utc ? a.ToLocalTime() : a;
}
else if (type == "bid")
{
bids.Add(new Bid(
T: Num(r, "t", 0),
User: Str(r, "user") ?? "",
UnixSeconds: ParseUnix(Str(r, "bidAt")),
BidType: Str(r, "bidType") ?? "",
Mine: Bool(r, "mine"),
Price: Num(r, "price", 0)));
}
else if (type == "summary")
{
hasSummary = true;
winner = Str(r, "winner") ?? "";
finalPrice = Num(r, "finalPrice", 0);
}
}
catch (JsonException) { /* riga rotta: si va avanti */ }
}
return Finish(new Session
{
AuctionId = auctionId,
ProductKey = productKey,
BuyNowPrice = buyNow,
AddedAtLocal = addedAt,
Bids = bids,
Winner = winner,
FinalPrice = finalPrice,
HasSummary = hasSummary,
Me = chiSonoIo
});
}
/// <summary>Ordina le puntate nel tempo del server e marca le nostre dal nome utente.</summary>
private static Session Finish(Session s)
{
s.Bids.Sort((a, b) =>
{
var c = a.UnixSeconds.CompareTo(b.UnixSeconds);
return c != 0 ? c : a.Price.CompareTo(b.Price);
});
if (s.Me.Length > 0)
{
for (var i = 0; i < s.Bids.Count; i++)
{
if (!s.Bids[i].Mine && string.Equals(s.Bids[i].User, s.Me, StringComparison.OrdinalIgnoreCase))
s.Bids[i] = s.Bids[i] with { Mine = true };
}
}
return s;
}
/// <summary>
/// Gli esempi di addestramento di un'asta: uno per puntata, con etichetta
/// «è rimasta senza risposta» — vera solo per l'ultima. La prima puntata non ha un
/// ciclo da misurare e si salta.
/// </summary>
public static IEnumerable<(BidContext Context, bool Unanswered, bool Mine)> Examples(Session s)
{
var bids = s.Bids;
if (bids.Count < 2) yield break;
for (var i = 1; i < bids.Count; i++)
{
var b = bids[i];
var prev = bids[i - 1];
var gap = b.UnixSeconds > 0 && prev.UnixSeconds > 0 ? b.UnixSeconds - prev.UnixSeconds : -1;
var when = b.UnixSeconds > 0
? DateTimeOffset.FromUnixTimeSeconds(b.UnixSeconds).ToLocalTime().DateTime
: s.AddedAtLocal.AddSeconds(b.T);
var ctx = new BidContext(
CycleSeconds: gap,
AutoShareRecent: AutoShare(bids, i, 10),
DistinctBiddersRecent: Distinct(bids, i, 20),
PriceRatio: s.BuyNowPrice is > 0 ? b.Price / s.BuyNowPrice.Value : null,
Hour: when.Hour,
Day: when.DayOfWeek,
OpenHour: s.AddedAtLocal == DateTime.MinValue ? when.Hour : s.AddedAtLocal.Hour,
IsAutoBid: string.Equals(b.BidType, "Auto", StringComparison.OrdinalIgnoreCase),
CyclesSinceManual: SinceManual(bids, i),
AuctionDepthCents: (int)Math.Round(b.Price * 100),
SameBidderAsTwoAgo: i >= 2 && string.Equals(bids[i - 2].User, b.User, StringComparison.OrdinalIgnoreCase),
ProductKey: s.ProductKey);
yield return (ctx, i == bids.Count - 1, b.Mine);
}
}
// Le finestre guardano le puntate PRIMA di questa: sono quelle che si sarebbero
// viste nell'istante di decidere.
private static double AutoShare(List<Bid> bids, int i, int window)
{
int n = 0, auto = 0;
for (var k = i - 1; k >= 0 && n < window; k--, n++)
if (string.Equals(bids[k].BidType, "Auto", StringComparison.OrdinalIgnoreCase)) auto++;
return n == 0 ? -1 : (double)auto / n;
}
private static int Distinct(List<Bid> bids, int i, int window)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int k = i - 1, n = 0; k >= 0 && n < window; k--, n++) seen.Add(bids[k].User);
return Math.Max(1, seen.Count);
}
private static int SinceManual(List<Bid> bids, int i)
{
var n = 0;
for (var k = i - 1; k >= 0; k--)
{
if (!string.Equals(bids[k].BidType, "Auto", StringComparison.OrdinalIgnoreCase)) return n;
if (++n >= 20) break;
}
return -1;
}
// ── JSON ─────────────────────────────────────────────────────────
private static string? Str(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
private static double Num(JsonElement e, string name, double fallback) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : fallback;
private static double? NullableNum(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : null;
private static bool Bool(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
private static long ParseUnix(string? iso) =>
DateTimeOffset.TryParse(iso, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var d)
? d.ToUnixTimeSeconds() : 0;
}
}
+267
View File
@@ -0,0 +1,267 @@
# AutoBidder — il sistema di decisione e apprendimento
Questo documento descrive come AutoBidder osserva, impara e decide. Vale come specifica:
il codice in `Data/`, `Ml/`, `Services/BidStrategyService.cs`, `Services/RiskManager.cs` e
`Engine/AuctionRunner.cs` la implementa, i test in `Tests/` la verificano. Nessuna libreria
esterna: SQLite arriva da `winsqlite3.dll` di Windows, rete neurale, bandit, simulatore e
agente di rinforzo sono C# puro.
---
## 0. Avvertenze, prima di tutto
**Termini e condizioni di Bidoo.** I termini (it.bidoo.com/terms.php) vietano «l'uso di
software di monitoraggio dell'offerta» e la creazione di account multipli, e consentono
un solo account per persona. Questo programma è software di monitoraggio e puntata: il suo
uso può comportare il blocco o la chiusura dell'account e la perdita delle puntate
acquistate. Non implementa e non implementerà la creazione di account multipli.
**Quadro legale.** In Italia le aste al centesimo sono in una zona grigia: non
classificate come gioco d'azzardo (art. 721 c.p. richiede aleatorietà pressoché totale;
qui c'è un elemento di abilità), ma trattate dall'AGCM come pratica commerciale ai sensi
del Codice del Consumo. Nel 2015 (provvedimenti PS8243 e PS9030) l'AGCM ha sanzionato due
operatori del settore per un milione di euro complessivo. Non risulta alcuna azione contro
Bidoo, ma ciò non è una garanzia.
**Realtà economica.** Augenblick (2016, *Review of Economic Studies* 83(1)), su 166.000
aste: il margine medio del banco supera il 50% del valore del bene; il 75% dei puntatori
abbandona prima di 50 puntate, l'86% prima di 100. Platt, Price e Tappen (2013) e Hinnosaar
(2016): in ogni equilibrio che non finisce subito, il profitto atteso di ogni puntata è
**zero**. Un vantaggio, se esiste, viene solo dall'irrazionalità altrui (costo affondato,
gusto per il rischio), non dalla struttura del gioco.
Il sistema è quindi **scettico per costruzione**: ogni stima di profittabilità si confronta
con il null-model (ROI per puntata = 0), e l'esito atteso onesto è astenersi dalla grande
maggioranza delle aste. Lo stato *Osserva* è la modalità shadow: registra ogni decisione
che avrebbe preso senza puntare. L'utente ha scelto di non avere una shadow globale
obbligatoria; le aste in *Attiva* puntano davvero, dentro i paletti del gestore del rischio.
---
## 1. Cosa si registra: il database
Due file SQLite in `%LocalAppData%\AutoBidder\Database` (cartella cambiabile dalle
Impostazioni, con spostamento dei file e riavvio; mai una cartella sincronizzata):
- `autobidder.sqlite` — le **osservazioni**: le tabelle qui sotto.
- `esercizio.sqlite` — l'**esercizio**: i documenti JSON dell'applicazione (aste nel
monitor, prodotti seguiti, statistiche per prodotto, promozioni riscattate, modelli
appresi con prefisso `ml/`) e i registri applicativo e del riscatto puntate. I file
JSON delle versioni precedenti vengono importati la prima volta e lasciati dove sono.
Le scritture vanno in coda e un thread dedicato per database le svuota a blocchi in
transazioni: nulla sul percorso della puntata aspetta il disco. Le letture usano una
seconda connessione con giornale WAL. Il motore comune è `SqliteDatabase`.
| tabella | cosa contiene |
|---|---|
| `items` | il prodotto: chiave, titolo, «Compralo Ora», **valore reale V** scelto dall'utente, spedizione |
| `auctions` | una riga per asta, aggiornata dall'apertura alla chiusura: esito, prezzo finale, vincitore, puntate mie e del vincitore, copertura dell'osservazione, rete, motore, serie dei prezzi, puntate per utente, configurazione |
| `bids` | ogni puntata di ogni utente: prezzo dopo, tipo (Auto/Manuale), ora del server, secondi residui quando è arrivata, mia/altrui |
| `polls` | le interrogazioni, coalescenti (quando cambia qualcosa, e almeno una al secondo): prezzo, timer, ultimo puntatore, ping, scarto dell'orologio |
| `resets` | ogni azzeramento del timer con la **profondità del ciclo** (fin dove era sceso) e la sua durata |
| `my_bids` | le nostre puntate: anticipo voluto ed effettivo, ping, andata e ritorno, esito, id della decisione |
| `bot_decisions` | ogni decisione del motore, in shadow e dal vivo: azione, P(vittoria), EV, policy, regime, **reason_detail**, e l'**esito** scritto alla chiusura |
| `network_metrics` | una riga ogni dieci secondi: mediana e p95 del ping, jitter, scarto dell'orologio |
| `bidders` | chi punta: aste, puntate, quota di automatiche, vittorie |
| `sessions`, `risk_ledger`, `meta` | sessioni dell'applicazione; uscite ed entrate in euro per il gestore del rischio; stato HALT |
Lo storico compatto (`CompletedAuctionsStore`), le schede dettagliate (`AuctionDetailStore`),
l'apprendimento, la rigiocata e le esportazioni leggono tutti da qui. I vecchi dossier
JSON Lines restano leggibili (`DossierReader.Read`, `DossierTrainingReader.Read`) per
un'eventuale importazione, ma non vengono più scritti.
**Esiti delle decisioni.** Alla chiusura dell'asta ogni decisione riceve un esito: se il
prezzo non è più salito dopo quell'istante, una puntata lì sarebbe stata l'ultima
(`won`/`would_win`; `missed` se non si è puntato), altrimenti sarebbe stata coperta
(`answered`/`would_be_answered`; `right_skip`). È quello che permette di giudicare le policy
sugli stessi istanti (§5).
---
## 2. Il modello matematico — `Ml/Theory.cs`
**Valore atteso di una puntata** (§3.1 del documento di progetto, nella forma corretta:
la puntata si paga in entrambi i rami):
```
EV = P · (V (p + Δ) fee spedizione c) (1 P) · c_perdita
```
con `V` il valore reale del bene (colonna «Valore reale €» nella scheda Prodotti; se vuota,
il «Compralo Ora»), `Δ = 0,01 €`, `fee ≈ 1 €`, `c` il costo della puntata.
**Copertura «Compralo Ora».** Chi perde può comprare al listino `L` recuperando le puntate
spese come credito: la perdita finale, se si esercita, è `L V` qualunque sia il numero di
puntate. Quindi `c_perdita = c` finché `spese + c ≤ L V`, zero dopo. Con `V = L` la
copertura è totale dal primo centesimo e si punterebbe sempre, a qualunque probabilità: in
shadow è successo (BID con P dell'1%). Per questo la copertura è un'impostazione
(`Copertura «Compralo Ora»`) **e vale solo per i prodotti con «Valore reale €» scritto**:
con V lasciato al listino la puntata si conta persa, come senza copertura.
**Null-model.** Profitto atteso per puntata = 0; numero critico di puntatori `V/c`
(Hinnosaar). **Sopravvivenza empirica:** dalla tabella `resets`, P(vittoria) a un dato
anticipo = quota dei cicli scesi almeno fin lì che hanno chiuso l'asta; curva di
Kaplan-Meier della profondità dei cicli. **Arrivi di Poisson:** `P(nessuna puntata in τ) =
exp(−λτ)` con λ dagli intervalli recenti.
---
## 3. La pipeline di decisione — `Services/BidStrategyService.cs`
Cinque passi, sempre nello stesso ordine, una volta per ciclo del timer, ~60 ms prima
dell'istante di fuoco:
```
RISCHIO ──▶ PALETTI ──▶ DUELLO ──▶ VALORE ATTESO ──▶ REGIME ──▶ punta?
(solo dal (utente) (sessione) (storico + (sessione)
vivo) sessione) │
QUANDO: anticipo adattivo
```
1. **Rischio**`RiskManager.Authorize`: HALT persistente, stop-loss del giorno,
drawdown dal picco, tetto di spesa del giorno, aste in gioco insieme. Lo stop-loss e
il drawdown mettono in HALT, che sopravvive al riavvio e si toglie a mano. La
contabilità è in `risk_ledger`: ogni puntata riuscita un'uscita, ogni vincita
un'entrata al valore reale meno prezzo, fee e spedizione.
2. **Paletti** — fascia oraria sospesa (globale, per prodotto, per asta), tetti di puntate.
**L'interruttore dell'apprendimento** (il robot nella barra del monitor, accanto ad
Avvia/Osserva/Ferma; `LearningEnabled`) taglia qui la pipeline: spento, i passi 35 non
girano e l'anticipo è quello fisso delle impostazioni. Si punta solo entro i limiti
dell'utente — prezzo minimo e massimo, puntate, budget, fascia oraria, rischio — come
un cecchino senza cervello. Le aste vengono comunque registrate e studiate: quando lo
si riaccende il modello non ha perso niente.
3. **Duello** — cinque risposte di fila con la firma dell'autopuntata (6 ± 2,5 s) = macchina
armata dall'altra parte; si smette e si informa il regime.
4. **Valore atteso** — il modello dà P(senza risposta); `Theory` la trasforma in euro.
Il modello parla sempre, decide dopo `LearningMinAuctions` aste apprese.
5. **Regime**`CompetitionRegime`: Calmo / Sfogo / Sondaggio con isteresi e pazienza
che raddoppia a ogni sondaggio coperto. Uscire è facile, rientrare è difficile.
**Quando**`LatencyModel`: anticipo = margine + p99 della latenza di sessione, fra
`LeadMinMs` e `LeadMaxMs`; una puntata tardiva alza il margine subito, venti in tempo lo
abbassano piano. Un anticipo scritto a mano su un'asta vince sempre.
**Shadow.** In *Osserva* il cecchino arriva allo stesso istante e chiama la stessa pipeline
(saltando il gestore del rischio, che riguarda solo i soldi veri), registra la decisione e
non punta. Ogni decisione porta anche il parere del bandit (§4.3) sullo stesso istante.
---
## 4. Cosa impara, e come — `Ml/`
### 4.1 P(senza risposta): campione e sfidante
Due modelli imparano dagli stessi esempi — ogni puntata di ogni asta chiusa, etichettata
«è rimasta l'ultima?» — con le stesse variabili a fasce (`BidFeatures`: durata del ciclo,
quota di autopuntate, puntatori distinti, prezzo sul valore, ora, giorno, ora di apertura,
tipo di puntata, profondità, prodotto in 32 cassetti):
- **campione**: regressione logistica online (`OnlineLogit`, SGD, L2, calibrazione in linea);
- **sfidante**: rete neurale 70→32→16→1 (`NeuralNet`, ReLU, Adam, L2, stessa calibrazione).
A ogni esempio, *prima* di imparare, entrambi prevedono; il quadrato dell'errore va in una
media mobile (20.000 esempi): è il **punteggio di Brier prequenziale** di ciascuno. Risponde
al motore chi ce l'ha più basso; la rete non comanda prima di 5.000 esempi. La scheda
Apprendimento mostra chi risponde e i due punteggi.
Perché non le nostre vittorie come etichetta: sono troppo poche (l'utente non ne ha ancora).
Il comportamento avversario non dipende da chi ha puntato: questo rende utilizzabili i
milioni di puntate altrui.
### 4.2 Profilo per prodotto e ora
`AuctionProfileStats`: puntate del vincitore e prezzo di chiusura in frazione del valore, per
prodotto × fascia oraria × feriale/festivo, con restringimento gerarchico dove i dati
scarseggiano. Alimentato asta per asta.
### 4.3 Bandit contestuale (Thompson)
`ThompsonBandit`: per ogni contesto (ciclo, prezzo/valore, fascia, puntatori, autopuntate)
una Beta(α, β) su P(senza risposta). Decide estraendo p dalla Beta e applicando la regola
del valore atteso: esplora dove ha visto poco, si stringe con i dati. Impara dagli **esiti
delle decisioni** — informazione completa, anche dove non si è puntato. È la seconda policy,
sempre in shadow, confrontata con la prima sugli stessi istanti.
### 4.4 Regime e latenza
Sessione in corso, non storico: `CompetitionRegime` per asta, `LatencyModel` per la rete.
Vedi §3.
### 4.5 Simulatore e agente di rinforzo
`PennyAuctionEnv`: aste sintetiche con quattro profili di avversario (aggressivo, cecchino,
autopuntata a 2 s, principiante), ognuno con credito e **persistenza crescente con lo speso**
(costo affondato, Augenblick). Taratura dal database: puntatori per asta, istogramma della
profondità dei cicli, quota di autopuntate, chiusura mediana in frazione del valore.
`QLearningAgent`: funzione Q su rete neurale, replay delle esperienze, ε-greedy decrescente,
**penalità conservativa** sulle azioni poco viste (nello spirito di CQL). Si addestra solo nel
simulatore: non si esplora con denaro vero.
`SimulationLab` mette a confronto su aste identiche (stessi semi): non punta mai, punta
sempre, valore atteso con P empirica, bandit che impara giocando, agente Q. Il simulatore
non è Bidoo: i numeri assoluti non contano, il confronto sì. È l'unico posto dove si vede
come reagirebbero gli altri a una nostra puntata.
---
## 5. Come si giudica — `ShadowReport`, `LearningEvaluator`, rigiocata
- **Shadow** (`ShadowReport`): sugli istanti di decisione con esito noto, per ogni policy:
puntate, vincenti, spesi, incassati al valore reale, ROI per puntata con estremo inferiore
dell'intervallo al 95%. Il null-model è battuto solo se l'estremo inferiore è sopra zero,
su abbastanza puntate.
- **Prequenziale** (`LearningEvaluator`, «Valuta ora»): addestra sulle aste più vecchie,
giudica sulle più recenti, poi ogni asta prima prevista e poi appresa; sollevamento,
calibrazione per fasce, punteggio di Brier, cosa avrebbe fatto il cancello sulle nostre
puntate vere.
- **Rigiocata** (`BacktestReport`): cosa avrebbe fatto il motore su ogni asta registrata,
anticipo per anticipo; misura i costi, non l'esito.
- **Esportazione**: «Esporta decisioni (CSV)» — punto e virgola, colonna `reason_detail`.
**Criteri di promozione e di stop** (dal documento di progetto): una policy passa dal
shadow al vivo solo con ROI per puntata > 0 ed estremo inferiore > 0 su almeno quattro
settimane, calibrazione buona, drawdown simulato entro il limite. Se dopo un periodo di
vita reale il ROI cumulato non batte il null-model, il sistema resta strumento analitico e
si spegne il vivo. La posizione di default è che un vantaggio consistente sia improbabile.
---
## 6. Dove si vede
Scheda **Apprendimento**: stato e pesi del modello, ultime decisioni, profilo per prodotto,
valutazione, autonomia sul momento (latenza e regime per asta), shadow (policy a confronto,
campione/sfidante, bandit), simulatore. Barra del **monitor**: l'interruttore
dell'apprendimento. Scheda **Impostazioni Gestione del rischio**: paletti, HALT e
contabilità; **Impostazioni Database** e **Manutenzione**: cartella dei database, «Elimina
tutti i dati (tranne la login)», «Impostazioni di fabbrica». Scheda **Prodotti**: colonna
«Valore reale €».
Nel registro di ogni asta: `[SHADOW] Avrei puntato…`, `[REGIME] Calmo → Sfogo…`, `[TIMING]
… anticipo adattivo…`, `⛔ Strategia blocca: …`.
---
## 7. Cosa non c'è, e perché
- **Feed DOM (Playwright)**: gli endpoint HTTP di Bidoo (`data.php`, `get_auction_updates`)
sono verificati e usati da mesi; un browser sarebbe più lento e più fragile.
- **Dataset Swoopo (2008-09)**: sito e anni diversi; il database si riempie da solo.
- **Modelli sequenziali (LSTM/Transformer)**: rimandati; le variabili a fasce con la rete
MLP coprono le interazioni che contano, e i dati per una sequenza profonda non ci sono.
- **Account multipli**: mai.
## 8. Milestone
| | stato |
|---|---|
| M1 rischio, HALT, interruttore dell'apprendimento, shadow per asta | fatto |
| M2 database SQLite (osservazioni + esercizio), registrazione, sessioni, misure di rete | fatto (si parte da zero) |
| M3 teoria, simulatore con avversari tarati, rigiocata | fatto |
| M4 modelli: logistico, rete neurale, profilo, hazard/Kaplan-Meier, Brier | fatto |
| M5 policy: valore atteso + regime, bandit Thompson, agente Q conservativo | fatto (Q solo nel simulatore) |
| M6 valutazione shadow ≥ 4 settimane, confronto fra policy | strumenti pronti; servono le settimane |
| M7 esecuzione e latenza | fatto (anticipo adattivo) |
| M8 monitoraggio e riaddestramento | scheda pronta; riaddestramento a ogni asta chiusa; deriva: da fare |
+221
View File
@@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using AutoBidder.Utilities;
namespace AutoBidder.Ml
{
/// <summary>
/// Decide da solo l'anticipo con cui parte la puntata, a partire dalla rete di questa
/// sessione — entro i paletti dell'utente.
///
/// <para><b>Il problema.</b> L'anticipo giusto dipende da quanto ci mette la puntata ad
/// arrivare al server, e questo cambia da macchina a macchina, da rete a rete, da ora a
/// ora. Un numero fisso è sbagliato quasi sempre: troppo largo regala secondi agli
/// avversari, troppo stretto fa arrivare la puntata a giochi chiusi. Nei registri erano
/// entrambe le cose, in giorni diversi.</para>
///
/// <para><b>La regola.</b> L'anticipo è un margine di sicurezza più la coda alta della
/// latenza misurata: <c>margine + p99(andata e ritorno)</c>. La coda, non la media —
/// una puntata su cento che arriva tardi costa un'asta intera, e la media non la vede.
/// Il margine copre l'elaborazione del server e l'arrotondamento al secondo con cui
/// Bidoo dichiara la scadenza.</para>
///
/// <para><b>Come impara.</b> Da tre cose che passano di qui comunque: il ping di ogni
/// interrogazione, l'andata e ritorno di ogni puntata, e l'esito «arrivata troppo
/// tardi». Una puntata tardiva alza il margine subito e di molto; una lunga serie di
/// puntate in tempo lo abbassa piano. Asimmetrico apposta: sbagliare per eccesso costa
/// qualche puntata in più, sbagliare per difetto costa l'asta.</para>
///
/// <para><b>I paletti.</b> L'utente fissa minimo e massimo; il modello si muove solo lì
/// dentro. Un anticipo fissato a mano su una singola asta vince sempre.</para>
///
/// <para>Lo stato si salva fra una sessione e l'altra: la rete di casa è quasi sempre
/// la stessa, e ripartire da zero ogni volta significherebbe pagare la prima puntata
/// tardiva ogni giorno.</para>
/// </summary>
public static class LatencyModel
{
private const int Window = 300;
private static readonly object Sync = new();
private static readonly Queue<int> _pings = new();
private static readonly Queue<int> _roundTrips = new();
/// <summary>Margine oltre la coda della latenza. Parte da 300 ms: elaborazione del server più arrotondamento al secondo.</summary>
private static int _marginMs = 300;
private static int _bidsSinceLastLate;
private static int _lateBids;
private static int _bidsTotal;
private static bool _loaded;
private const string DocKey = "ml/latenza";
// ── Alimentazione ────────────────────────────────────────────────
public static void NotePing(int ms)
{
if (ms <= 0 || ms > 10000) return;
lock (Sync)
{
_pings.Enqueue(ms);
while (_pings.Count > Window) _pings.Dequeue();
}
}
/// <summary>Una puntata è stata inviata: quanto ci ha messo, e se è arrivata tardi.</summary>
public static void NoteBid(int roundTripMs, bool late)
{
lock (Sync)
{
if (roundTripMs > 0 && roundTripMs < 10000)
{
_roundTrips.Enqueue(roundTripMs);
while (_roundTrips.Count > Window) _roundTrips.Dequeue();
}
_bidsTotal++;
if (late)
{
// Una sola puntata tardiva vale un'asta persa: si sale subito e di
// molto, poi si riscende piano se non ricapita.
_lateBids++;
_bidsSinceLastLate = 0;
_marginMs = Math.Min(2000, _marginMs + 150);
}
else
{
_bidsSinceLastLate++;
// Venti puntate in tempo di fila: si prova a stringere di un pelo.
if (_bidsSinceLastLate % 20 == 0) _marginMs = Math.Max(150, _marginMs - 25);
}
}
Save();
}
// ── Risposta ─────────────────────────────────────────────────────
/// <summary>L'anticipo consigliato adesso, dentro i paletti delle impostazioni.</summary>
public static int RecommendedLeadMs(AppSettings settings)
{
Load();
var min = Math.Max(100, settings.LeadMinMs);
var max = Math.Max(min, settings.LeadMaxMs);
lock (Sync)
{
// La coda della latenza: l'andata e ritorno delle puntate se ne abbiamo
// abbastanza, altrimenti il ping delle interrogazioni, che è la stessa
// strada. Senza campioni si usa il predefinito dell'utente.
var coda = _roundTrips.Count >= 10 ? Percentile(_roundTrips, 0.99)
: _pings.Count >= 10 ? Percentile(_pings, 0.99)
: -1;
if (coda < 0) return Math.Clamp(settings.DefaultBidBeforeDeadlineMs, min, max);
return Math.Clamp(_marginMs + coda, min, max);
}
}
/// <summary>Numeri per la scheda e il registro.</summary>
public static (int Samples, int P50, int P99, int MarginMs, int LateBids, int Bids) Snapshot()
{
Load();
lock (Sync)
{
var src = _roundTrips.Count >= 10 ? _roundTrips : _pings;
return (src.Count,
src.Count > 0 ? Percentile(src, 0.50) : 0,
src.Count > 0 ? Percentile(src, 0.99) : 0,
_marginMs, _lateBids, _bidsTotal);
}
}
private static int Percentile(Queue<int> q, double p)
{
var a = q.ToArray();
Array.Sort(a);
var i = (int)Math.Ceiling(p * a.Length) - 1;
return a[Math.Clamp(i, 0, a.Length - 1)];
}
// ── Persistenza ──────────────────────────────────────────────────
private sealed class Snap
{
public int MarginMs { get; set; }
public int LateBids { get; set; }
public int Bids { get; set; }
public int[] RoundTrips { get; set; } = Array.Empty<int>();
}
private static void Load()
{
if (_loaded) return;
lock (Sync)
{
if (_loaded) return;
_loaded = true;
try
{
var json = Data.OperationalDatabase.Instance.GetDocumentOrImport(DocKey, Path.Combine(AppPaths.LegacyLearningFolder, "latenza.json"));
if (json == null) return;
var s = JsonSerializer.Deserialize<Snap>(json);
if (s == null) return;
_marginMs = Math.Clamp(s.MarginMs, 150, 2000);
_lateBids = s.LateBids;
_bidsTotal = s.Bids;
foreach (var r in s.RoundTrips) _roundTrips.Enqueue(r);
}
catch { /* si riparte dai predefiniti */ }
}
}
private static void Save()
{
try
{
string json;
lock (Sync)
{
json = JsonSerializer.Serialize(new Snap
{
MarginMs = _marginMs,
LateBids = _lateBids,
Bids = _bidsTotal,
RoundTrips = _roundTrips.ToArray()
});
}
Data.OperationalDatabase.Instance.SetDocument(DocKey, json);
}
catch { /* la prossima puntata riproverà */ }
}
/// <summary>Dimentica le misure, in memoria e su disco: si riparte dai predefiniti.</summary>
public static void Forget()
{
ResetForTests();
try { Data.OperationalDatabase.Instance.DeleteDocument(DocKey); } catch { }
}
/// <summary>Solo per i test.</summary>
public static void ResetForTests(int marginMs = 300)
{
lock (Sync)
{
_pings.Clear();
_roundTrips.Clear();
_marginMs = marginMs;
_bidsSinceLastLate = 0;
_lateBids = 0;
_bidsTotal = 0;
_loaded = true;
}
}
}
}
+226
View File
@@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using AutoBidder.Data;
namespace AutoBidder.Ml
{
/// <summary>
/// Valuta il modello sulle aste vere del database, con una divisione onesta nel tempo:
/// si impara dalle più vecchie e si giudica sulle più recenti, mai viste. Poi, sulle
/// recenti, la valutazione <b>prequenziale</b>: ogni asta prima prevista e poi appresa,
/// in ordine di tempo, che è esattamente la sequenza del motore in produzione.
///
/// <para>Sta in una classe a sé perché la usano in due: il test da riga di comando
/// (target <c>Apprendimento</c>) e la scheda nell'applicazione.</para>
///
/// <para><b>Cosa misura.</b> Quanto il modello separa le puntate rimaste senza risposta
/// (sollevamento nella cima della classifica), se la probabilità è nella scala giusta
/// (calibrazione per fasce, punteggio di Brier), e sulle nostre puntate vere cosa
/// avrebbe fatto il cancello del valore atteso.</para>
/// </summary>
public static class LearningEvaluator
{
public sealed class Report
{
public string Text { get; init; } = "";
public int Auctions { get; init; }
public int Usable { get; init; }
public double Lift5 { get; init; }
public double Brier { get; init; }
public double CalibrationFactor { get; init; }
public int MyBids { get; init; }
public int MyBlocked { get; init; }
public int MyBlockedWinning { get; init; }
public bool Enough => Usable >= 20;
}
private sealed record Row(double P, bool Unanswered, bool Mine, double Ev);
public static Report Run(AuctionDatabase db, int maxAuctions, string? me, Action<string>? progress = null, CancellationToken ct = default)
{
db.Flush();
var ids = DossierTrainingReader.ClosedAuctionIds(db, maxAuctions);
var sessions = new List<DossierTrainingReader.Session>();
var letti = 0;
foreach (var id in ids)
{
ct.ThrowIfCancellationRequested();
var s = DossierTrainingReader.FromDatabase(db, id, me);
if (s != null && s.IsUsable) sessions.Add(s);
if (++letti % 200 == 0) progress?.Invoke($"lette {letti:N0} aste su {ids.Count:N0}…");
}
return Evaluate(sessions, ids.Count, progress, ct);
}
/// <summary>La valutazione vera e propria, su sessioni già in memoria (anche per i test).</summary>
public static Report Evaluate(List<DossierTrainingReader.Session> sessions, int totalAuctions, Action<string>? progress = null, CancellationToken ct = default)
{
var report = new StringBuilder();
report.AppendLine($"Aste chiuse: {totalAuctions:N0}, utilizzabili: {sessions.Count:N0}");
if (sessions.Count < 20)
{
report.AppendLine("Troppo poche aste utilizzabili per una valutazione.");
return new Report { Text = report.ToString(), Auctions = totalAuctions, Usable = sessions.Count };
}
progress?.Invoke("addestramento e confronto delle configurazioni…");
var split = (int)(sessions.Count * 0.7);
var train = sessions.Take(split).ToList();
var test = sessions.Skip(split).ToList();
var trainX = train.SelectMany(s => DossierTrainingReader.Examples(s))
.Select(e => (X: BidFeatures.Build(e.Context), Y: e.Unanswered)).ToList();
var perAsta = test.Select(s => DossierTrainingReader.Examples(s)
.Select(e => (
X: BidFeatures.Build(e.Context), Y: e.Unanswered, Mine: e.Mine,
Margine: s.BuyNowPrice is > 0 ? s.BuyNowPrice.Value - e.Context.AuctionDepthCents / 100.0 - 0.01 : double.NaN))
.ToList()).ToList();
var testX = perAsta.SelectMany(a => a).ToList();
var n = testX.Count;
var positives = testX.Count(r => r.Y);
var baseRate = (double)positives / Math.Max(1, n);
report.AppendLine($"Addestramento: {train.Count:N0} aste, {trainX.Count:N0} puntate");
report.AppendLine($"Valutazione : {test.Count:N0} aste, {n:N0} puntate, {positives:N0} senza risposta ({baseRate:P3})");
report.AppendLine();
// ── Confronto fra configurazioni (modello congelato) ─────────────
const double costo = 0.20;
var configs = new List<(string Nome, double Lr, double L2)>();
foreach (var lr in new[] { 0.01, 0.003 })
foreach (var l2 in new[] { 1e-4, 0.0 })
configs.Add(($"passo {lr:0.000}, L2 {l2:0.######}", lr, l2));
report.AppendLine("Configurazione (modello congelato) | sollev. 5% | prevista/osservata (cima 10%) | prevista/osservata (tutte)");
report.AppendLine("-------------------------------------+------------+-------------------------------+---------------------------");
OnlineLogit? best = null; var bestName = ""; var bestScore = double.MaxValue;
foreach (var (nome, lr, l2) in configs)
{
ct.ThrowIfCancellationRequested();
var modello = new OnlineLogit(BidFeatures.Size) { LearningRate = lr, L2 = l2 };
foreach (var (x, y) in trainX) modello.Update(x, y);
var ps = testX.Select(r => modello.Predict(r.X)).ToArray();
var order = Enumerable.Range(0, n).OrderByDescending(i => ps[i]).ToArray();
var k5 = Math.Max(1, n / 20); var k10 = Math.Max(1, n / 10);
var lift5 = order.Take(k5).Count(i => testX[i].Y) / (double)k5 / Math.Max(1e-9, baseRate);
var top10 = order.Take(k10).ToArray();
var ratioTop = top10.Average(i => ps[i]) / Math.Max(1e-9, top10.Average(i => testX[i].Y ? 1.0 : 0.0));
var ratioAll = ps.Average() / Math.Max(1e-9, baseRate);
report.AppendLine($"{nome,-37} | {lift5,9:N2}x | {ratioTop,29:N2} | {ratioAll,25:N2}");
var score = Math.Abs(Math.Log(Math.Max(1e-9, ratioTop))) + Math.Abs(Math.Log(Math.Max(1e-9, ratioAll)));
if (lift5 > 2.0 && score < bestScore) { bestScore = score; best = modello; bestName = nome; }
}
report.AppendLine();
if (best == null)
{
report.AppendLine("Nessuna configurazione separa abbastanza (sollevamento al 5% > 2x).");
return new Report { Text = report.ToString(), Auctions = totalAuctions, Usable = sessions.Count };
}
report.AppendLine($"Scelta: {bestName}");
report.AppendLine();
// ── Prequenziale: come nell'uso vero ─────────────────────────────
progress?.Invoke("valutazione prequenziale…");
var model = best;
var rows = new List<Row>();
foreach (var esempi in perAsta)
{
ct.ThrowIfCancellationRequested();
foreach (var r in esempi)
{
var p = model.PredictCalibrated(r.X);
rows.Add(new Row(p, r.Y, r.Mine, double.IsNaN(r.Margine) ? double.NaN : p * r.Margine - costo));
}
foreach (var r in esempi) model.Update(r.X, r.Y);
}
report.AppendLine($"Valutazione prequenziale (ogni asta prima prevista, poi appresa), fattore di calibrazione finale {model.CalibrationFactor:N2}:");
var meanPos = rows.Where(r => r.Unanswered).Select(r => r.P).DefaultIfEmpty(0).Average();
var meanNeg = rows.Where(r => !r.Unanswered).Select(r => r.P).DefaultIfEmpty(0).Average();
report.AppendLine($"P media sulle puntate senza risposta : {meanPos:P3}");
report.AppendLine($"P media sulle altre : {meanNeg:P3}");
report.AppendLine($"rapporto : {meanPos / Math.Max(meanNeg, 1e-9):N2}x");
// Il punteggio di Brier: media del quadrato dell'errore fra probabilità e
// esito. È la misura onesta di una probabilità: premia chi è calibrato E
// discrimina. Si confronta con quello del tasso di base, che è il minimo
// ottenibile senza guardare nulla.
var brier = rows.Average(r => Math.Pow(r.P - (r.Unanswered ? 1 : 0), 2));
var brierBase = rows.Average(r => Math.Pow(baseRate - (r.Unanswered ? 1 : 0), 2));
report.AppendLine($"punteggio di Brier : {brier:F6} (tasso di base: {brierBase:F6}, miglioramento {(1 - brier / Math.Max(1e-12, brierBase)):P1})");
report.AppendLine();
var sorted = rows.OrderByDescending(r => r.P).ToList();
report.AppendLine("Sollevamento nella cima della classifica (precisione / tasso di base):");
double liftTop5 = 0;
foreach (var frac in new[] { 0.01, 0.05, 0.10, 0.25 })
{
var k = Math.Max(1, (int)(n * frac));
var prec = sorted.Take(k).Count(r => r.Unanswered) / (double)k;
var lift = prec / Math.Max(1e-9, baseRate);
if (frac == 0.05) liftTop5 = lift;
report.AppendLine($" cima {frac:P0}: {k,8:N0} puntate, precisione {prec:P2}, sollevamento {lift:N2}x");
}
report.AppendLine();
report.AppendLine("Calibrazione per fasce di probabilità prevista:");
var edges = new[] { 0.0, 0.001, 0.0025, 0.005, 0.01, 0.02, 0.05, 1.0 };
for (var i = 0; i < edges.Length - 1; i++)
{
var bin = rows.Where(r => r.P >= edges[i] && r.P < edges[i + 1]).ToList();
if (bin.Count == 0) continue;
report.AppendLine($" P in [{edges[i]:P2}, {edges[i + 1]:P2}): {bin.Count,8:N0} puntate, prevista {bin.Average(r => r.P):P3}, osservata {bin.Average(r => r.Unanswered ? 1.0 : 0.0):P3}");
}
report.AppendLine();
var mie = rows.Where(r => r.Mine && !double.IsNaN(r.Ev)).ToList();
var fermate = mie.Where(r => r.Ev < 0).ToList();
if (mie.Count > 0)
{
var passate = mie.Where(r => r.Ev >= 0).ToList();
report.AppendLine($"Le mie puntate nel periodo di valutazione: {mie.Count:N0}, di cui finali {mie.Count(r => r.Unanswered):N0}");
report.AppendLine($" il cancello ne avrebbe lasciate passare {passate.Count:N0} (finali: {passate.Count(r => r.Unanswered):N0})");
report.AppendLine($" e ne avrebbe fermate {fermate.Count:N0} (di cui finali, cioè vincenti perse: {fermate.Count(r => r.Unanswered):N0})");
}
else
{
report.AppendLine("Nessuna mia puntata nel periodo di valutazione (serve il nome utente, o non ci sono).");
}
report.AppendLine();
report.AppendLine("Pesi più forti (cosa ha imparato):");
foreach (var (nome, peso) in BidFeatures.Names.Select((nm, i) => (nm, model.Weights[i])).OrderByDescending(t => Math.Abs(t.Item2)).Take(15))
report.AppendLine($" {peso,+8:F3} {nome}");
return new Report
{
Text = report.ToString(),
Auctions = totalAuctions,
Usable = sessions.Count,
Lift5 = liftTop5,
Brier = brier,
CalibrationFactor = model.CalibrationFactor,
MyBids = mie.Count,
MyBlocked = fermate.Count,
MyBlockedWinning = fermate.Count(r => r.Unanswered)
};
}
}
}
+701
View File
@@ -0,0 +1,701 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Ml
{
/// <summary>
/// L'apprendimento, sempre acceso: impara da ogni asta che chiude e risponde al motore
/// quando deve decidere.
///
/// <para><b>Due cose imparate, per due domande diverse.</b> Il <i>modello delle
/// puntate</i> risponde a «se punto adesso, qualcuno risponderà?»: una regressione
/// logistica su ciò che si vede nell'istante della puntata. Il <i>profilo delle aste</i>
/// risponde a «quanto costa di solito vincere questo prodotto a quest'ora?»: medie per
/// prodotto, fascia oraria e tipo di giorno, con restringimento dove i dati scarseggiano.</para>
///
/// <para><b>Da dove impara.</b> Dal database. Ogni puntata di ogni asta chiusa è un
/// esempio: rimasta senza risposta oppure no. Non dalle nostre vittorie, che sono
/// troppo poche: il comportamento avversario non dipende da chi ha puntato, e questo
/// rende utilizzabili le puntate altrui.</para>
///
/// <para><b>Quando impara.</b> Alla chiusura di ogni asta, subito. All'avvio, in
/// sottofondo e con un tempo massimo, ripassa le aste chiuse che non risultano ancora
/// apprese (colonna <c>learned_at</c>): capita dopo un riaddestramento da capo o se
/// l'applicazione era stata chiusa nel mezzo.</para>
///
/// <para><b>Cosa NON può dire.</b> Il modello impara come finiscono le aste senza di
/// noi. La nostra puntata cambia il gioco: chi stava per smettere può rispondere.
/// Nessun dato storico dice questo.</para>
/// </summary>
public static class LearningService
{
private static readonly object Sync = new();
// I salvataggi hanno un lucchetto tutto loro: all'avvio più aste concluse nel
// frattempo vengono chiuse insieme, ognuna su un proprio thread.
private static readonly object SaveSync = new();
private static OnlineLogit _model = new(BidFeatures.Size);
private static NeuralNet _challenger = NewChallenger();
private static ThompsonBandit _bandit = new();
private static AuctionProfileStats _profile = new();
private static int _auctionsLearned;
private static bool _loaded;
private static volatile bool _bootstrapRunning;
// ── Campione e sfidante ──────────────────────────────────────────
//
// Il modello logistico (campione) e la rete neurale (sfidante) imparano dagli
// stessi esempi. A ogni esempio, PRIMA di imparare, entrambi prevedono: il
// quadrato dell'errore va in una media mobile, che è il punteggio di Brier
// prequenziale di ciascuno. Chi ce l'ha più basso risponde al motore. La rete
// non comanda mai prima di qualche migliaio di esempi: all'inizio direbbe cose
// a caso.
private static double _brierLogit = double.NaN;
private static double _brierNet = double.NaN;
private static long _brierSamples;
private const double BrierWindow = 20_000;
private const long ChallengerMinSamples = 5_000;
private static NeuralNet NewChallenger() =>
new(new[] { BidFeatures.Size, 32, 16, 1 }, seed: 42) { LearningRate = 0.001, L2 = 1e-5 };
/// <summary>La rete neurale ha un Brier prequenziale più basso del logistico: risponde lei.</summary>
public static bool ChallengerLeads
{
get { lock (Sync) return _brierSamples >= ChallengerMinSamples && !double.IsNaN(_brierNet) && _brierNet < _brierLogit; }
}
public static (double Logit, double Net, long Samples) BrierScores
{
get { lock (Sync) return (_brierLogit, _brierNet, _brierSamples); }
}
public static long ChallengerUpdates { get { lock (Sync) return _challenger.Updates; } }
/// <summary>Il bandit Thompson: la seconda policy, giudicata in shadow sugli stessi istanti.</summary>
public static ThompsonBandit Bandit => _bandit;
/// <summary>Righe di registro sull'apprendimento: chi si aggancia le scrive dove vuole.</summary>
public static event Action<string>? OnLog;
private static AuctionDatabase Db => AuctionDatabase.Instance;
// ── Percorsi ─────────────────────────────────────────────────────
// Tutto ciò che il modello sa vive nel database d'esercizio, come documenti con
// prefisso «ml/». I file della cartella Apprendimento delle versioni precedenti
// vengono importati la prima volta.
private const string DocModel = "ml/modello-puntate";
private const string DocProfile = "ml/profilo-aste";
private const string DocChallenger = "ml/sfidante-rete";
private const string DocBandit = "ml/bandit";
private const string DocBrier = "ml/brier";
private const string DocCounter = "ml/aste-apprese";
private const string DocEvaluation = "ml/valutazione";
private static OperationalDatabase Docs => OperationalDatabase.Instance;
private static string? Doc(string key, string legacyFileName) =>
Docs.GetDocumentOrImport(key, Path.Combine(AppPaths.LegacyLearningFolder, legacyFileName));
// ── Stato, per l'interfaccia e il registro ───────────────────────
public static long ModelUpdates { get { lock (Sync) return _model.Updates; } }
public static double CalibrationFactor { get { lock (Sync) return _model.CalibrationFactor; } }
public static int AuctionsLearned { get { lock (Sync) return _auctionsLearned; } }
public static long ProfileAuctions { get { lock (Sync) return _profile.AuctionsObserved; } }
public static bool BootstrapRunning => _bootstrapRunning;
/// <summary>Versione del modello, per marcare le decisioni: chi risponde e quanti aggiornamenti ha.</summary>
public static string ModelVersion
{
get
{
lock (Sync)
{
var netLeads = _brierSamples >= ChallengerMinSamples && !double.IsNaN(_brierNet) && _brierNet < _brierLogit;
return netLeads ? $"mlp-{BidFeatures.Size}-{_challenger.Updates}" : $"logit-{BidFeatures.Size}-{_model.Updates}";
}
}
}
/// <summary>
/// Abbastanza aste apprese perché il cancello sul valore atteso possa fermare una
/// puntata. Prima di questa soglia il modello parla, ma non decide.
/// </summary>
public static bool IsReady(AppSettings settings) =>
AuctionsLearned >= Math.Max(1, settings.LearningMinAuctions);
/// <summary>I pesi con i loro nomi, ordinati per peso: per guardare cosa ha imparato.</summary>
public static IReadOnlyList<(string Nome, double Peso)> Pesi()
{
lock (Sync)
{
return BidFeatures.Names
.Select((n, i) => (n, _model.Weights[i]))
.OrderByDescending(t => Math.Abs(t.Item2))
.ToList();
}
}
/// <summary>Il profilo per prodotto e fascia, dalla combinazione più numerosa.</summary>
public static IReadOnlyList<AuctionProfileStats.Row> ProfileRows(int max = 200)
{
lock (Sync) return _profile.Rows(max);
}
// ── Ultime decisioni, per la scheda ──────────────────────────────
/// <summary>Una risposta data al motore.</summary>
public readonly record struct Decision(
DateTime At, string Auction, double Price, double Probability, double ExpectedValue, bool Blocked, bool Ready);
private const int MaxDecisions = 300;
private static readonly Queue<Decision> _decisions = new();
public static void RecordDecision(string auction, double price, double probability, double expectedValue, bool blocked, bool ready)
{
lock (_decisions)
{
if (_decisions.Count > 0)
{
var last = _decisions.Last();
if (last.Auction == auction && last.Blocked == blocked &&
Math.Abs(last.Price - price) < 0.005 && (DateTime.Now - last.At).TotalSeconds < 2)
return;
}
_decisions.Enqueue(new Decision(DateTime.Now, auction, price, probability, expectedValue, blocked, ready));
while (_decisions.Count > MaxDecisions) _decisions.Dequeue();
}
}
public static IReadOnlyList<Decision> RecentDecisions()
{
lock (_decisions) return _decisions.Reverse().ToList();
}
// ── Valutazione dall'applicazione ────────────────────────────────
/// <summary>L'ultimo rapporto di valutazione salvato, se c'è.</summary>
public static string? LastEvaluation()
{
try { return Doc(DocEvaluation, "valutazione.txt"); }
catch { return null; }
}
/// <summary>
/// Valuta il modello sulle aste del database, in sottofondo, e salva il rapporto.
/// È la stessa valutazione del target da riga di comando.
/// </summary>
public static Task<LearningEvaluator.Report> EvaluateAsync(int maxAuctions, Action<string>? progress, System.Threading.CancellationToken ct)
{
return Task.Run(() =>
{
var me = AuctionRecorder.CurrentUsername;
var report = LearningEvaluator.Run(Db, maxAuctions, string.IsNullOrEmpty(me) ? null : me, progress, ct);
try
{
Docs.SetDocument(DocEvaluation, $"Valutazione del {DateTime.Now:dd/MM/yyyy HH:mm}\n\n" + report.Text);
}
catch { /* il rapporto e' comunque restituito */ }
return report;
}, ct);
}
// ── Ricominciare da capo ─────────────────────────────────────────
/// <summary>
/// Butta modello e profilo, smarca tutte le aste come non apprese e ricomincia a
/// studiarle dal database. Serve quando si cambiano le variabili o si sospetta che
/// il modello abbia imparato da dati sbagliati.
/// </summary>
public static Task RetrainFromScratchAsync(AppSettings settings)
{
Forget();
OnLog?.Invoke("[APPRENDIMENTO] Ricomincio da capo: profilo dallo storico, poi tutte le aste chiuse.");
BootstrapProfileFromHistory();
// Un riaddestramento voluto ha diritto a tutto il tempo che serve.
var generose = new AppSettings { LearningBootstrapSecondsPerStart = Math.Max(600, settings.LearningBootstrapSecondsPerStart) };
return Task.Run(() => BootstrapAsync(generose));
}
/// <summary>
/// Dimentica tutto: modello, profilo, contatore, decisioni e valutazione — in
/// memoria e su disco — e smarca le aste come non apprese. Senza, il modello
/// ancora vivo riscriverebbe il file al primo salvataggio.
/// </summary>
public static void Forget()
{
lock (Sync) _closedSeen.Clear();
lock (Sync)
{
_model = new OnlineLogit(BidFeatures.Size);
_challenger = NewChallenger();
_bandit = new ThompsonBandit();
_profile = new AuctionProfileStats();
_auctionsLearned = 0;
_brierLogit = double.NaN; _brierNet = double.NaN; _brierSamples = 0;
}
lock (_decisions) _decisions.Clear();
try
{
lock (SaveSync) Docs.DeleteDocuments("ml/");
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Archivio non cancellato del tutto: {ex.Message}");
}
try { Db.ExecuteNow("UPDATE auctions SET learned_at = NULL"); }
catch (Exception ex) { OnLog?.Invoke($"[APPRENDIMENTO] Aste non smarcate: {ex.Message}"); }
}
// ── Avvio ────────────────────────────────────────────────────────
public static void Start(AppSettings settings)
{
Load();
BootstrapProfileFromHistory();
_ = Task.Run(() => BootstrapAsync(settings));
}
private static void Load()
{
lock (Sync)
{
if (_loaded) return;
_loaded = true;
try
{
if (Doc(DocModel, "modello-puntate.json") is { } model)
_model = OnlineLogit.FromJson(model, BidFeatures.Size);
if (Doc(DocProfile, "profilo-aste.json") is { } profile)
_profile = AuctionProfileStats.FromJson(profile);
if (Doc(DocChallenger, "sfidante-rete.json") is { } challenger)
_challenger = NeuralNet.FromJson(challenger, new[] { BidFeatures.Size, 32, 16, 1 });
if (Doc(DocBandit, "bandit.json") is { } bandit)
_bandit = ThompsonBandit.FromJson(bandit);
if (Doc(DocBrier, "brier.txt") is { } brierText)
{
var parts = brierText.Split(';');
if (parts.Length >= 3 &&
double.TryParse(parts[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var bl) &&
double.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var bn) &&
long.TryParse(parts[2], out var bs))
{ _brierLogit = bl; _brierNet = bn; _brierSamples = bs; }
}
_auctionsLearned = ReadCounter();
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Archivio illeggibile, si riparte da zero: {ex.Message}");
_model = new OnlineLogit(BidFeatures.Size);
_profile = new AuctionProfileStats();
_auctionsLearned = 0;
}
}
OnLog?.Invoke($"[APPRENDIMENTO] Modello: {ModelUpdates:N0} aggiornamenti da {AuctionsLearned:N0} aste; " +
$"profilo: {ProfileAuctions:N0} aste");
}
/// <summary>
/// Il contatore delle aste apprese. Il file nuovo porta solo il numero; quello
/// delle versioni precedenti lo portava in testa all'elenco dei dossier letti, e
/// si legge ancora per non azzerare un modello che ha già studiato migliaia di aste.
/// </summary>
private static int ReadCounter()
{
// Il contatore nuovo, oppure quello delle versioni precedenti, che stava in
// testa all'elenco dei dossier letti: non si azzera un modello che ha già
// studiato migliaia di aste.
var text = Doc(DocCounter, "aste-apprese.txt");
if (text == null)
{
try
{
var legacy = Path.Combine(AppPaths.LegacyLearningFolder, "dossier-appresi.txt");
if (File.Exists(legacy)) text = File.ReadAllText(legacy);
}
catch { }
}
if (string.IsNullOrEmpty(text)) return 0;
foreach (var line in text.Split('\n'))
{
var l = line.Trim();
if (l.StartsWith("#appresi=", StringComparison.Ordinal) && int.TryParse(l["#appresi=".Length..], out var n)) return n;
if (int.TryParse(l, out var plain)) return plain;
}
return 0;
}
private static void Save()
{
try
{
string model, profile, challenger, bandit, brier;
int learned;
lock (Sync)
{
model = _model.ToJson();
profile = _profile.ToJson();
challenger = _challenger.ToJson();
bandit = _bandit.ToJson();
brier = string.Join(';',
_brierLogit.ToString("R", System.Globalization.CultureInfo.InvariantCulture),
_brierNet.ToString("R", System.Globalization.CultureInfo.InvariantCulture),
_brierSamples);
learned = _auctionsLearned;
}
lock (SaveSync)
{
Docs.SetDocument(DocModel, model);
Docs.SetDocument(DocProfile, profile);
Docs.SetDocument(DocChallenger, challenger);
Docs.SetDocument(DocBandit, bandit);
Docs.SetDocument(DocBrier, brier);
Docs.SetDocument(DocCounter, "#appresi=" + learned);
}
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Salvataggio non riuscito: {ex.Message}");
}
}
/// <summary>
/// Il profilo parte dallo storico compatto: piccolo, immediato, e con le puntate
/// del vincitore già passate dal controllo di coerenza. Solo se il profilo è vuoto.
/// </summary>
private static void BootstrapProfileFromHistory()
{
lock (Sync) { if (_profile.AuctionsObserved > 0) return; }
try
{
var records = CompletedAuctionsStore.LoadAll();
if (records.Count == 0) return;
var fresh = new AuctionProfileStats();
foreach (var r in records)
fresh.Observe(r.ProductKey, r.EndedAt.ToLocalTime(),
AuctionIntegrity.TrustedWinnerBids(r), Ratio(r.FinalPrice, r.BuyNowPrice));
lock (Sync) _profile = fresh;
Save();
OnLog?.Invoke($"[APPRENDIMENTO] Profilo delle aste costruito dallo storico: {records.Count:N0} aste");
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Profilo dallo storico non riuscito: {ex.Message}");
}
}
/// <summary>
/// Studia le aste chiuse non ancora apprese, dalla più recente, finché c'è tempo.
/// </summary>
private static async Task BootstrapAsync(AppSettings settings)
{
if (_bootstrapRunning) return;
_bootstrapRunning = true;
try
{
await Task.Delay(3000).ConfigureAwait(false); // prima l'avvio, poi lo studio
List<string> pending;
try
{
Db.Flush();
pending = Db.Query("SELECT auction_id FROM auctions WHERE outcome IS NOT NULL AND learned_at IS NULL ORDER BY end_time DESC")
.Select(r => r.Str("auction_id")).ToList();
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Elenco delle aste da studiare non disponibile: {ex.Message}");
return;
}
if (pending.Count == 0) return;
var budget = TimeSpan.FromSeconds(Math.Max(10, settings.LearningBootstrapSecondsPerStart));
var sw = Stopwatch.StartNew();
var done = 0;
var usable = 0;
OnLog?.Invoke($"[APPRENDIMENTO] {pending.Count:N0} aste da studiare, al massimo {budget.TotalSeconds:N0} s per questo avvio");
foreach (var id in pending)
{
if (sw.Elapsed > budget) break;
if (IngestAuction(id)) usable++;
done++;
if (done % 100 == 0)
{
Save();
OnLog?.Invoke($"[APPRENDIMENTO] {done:N0} aste studiate, {usable:N0} utilizzabili, {ModelUpdates:N0} aggiornamenti");
}
await Task.Yield(); // il motore delle aste ha la precedenza
}
Save();
var remaining = pending.Count - done;
OnLog?.Invoke($"[APPRENDIMENTO] Studiate {done:N0} aste ({usable:N0} utilizzabili) in {sw.Elapsed.TotalSeconds:N0} s; " +
(remaining > 0 ? $"ne restano {remaining:N0} per i prossimi avvii." : "archivio completo."));
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Studio delle aste interrotto: {ex.Message}");
}
finally
{
_bootstrapRunning = false;
}
}
/// <summary>
/// Impara da un'asta del database e la marca come appresa. Restituisce true se era
/// utilizzabile. Un'asta studiata si marca anche se non era utilizzabile:
/// ristudiarla non la renderebbe tale.
/// </summary>
private static bool IngestAuction(string auctionId)
{
var session = DossierTrainingReader.FromDatabase(Db, auctionId, AuctionRecorder.CurrentUsername);
var usable = false;
lock (Sync)
{
if (session != null && session.IsUsable)
{
foreach (var (ctx, unanswered, _) in DossierTrainingReader.Examples(session))
{
var x = BidFeatures.Build(ctx);
// Prima si prevede, poi si impara: è la valutazione prequenziale
// fatta dal vivo, e decide chi dei due risponde al motore.
var y = unanswered ? 1.0 : 0.0;
var eL = _model.PredictCalibrated(x) - y;
var eN = _challenger.PredictCalibrated(x) - y;
var k = 1.0 / Math.Min(BrierWindow, _brierSamples + 1);
_brierLogit = double.IsNaN(_brierLogit) ? eL * eL : _brierLogit + k * (eL * eL - _brierLogit);
_brierNet = double.IsNaN(_brierNet) ? eN * eN : _brierNet + k * (eN * eN - _brierNet);
_brierSamples++;
_model.Update(x, unanswered);
_challenger.Update(x, unanswered);
}
_auctionsLearned++;
usable = true;
}
}
Db.Enqueue("UPDATE auctions SET learned_at = ?2 WHERE auction_id = ?1", auctionId, AuctionDatabase.Now());
return usable;
}
/// <summary>
/// Il bandit impara dalle decisioni di quest'asta, ora che hanno un esito: per
/// ogni istante di decisione si sa se una puntata lì sarebbe stata l'ultima.
/// </summary>
private static int LearnBanditFromAuction(string auctionId)
{
var n = 0;
foreach (var r in Db.Query("SELECT context_key, outcome FROM bot_decisions WHERE auction_id = ?1 AND context_key IS NOT NULL AND outcome IS NOT NULL", auctionId))
{
var outcome = r.Str("outcome");
var unanswered = outcome is "won" or "would_win" or "missed";
_bandit.Observe(r.Str("context_key"), unanswered);
n++;
}
return n;
}
// ── Chiusura di un'asta: si impara subito ────────────────────────
private static readonly HashSet<string> _closedSeen = new(StringComparer.Ordinal);
/// <summary>Vero se l'asta ha già <c>learned_at</c> nel database: studiata in una sessione precedente.</summary>
private static bool AlreadyLearned(string auctionId)
{
try
{
Db.Flush();
return Db.ScalarString("SELECT learned_at FROM auctions WHERE auction_id = ?1", auctionId) != null;
}
catch { return false; }
}
/// <summary>
/// Da chiamare quando un'asta è chiusa e la sua registrazione è stata completata.
/// Aggiorna il profilo con lo storico appena registrato e il modello con le
/// puntate dell'asta, poi salva: la prossima decisione le vede già.
/// </summary>
public static void OnAuctionClosed(CompletedAuctionRecord record)
{
if (record == null || string.IsNullOrEmpty(record.AuctionId)) return;
// Una volta per asta. La chiusura arrivava due volte (fine e rimozione dal
// monitor) e, al riavvio, di nuovo per le aste concluse rimaste in elenco:
// il modello, il profilo e il bandit le contavano due volte.
lock (Sync)
{
if (!_closedSeen.Add(record.AuctionId)) return;
}
if (AlreadyLearned(record.AuctionId))
{
OnLog?.Invoke($"[APPRENDIMENTO] '{record.Name}': già appresa, non la ristudio");
return;
}
try
{
lock (Sync)
{
_profile.Observe(record.ProductKey, record.EndedAt.ToLocalTime(),
AuctionIntegrity.TrustedWinnerBids(record), Ratio(record.FinalPrice, record.BuyNowPrice));
}
// Le righe dell'asta sono in coda: prima devono essere sul disco.
Db.Flush();
var learned = IngestAuction(record.AuctionId);
var decisioni = LearnBanditFromAuction(record.AuctionId);
Save();
if (decisioni > 0)
OnLog?.Invoke($"[APPRENDIMENTO] '{record.Name}': {decisioni} decisioni con esito, bandit aggiornato ({_bandit.Observations:N0} osservazioni)");
OnLog?.Invoke(learned
? $"[APPRENDIMENTO] '{record.Name}': appresa ({ModelUpdates:N0} aggiornamenti, {AuctionsLearned:N0} aste)"
: $"[APPRENDIMENTO] '{record.Name}': profilo aggiornato, puntate non utilizzabili per il modello");
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Errore su '{record.Name}': {ex.Message}");
}
}
// ── Domande dal motore ───────────────────────────────────────────
/// <summary>
/// Se puntassi adesso, con che probabilità nessuno risponderebbe? Null quando non
/// c'è abbastanza contesto (nessuna puntata recente vista).
/// </summary>
public static double? PredictUnanswered(AuctionInfo auction, AuctionState state, DateTime nowLocal, string? myUsername)
{
var ctx = BuildContext(auction, state, nowLocal, myUsername);
return ctx is { } c ? PredictUnanswered(c) : null;
}
/// <summary>La probabilità per un contesto già costruito: risponde il campione del momento.</summary>
public static double PredictUnanswered(in BidContext ctx)
{
var x = BidFeatures.Build(ctx);
lock (Sync)
{
var netLeads = _brierSamples >= ChallengerMinSamples && !double.IsNaN(_brierNet) && _brierNet < _brierLogit;
return netLeads ? _challenger.PredictCalibrated(x) : _model.PredictCalibrated(x);
}
}
/// <summary>
/// Lo stato dell'asta nell'istante della decisione, ridotto alle variabili del
/// modello. Null quando non c'è abbastanza contesto (nessuna puntata recente).
/// </summary>
public static BidContext? BuildContext(AuctionInfo auction, AuctionState state, DateTime nowLocal, string? myUsername)
{
if (auction == null || state == null) return null;
var recent = auction.SnapshotRecentBids(20); // dalla più recente
if (recent.Count == 0) return null;
var last = recent[0];
var gap = state.ServerUnixSeconds > 0 && last.Timestamp > 0
? state.ServerUnixSeconds - last.Timestamp
: -1;
int declared = 0, auto = 0;
foreach (var b in recent.Take(10))
{
if (b.IsAuto) { auto++; declared++; }
else if (b.IsManual) declared++;
}
var autoShare = declared == 0 ? -1 : (double)auto / declared;
var distinct = recent.Select(b => b.Username).Distinct(StringComparer.OrdinalIgnoreCase).Count();
var sinceManual = -1;
for (var i = 0; i < recent.Count; i++)
{
if (recent[i].IsManual) { sinceManual = i; break; }
if (!recent[i].IsAuto) break;
}
return new BidContext(
CycleSeconds: gap,
AutoShareRecent: autoShare,
DistinctBiddersRecent: Math.Max(1, distinct),
PriceRatio: auction.BuyNowPrice is > 0 ? state.Price / auction.BuyNowPrice.Value : null,
Hour: nowLocal.Hour,
Day: nowLocal.DayOfWeek,
OpenHour: auction.AddedAt.ToLocalTime().Hour,
IsAutoBid: false,
CyclesSinceManual: sinceManual,
AuctionDepthCents: (int)Math.Round(state.Price * 100),
SameBidderAsTwoAgo: recent.Count >= 2 && !string.IsNullOrEmpty(myUsername) &&
string.Equals(recent[1].Username, myUsername, StringComparison.OrdinalIgnoreCase),
ProductKey: ProductKeyHelper.GenerateProductKey(auction.Name));
}
/// <summary>Cosa aspettarsi da un'asta di questo prodotto se chiudesse a quest'ora.</summary>
public static AuctionProfileStats.Estimate? Expect(string? productKey, DateTime whenLocal)
{
lock (Sync) return _profile.Expect(productKey, whenLocal);
}
private static double? Ratio(double finalPrice, double? buyNow) =>
buyNow is > 0 && finalPrice > 0 ? finalPrice / buyNow.Value : null;
/// <summary>Solo per i test: stato pulito, senza toccare il disco.</summary>
public static void ResetForTests()
{
lock (Sync)
{
_model = new OnlineLogit(BidFeatures.Size);
_challenger = NewChallenger();
_bandit = new ThompsonBandit();
_profile = new AuctionProfileStats();
_auctionsLearned = 0;
_brierLogit = double.NaN; _brierNet = double.NaN; _brierSamples = 0;
_loaded = true;
}
}
}
}
+274
View File
@@ -0,0 +1,274 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace AutoBidder.Ml
{
/// <summary>
/// Una rete neurale densa (MLP) in C# puro: strati completamente connessi, ReLU
/// nascosti, uscita lineare, ottimizzatore Adam, regolarizzazione L2. Niente librerie.
///
/// <para><b>A cosa serve qui.</b> Due usi. Come <i>sfidante</i> del modello logistico
/// per P(senza risposta): stesse variabili, stessi esempi, ma può imparare le
/// interazioni fra fasce che un modello lineare non vede (un ciclo lungo vale cose
/// diverse alle 12 e a mezzanotte). E come funzione Q dell'agente di rinforzo nel
/// simulatore. La valutazione prequenziale decide chi comanda: il modello che ha il
/// punteggio di Brier più basso sulle aste recenti.</para>
///
/// <para><b>Perché piccola.</b> Le variabili sono una settantina di indicatori a
/// fasce; una rete 70→32→16→1 ha circa tremila pesi e si addestra online, un esempio
/// alla volta, in microsecondi. Più grande non imparerebbe di più da questi dati:
/// li memorizzerebbe.</para>
///
/// <para>La calibrazione in linea è la stessa del modello logistico: il rapporto
/// osservato/previsto sugli ultimi esempi corregge la scala, perché il mercato
/// deriva e una rete addestrata su ieri sovrastima oggi.</para>
/// </summary>
public sealed class NeuralNet
{
private readonly int[] _sizes;
private readonly double[][] _w; // per strato: [out * in]
private readonly double[][] _b; // per strato: [out]
private readonly double[][] _mw, _vw, _mb, _vb; // momenti di Adam
private long _t;
// Attivazioni dell'ultimo passaggio in avanti, per la retropropagazione.
private readonly double[][] _a; // _a[0] = ingresso, _a[L] = uscita (lineare)
private readonly double[][] _z; // pre-attivazioni
public double LearningRate { get; set; } = 0.001;
public double L2 { get; set; } = 1e-5;
public double Beta1 { get; set; } = 0.9;
public double Beta2 { get; set; } = 0.999;
public long Updates { get; private set; }
// ── Calibrazione in linea (come OnlineLogit) ─────────────────────
private double _observedEma;
private double _predictedEma;
private const double CalibrationWindow = 20_000;
/// <summary>Fattore che riporta la scala delle probabilità a quella osservata di recente.</summary>
public double CalibrationFactor =>
_predictedEma > 1e-9 && Updates > 200 ? Math.Clamp(_observedEma / _predictedEma, 0.25, 4.0) : 1.0;
public int InputSize => _sizes[0];
public int OutputSize => _sizes[^1];
public IReadOnlyList<int> Sizes => _sizes;
public NeuralNet(int[] layerSizes, int seed = 42)
{
if (layerSizes == null || layerSizes.Length < 2) throw new ArgumentException("servono almeno ingresso e uscita");
_sizes = (int[])layerSizes.Clone();
var layers = _sizes.Length - 1;
_w = new double[layers][]; _b = new double[layers][];
_mw = new double[layers][]; _vw = new double[layers][];
_mb = new double[layers][]; _vb = new double[layers][];
_a = new double[_sizes.Length][];
_z = new double[_sizes.Length][];
var rng = new Random(seed);
for (var l = 0; l < layers; l++)
{
int nin = _sizes[l], nout = _sizes[l + 1];
_w[l] = new double[nout * nin];
_b[l] = new double[nout];
_mw[l] = new double[nout * nin]; _vw[l] = new double[nout * nin];
_mb[l] = new double[nout]; _vb[l] = new double[nout];
// Inizializzazione di He: adatta alla ReLU, tiene la varianza stabile in profondità.
var scale = Math.Sqrt(2.0 / nin);
for (var i = 0; i < _w[l].Length; i++) _w[l][i] = Gaussian(rng) * scale;
}
for (var l = 0; l < _sizes.Length; l++) { _a[l] = new double[_sizes[l]]; _z[l] = new double[_sizes[l]]; }
}
private static double Gaussian(Random rng)
{
var u1 = 1.0 - rng.NextDouble();
var u2 = rng.NextDouble();
return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
}
// ── In avanti ────────────────────────────────────────────────────
/// <summary>Uscita lineare della rete (senza sigmoide).</summary>
public double[] Forward(double[] x)
{
if (x.Length != _sizes[0]) throw new ArgumentException($"ingresso di {x.Length} valori, attesi {_sizes[0]}");
Array.Copy(x, _a[0], x.Length);
var layers = _sizes.Length - 1;
for (var l = 0; l < layers; l++)
{
int nin = _sizes[l], nout = _sizes[l + 1];
var w = _w[l]; var b = _b[l]; var ain = _a[l]; var z = _z[l + 1]; var aout = _a[l + 1];
for (var o = 0; o < nout; o++)
{
var sum = b[o];
var row = o * nin;
for (var i = 0; i < nin; i++) sum += w[row + i] * ain[i];
z[o] = sum;
aout[o] = l == layers - 1 ? sum : Math.Max(0, sum); // ReLU nascosti, uscita lineare
}
}
return (double[])_a[layers].Clone();
}
public static double Sigmoid(double z) => z >= 0 ? 1.0 / (1.0 + Math.Exp(-z)) : Math.Exp(z) / (1.0 + Math.Exp(z));
/// <summary>Probabilità grezza (rete a una uscita, perdita logistica).</summary>
public double Predict(double[] x) => Sigmoid(Forward(x)[0]);
/// <summary>Probabilità con la scala corretta dalla calibrazione in linea.</summary>
public double PredictCalibrated(double[] x) => Math.Clamp(Predict(x) * CalibrationFactor, 1e-6, 1 - 1e-6);
// ── Addestramento ────────────────────────────────────────────────
/// <summary>
/// Un passo online con perdita logistica su un'uscita sola: è il caso
/// «P(senza risposta)». Il gradiente sull'uscita lineare è semplicemente p y.
/// </summary>
public void Update(double[] x, bool y)
{
var p = Predict(x);
var target = y ? 1.0 : 0.0;
// Calibrazione: media mobile di osservato e previsto.
var k = Math.Min(1.0, 1.0 / Math.Min(CalibrationWindow, Math.Max(1, Updates + 1)));
_observedEma += k * (target - _observedEma);
_predictedEma += k * (p - _predictedEma);
Backward(new[] { p - target });
}
/// <summary>Un passo con perdita quadratica su tutte le uscite (regressione, funzione Q).</summary>
public double UpdateRegression(double[] x, double[] target)
{
var y = Forward(x);
var grad = new double[y.Length];
var loss = 0.0;
for (var i = 0; i < y.Length; i++)
{
grad[i] = y[i] - target[i];
loss += 0.5 * grad[i] * grad[i];
}
Backward(grad);
return loss;
}
/// <summary>
/// Come <see cref="UpdateRegression"/> ma aggiorna una sola uscita (l'azione
/// scelta): è ciò che serve al Q-learning, dove il bersaglio esiste solo per
/// l'azione presa.
/// </summary>
public double UpdateOutput(double[] x, int output, double target)
{
var y = Forward(x);
var grad = new double[y.Length];
grad[output] = y[output] - target;
Backward(grad);
return 0.5 * grad[output] * grad[output];
}
/// <summary>Retropropagazione dal gradiente sull'uscita lineare, con passo di Adam.</summary>
private void Backward(double[] gradOut)
{
var layers = _sizes.Length - 1;
var delta = (double[])gradOut.Clone();
_t++;
Updates++;
var lrT = LearningRate * Math.Sqrt(1 - Math.Pow(Beta2, _t)) / (1 - Math.Pow(Beta1, _t));
for (var l = layers - 1; l >= 0; l--)
{
int nin = _sizes[l], nout = _sizes[l + 1];
var w = _w[l]; var b = _b[l]; var ain = _a[l];
var prevDelta = l > 0 ? new double[nin] : null;
for (var o = 0; o < nout; o++)
{
var d = delta[o];
var row = o * nin;
// gradiente rispetto all'ingresso di questo strato (per lo strato sotto)
if (prevDelta != null)
for (var i = 0; i < nin; i++) prevDelta[i] += w[row + i] * d;
// Adam sui pesi
for (var i = 0; i < nin; i++)
{
var g = d * ain[i] + L2 * w[row + i];
var idx = row + i;
_mw[l][idx] = Beta1 * _mw[l][idx] + (1 - Beta1) * g;
_vw[l][idx] = Beta2 * _vw[l][idx] + (1 - Beta2) * g * g;
w[idx] -= lrT * _mw[l][idx] / (Math.Sqrt(_vw[l][idx]) + 1e-8);
}
_mb[l][o] = Beta1 * _mb[l][o] + (1 - Beta1) * d;
_vb[l][o] = Beta2 * _vb[l][o] + (1 - Beta2) * d * d;
b[o] -= lrT * _mb[l][o] / (Math.Sqrt(_vb[l][o]) + 1e-8);
}
if (prevDelta != null)
{
// derivata della ReLU: passa solo dove la pre-attivazione era positiva
for (var i = 0; i < nin; i++) if (_z[l][i] <= 0) prevDelta[i] = 0;
delta = prevDelta;
}
}
}
// ── Persistenza ──────────────────────────────────────────────────
private sealed class Snapshot
{
public int[] Sizes { get; set; } = Array.Empty<int>();
public double[][] W { get; set; } = Array.Empty<double[]>();
public double[][] B { get; set; } = Array.Empty<double[]>();
public long Updates { get; set; }
public double ObservedEma { get; set; }
public double PredictedEma { get; set; }
}
public string ToJson() => JsonSerializer.Serialize(new Snapshot
{
Sizes = _sizes, W = _w, B = _b, Updates = Updates, ObservedEma = _observedEma, PredictedEma = _predictedEma
});
/// <summary>
/// Ricarica i pesi. Passo, L2 e momenti di Adam NON si rileggono: i primi sono
/// parametri del codice, i momenti ripartono da zero (un breve riscaldamento).
/// Se la forma salvata non coincide con quella richiesta, si riparte da capo.
/// </summary>
public static NeuralNet FromJson(string json, int[] expectedSizes, int seed = 42)
{
var fresh = new NeuralNet(expectedSizes, seed);
try
{
var s = JsonSerializer.Deserialize<Snapshot>(json);
if (s == null || !s.Sizes.SequenceEqual(expectedSizes)) return fresh;
for (var l = 0; l < fresh._w.Length; l++)
{
if (s.W[l].Length != fresh._w[l].Length || s.B[l].Length != fresh._b[l].Length) return new NeuralNet(expectedSizes, seed);
Array.Copy(s.W[l], fresh._w[l], s.W[l].Length);
Array.Copy(s.B[l], fresh._b[l], s.B[l].Length);
}
fresh.Updates = s.Updates;
fresh._observedEma = s.ObservedEma;
fresh._predictedEma = s.PredictedEma;
return fresh;
}
catch
{
return fresh;
}
}
}
}
+232
View File
@@ -0,0 +1,232 @@
using System;
using System.Text.Json;
namespace AutoBidder.Ml
{
/// <summary>
/// Regressione logistica che impara un esempio alla volta.
///
/// <para><b>Perché proprio questa.</b> È il modello più semplice che dia una
/// <i>probabilità calibrata</i>, e la calibrazione qui non è un lusso: il numero che
/// esce entra in una formula di valore atteso, moltiplicato per euro. Un modello che
/// ordinasse bene le puntate ma sbagliasse la scala direbbe di puntare quando non
/// conviene. Si aggiorna con un passo di discesa del gradiente per esempio, senza
/// bisogno di tenere i dati: è quello che serve a un motore che deve migliorare a ogni
/// asta chiusa, senza riaddestrarsi da capo.</para>
///
/// <para><b>Nessuna libreria.</b> Sono trenta righe di aritmetica: un prodotto scalare,
/// una sigmoide, un aggiornamento dei pesi. Portarsi dietro una dipendenza per questo
/// sarebbe più codice, non meno.</para>
///
/// <para><b>Sbilanciamento delle classi.</b> Le puntate senza risposta sono una su
/// quattrocento. Non si riequilibra apposta: pesare di più i positivi gonfierebbe le
/// probabilità, e sarebbero poi confrontate con costi veri. Si lascia che il modello
/// impari la scala vera, e si accetta che serva più materiale.</para>
/// </summary>
public sealed class OnlineLogit
{
/// <summary>Pesi delle variabili; l'ultimo elemento è l'intercetta.</summary>
public double[] Weights { get; private set; }
/// <summary>
/// Media mobile dei pesi, usata per prevedere quando <see cref="UseAveraging"/> è acceso.
///
/// <para>Con un passo costante i pesi oscillano attorno al punto buono: ogni esempio
/// li strattona un po' nella sua direzione, e l'ultimo esempio di ogni asta è sempre
/// il positivo. Prevedere con la media degli ultimi qualche migliaio di passi toglie
/// l'oscillazione senza fermare l'apprendimento: i pesi vivi continuano a muoversi,
/// la media li segue con calma.</para>
/// </summary>
public double[] AveragedWeights { get; private set; }
/// <summary>
/// Prevedere con la media dei pesi invece che con i pesi vivi. Spento di serie:
/// sui dossier veri non ha migliorato né separazione né scala, e la calibrazione
/// in linea fa il lavoro che ci si aspettava dalla media.
/// </summary>
public bool UseAveraging { get; set; } = false;
/// <summary>Quanti passi «pesa» la media mobile: 2000 sono circa otto aste.</summary>
public int AveragingWindow { get; set; } = 2000;
/// <summary>Quanti esempi hanno aggiornato i pesi finora.</summary>
public long Updates { get; private set; }
// ── Calibrazione in linea ────────────────────────────────────────
//
// Sui dossier veri il modello separa bene ma la scala deriva: addestrato sui
// vecchi e giudicato sui nuovi sovrastima di 1,5-1,8 volte, e il mercato cambia
// davvero fra un mese e l'altro. Si tiene quindi il rapporto fra esiti osservati e
// probabilità previste sugli ultimi esempi, e si scala la previsione con quello. È
// il modo più semplice di restare nella scala giusta mentre il mondo si muove, e
// non richiede nessun dato in più di quelli che già passano di qui.
private double _emaObserved;
private double _emaPredicted;
/// <summary>Su quanti esempi si misura il rapporto: 20000 sono circa ottanta aste.</summary>
public int CalibrationWindow { get; set; } = 20000;
/// <summary>
/// Osservato / previsto sugli ultimi esempi, limitato fra ¼ e 4. Uno = scala giusta.
/// </summary>
public double CalibrationFactor
{
get
{
if (Updates < 2000 || _emaPredicted <= 0) return 1.0;
return Math.Clamp(_emaObserved / _emaPredicted, 0.25, 4.0);
}
}
/// <summary>Probabilità riportata nella scala osservata di recente.</summary>
public double PredictCalibrated(ReadOnlySpan<double> x) =>
Math.Clamp(Predict(x) * CalibrationFactor, 0.0, 1.0);
/// <summary>
/// Passo di apprendimento. Costante, non decrescente: il mercato cambia — nuovi
/// utenti, nuove abitudini — e un passo che tende a zero smetterebbe di seguirlo.
/// </summary>
public double LearningRate { get; set; } = 0.01;
/// <summary>
/// Regolarizzazione: tiene i pesi piccoli quando una variabile ha pochi esempi.
/// Serve soprattutto per i prodotti rari, che altrimenti farebbero oscillare tutto.
/// </summary>
public double L2 { get; set; } = 1e-4;
public int FeatureCount => Weights.Length - 1;
public OnlineLogit(int featureCount)
{
if (featureCount <= 0) throw new ArgumentOutOfRangeException(nameof(featureCount));
Weights = new double[featureCount + 1];
AveragedWeights = new double[featureCount + 1];
}
private OnlineLogit(double[] weights, double[] averaged, long updates)
{
Weights = weights;
AveragedWeights = averaged;
Updates = updates;
}
/// <summary>Probabilità dell'esito positivo per queste variabili.</summary>
public double Predict(ReadOnlySpan<double> x)
{
if (x.Length != FeatureCount)
throw new ArgumentException($"attese {FeatureCount} variabili, ricevute {x.Length}");
// Nei primissimi passi la media è ancora vicina a zero: si usano i pesi vivi.
var w = UseAveraging && Updates >= 200 ? AveragedWeights : Weights;
return Sigmoid(Dot(w, x));
}
/// <summary>Un passo di apprendimento su un esempio con la sua etichetta.</summary>
public void Update(ReadOnlySpan<double> x, bool positive)
{
var p = Sigmoid(Dot(Weights, x)); // il gradiente usa i pesi vivi, sempre
var err = (positive ? 1.0 : 0.0) - p;
// La calibrazione si misura sulla previsione fatta PRIMA di imparare da questo
// esempio: è quella che il motore avrebbe usato.
var beta = 1.0 / Math.Min(Updates + 1, Math.Max(1, CalibrationWindow));
_emaObserved += ((positive ? 1.0 : 0.0) - _emaObserved) * beta;
_emaPredicted += (Predict(x) - _emaPredicted) * beta;
var w = Weights;
var lr = LearningRate;
for (var i = 0; i < x.Length; i++)
{
if (x[i] == 0) continue; // le variabili a zero non muovono niente
w[i] += lr * (err * x[i] - L2 * w[i]);
}
w[^1] += lr * err; // intercetta: senza regolarizzazione, deve poter valere quanto serve
Updates++;
// Media mobile esponenziale: nei primi passi segue da vicino (così non parte
// da zero), poi si assesta sulla finestra scelta.
var betaMedia = 1.0 / Math.Min(Updates, Math.Max(1, AveragingWindow));
var a = AveragedWeights;
for (var i = 0; i < a.Length; i++) a[i] += (w[i] - a[i]) * betaMedia;
}
private static double Dot(double[] w, ReadOnlySpan<double> x)
{
var s = w[^1];
for (var i = 0; i < x.Length; i++) s += w[i] * x[i];
return s;
}
private static double Sigmoid(double z)
{
// Oltre ±30 la sigmoide è già 0 o 1 in doppia precisione: si evita l'overflow.
if (z > 30) return 1.0;
if (z < -30) return 0.0;
return 1.0 / (1.0 + Math.Exp(-z));
}
// ── Persistenza ──────────────────────────────────────────────────
private sealed class Snapshot
{
public int Version { get; set; } = 2;
public double[] Weights { get; set; } = Array.Empty<double>();
public double[]? AveragedWeights { get; set; }
public long Updates { get; set; }
public double LearningRate { get; set; }
public double L2 { get; set; }
public bool UseAveraging { get; set; } = false;
public int AveragingWindow { get; set; } = 2000;
public double EmaObserved { get; set; }
public double EmaPredicted { get; set; }
public int CalibrationWindow { get; set; } = 20000;
}
public string ToJson() => JsonSerializer.Serialize(new Snapshot
{
Weights = Weights,
AveragedWeights = AveragedWeights,
Updates = Updates,
LearningRate = LearningRate,
L2 = L2,
UseAveraging = UseAveraging,
AveragingWindow = AveragingWindow,
EmaObserved = _emaObserved,
EmaPredicted = _emaPredicted,
CalibrationWindow = CalibrationWindow
});
/// <summary>
/// Rilegge un modello salvato. Se il numero di variabili non coincide — perché le
/// variabili sono cambiate fra una versione e l'altra — si riparte da zero: pesi
/// riferiti a variabili diverse non sono un modello, sono rumore.
/// </summary>
public static OnlineLogit FromJson(string json, int expectedFeatureCount)
{
try
{
var s = JsonSerializer.Deserialize<Snapshot>(json);
if (s?.Weights != null && s.Weights.Length == expectedFeatureCount + 1)
{
var averaged = s.AveragedWeights != null && s.AveragedWeights.Length == s.Weights.Length
? s.AveragedWeights
: (double[])s.Weights.Clone();
// Si rileggono lo STATO appreso (pesi, conteggio, calibrazione) e non i
// parametri (passo, regolarizzazione, media): quelli sono scelte del
// codice, tarate sui dossier veri, e un cambio deve valere dal riavvio
// successivo — non restare inchiodato al valore di quando il file nacque.
return new OnlineLogit(s.Weights, averaged, s.Updates)
{
_emaObserved = s.EmaObserved,
_emaPredicted = s.EmaPredicted
};
}
}
catch (JsonException) { /* file illeggibile: si riparte */ }
return new OnlineLogit(expectedFeatureCount);
}
}
}
+303
View File
@@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Data;
namespace AutoBidder.Ml
{
/// <summary>
/// Il simulatore di un'asta al centesimo, con avversari sintetici tarati sui dati
/// osservati. È il banco di prova sicuro: qui una policy si può lasciar giocare
/// migliaia di aste senza spendere un centesimo, e si può misurare quello che la
/// rigiocata dei dati veri non può dire — come reagiscono gli altri a una nostra
/// puntata.
///
/// <para><b>Gli avversari.</b> Quattro profili, come nel documento di progetto:
/// <i>aggressivo</i> punta presto e insiste; <i>cecchino</i> punta negli ultimi
/// secondi; <i>autopuntata</i> spara a 2 secondi esatti finché ha credito;
/// <i>principiante</i> punta a caso e molla presto. Ognuno ha un credito di puntate e
/// una persistenza che <b>cresce con quanto ha già speso</b>: è l'effetto del costo
/// affondato documentato da Augenblick, ed è ciò che rende le aste lunghe.</para>
///
/// <para><b>La taratura.</b> Da <see cref="Calibration.FromDatabase"/>: quanti
/// puntatori distinti per asta, a che profondità del ciclo arrivano le puntate
/// (tabella <c>resets</c>), la quota di autopuntate, a che frazione del valore chiude
/// l'asta. Il simulatore non pretende di essere Bidoo: pretende di sbagliare nello
/// stesso verso su tutte le policy, così che il confronto fra loro sia onesto.</para>
///
/// <para><b>Il ciclo.</b> Ogni passo è un ciclo del timer. Si chiede all'agente cosa
/// fa (niente, oppure punta all'anticipo dato); gli avversari attivi estraggono
/// ciascuno il momento in cui punterebbero; vince chi arriva prima. Se nessuno punta
/// e non puntiamo noi, l'asta finisce e vince chi era in testa.</para>
/// </summary>
public sealed class PennyAuctionEnv
{
public enum OpponentType { Aggressive, Sniper, AutoBid, Beginner }
/// <summary>Parametri appresi dai dati, con predefiniti sensati per quando i dati mancano.</summary>
public sealed class Calibration
{
public double Value { get; set; } = 11;
public double BidCost { get; set; } = 0.20;
public double TransactionFee { get; set; } = 1.0;
public double Shipping { get; set; } = 0;
public double TimerSeconds { get; set; } = 10;
/// <summary>Puntatori distinti per asta: media e dispersione.</summary>
public double MeanBidders { get; set; } = 4;
public double AutoShare { get; set; } = 0.35;
/// <summary>Istogramma della profondità dei cicli (secondi residui quando arriva la puntata), da resets.min_timer.</summary>
public double[] CycleDepthHistogram { get; set; } = { 0.05, 0.10, 0.30, 0.25, 0.15, 0.08, 0.04, 0.02, 0.005, 0.005 };
/// <summary>Prezzo finale in frazione del valore: mediana osservata, usata per tarare la persistenza.</summary>
public double MedianFinalPriceRatio { get; set; } = 0.06;
public int Auctions { get; set; }
public static Calibration FromDatabase(AuctionDatabase db, string? productKey = null)
{
var c = new Calibration();
try
{
db.Flush();
var where = productKey == null ? "" : " AND a.product_key = ?1";
var args = productKey == null ? Array.Empty<object?>() : new object?[] { productKey };
var a = db.QueryOne(
"SELECT COUNT(*) AS n, AVG(distinct_bidders) AS bidders, AVG(buy_now_price) AS v, AVG(bid_fee) AS fee, AVG(shipping) AS ship " +
"FROM auctions a WHERE outcome IS NOT NULL AND distinct_bidders > 0" + where, args);
if (a != null && a.Long("n") > 0)
{
c.Auctions = (int)a.Long("n");
c.MeanBidders = Math.Max(1, a.Dbl("bidders"));
if (a.DblOrNull("v") is > 0) c.Value = a.Dbl("v");
if (a.DblOrNull("fee") is > 0) c.BidCost = a.Dbl("fee");
if (a.DblOrNull("ship") is > 0) c.Shipping = a.Dbl("ship");
}
var ratios = db.Query(
"SELECT final_price / buy_now_price AS r FROM auctions a WHERE outcome IS NOT NULL AND buy_now_price > 0 AND final_price > 0" + where + " ORDER BY r", args)
.Select(r => r.Dbl("r")).ToList();
if (ratios.Count >= 5) c.MedianFinalPriceRatio = ratios[ratios.Count / 2];
var types = db.QueryOne(
"SELECT SUM(CASE WHEN b.bid_type = 'Auto' THEN 1 ELSE 0 END) AS auto, SUM(CASE WHEN b.bid_type IN ('Auto', 'Manuale') THEN 1 ELSE 0 END) AS known " +
"FROM bids b JOIN auctions a ON a.auction_id = b.auction_id WHERE a.outcome IS NOT NULL" + where, args);
if (types != null && types.Long("known") > 50) c.AutoShare = (double)types.Long("auto") / types.Long("known");
var depths = db.Query(
"SELECT r.min_timer AS d FROM resets r JOIN auctions a ON a.auction_id = r.auction_id WHERE r.min_timer IS NOT NULL AND a.outcome IS NOT NULL" + where, args)
.Select(r => r.Dbl("d")).ToList();
if (depths.Count >= 100)
{
var hist = new double[10];
foreach (var d in depths) hist[Math.Clamp((int)Math.Floor(d), 0, 9)]++;
var total = hist.Sum();
for (var i = 0; i < hist.Length; i++) hist[i] = Math.Max(0.002, hist[i] / total);
c.CycleDepthHistogram = hist;
}
}
catch { /* si resta sui predefiniti */ }
return c;
}
}
public sealed class Opponent
{
public OpponentType Type;
public int Budget; // puntate che è disposto a spendere
public int Spent;
public bool Active = true;
/// <summary>Probabilità di rispondere a un ciclo, crescente con lo speso (costo affondato).</summary>
public double Persistence(double baseP) =>
Math.Min(0.98, baseP + 0.04 * Math.Min(Spent, 15));
}
/// <summary>Lo stato osservabile dall'agente a ogni ciclo.</summary>
public readonly record struct Observation(
double Price,
double PriceRatio,
int ActiveOpponents,
int TotalBids,
double LastGapSeconds,
bool AutoBidPresent,
int MyBids,
double MyBidsEuro,
bool ImLeading);
public readonly record struct StepResult(Observation Next, double Reward, bool Done, bool Won);
private readonly Calibration _cal;
private readonly Random _rng;
private readonly List<Opponent> _opponents = new();
private double _price;
private int _totalBids;
private int _myBids;
private bool _imLeading;
private double _lastGap;
private bool _done;
public PennyAuctionEnv(Calibration calibration, int seed = 1)
{
_cal = calibration;
_rng = new Random(seed);
}
public Calibration Cal => _cal;
/// <summary>Comincia un'asta nuova con avversari estratti dalla taratura.</summary>
public Observation Reset()
{
_opponents.Clear();
_price = 0.01;
_totalBids = 1;
_myBids = 0;
_imLeading = false;
_lastGap = _cal.TimerSeconds;
_done = false;
var n = Math.Max(1, (int)Math.Round(Gaussian() * Math.Sqrt(_cal.MeanBidders) + _cal.MeanBidders));
for (var i = 0; i < n; i++)
{
var r = _rng.NextDouble();
var type = r < _cal.AutoShare ? OpponentType.AutoBid
: r < _cal.AutoShare + 0.25 ? OpponentType.Sniper
: r < _cal.AutoShare + 0.45 ? OpponentType.Aggressive
: OpponentType.Beginner;
// Il credito complessivo degli avversari deve produrre chiusure vicine a
// quelle osservate: il prezzo finale in centesimi è il numero di puntate.
var expectedTotal = Math.Max(5, _cal.MedianFinalPriceRatio * _cal.Value * 100);
var budget = type switch
{
OpponentType.AutoBid => (int)(expectedTotal * (0.3 + 0.6 * _rng.NextDouble())),
OpponentType.Aggressive => (int)(expectedTotal * (0.2 + 0.5 * _rng.NextDouble())),
OpponentType.Sniper => (int)(expectedTotal * (0.1 + 0.3 * _rng.NextDouble())),
_ => (int)(expectedTotal * (0.02 + 0.15 * _rng.NextDouble()))
};
_opponents.Add(new Opponent { Type = type, Budget = Math.Max(1, budget) });
}
return Observe();
}
private Observation Observe() => new(
Price: _price,
PriceRatio: _cal.Value > 0 ? _price / _cal.Value : 0,
ActiveOpponents: _opponents.Count(o => o.Active),
TotalBids: _totalBids,
LastGapSeconds: _lastGap,
AutoBidPresent: _opponents.Any(o => o.Active && o.Type == OpponentType.AutoBid),
MyBids: _myBids,
MyBidsEuro: _myBids * _cal.BidCost,
ImLeading: _imLeading);
/// <summary>
/// Un ciclo. <paramref name="bidAtLeadSeconds"/> = null per non puntare, altrimenti
/// l'anticipo (secondi residui) a cui la nostra puntata partirebbe.
/// </summary>
public StepResult Step(double? bidAtLeadSeconds)
{
if (_done) return new StepResult(Observe(), 0, true, _imLeading);
// Ogni avversario attivo decide se e quando punterebbe in questo ciclo.
var bestOpponentTime = double.NegativeInfinity; // secondi residui: più alto = prima
Opponent? bidder = null;
foreach (var o in _opponents)
{
if (!o.Active) continue;
if (o.Spent >= o.Budget) { o.Active = false; continue; }
var baseP = o.Type switch
{
OpponentType.AutoBid => 0.97,
OpponentType.Aggressive => 0.75,
OpponentType.Sniper => 0.55,
_ => 0.35
};
if (_rng.NextDouble() > o.Persistence(baseP))
{
// Non risponde a questo ciclo: i principianti mollano per sempre.
if (o.Type == OpponentType.Beginner && _rng.NextDouble() < 0.5) o.Active = false;
continue;
}
var when = o.Type switch
{
OpponentType.AutoBid => 2.0,
OpponentType.Sniper => 0.5 + 2.5 * _rng.NextDouble(),
OpponentType.Aggressive => Math.Min(_cal.TimerSeconds, 5 + 5 * _rng.NextDouble()),
_ => SampleDepth()
};
if (when > bestOpponentTime) { bestOpponentTime = when; bidder = o; }
}
var reward = 0.0;
var weBid = bidAtLeadSeconds is { } lead && !_imLeading;
var ourTime = weBid ? bidAtLeadSeconds!.Value : double.NegativeInfinity;
if (bidder != null && bestOpponentTime >= ourTime)
{
// Un avversario arriva prima di noi (o noi non puntiamo).
bidder.Spent++;
_price += Theory.PriceIncrement;
_totalBids++;
_imLeading = false;
_lastGap = _cal.TimerSeconds - bestOpponentTime;
return new StepResult(Observe(), 0, false, false);
}
if (weBid)
{
// La nostra puntata resetta il timer; si paga subito.
_myBids++;
_price += Theory.PriceIncrement;
_totalBids++;
_imLeading = true;
_lastGap = _cal.TimerSeconds - ourTime;
reward -= _cal.BidCost;
return new StepResult(Observe(), reward, false, false);
}
// Nessuno ha puntato: l'asta finisce.
_done = true;
if (_imLeading)
{
reward += _cal.Value - _price - _cal.TransactionFee - _cal.Shipping;
return new StepResult(Observe(), reward, true, true);
}
return new StepResult(Observe(), 0, true, false);
}
private double SampleDepth()
{
var h = _cal.CycleDepthHistogram;
var u = _rng.NextDouble() * h.Sum();
for (var i = 0; i < h.Length; i++)
{
u -= h[i];
if (u <= 0) return Math.Min(_cal.TimerSeconds, i + _rng.NextDouble());
}
return 0.5;
}
private double Gaussian()
{
var u1 = 1.0 - _rng.NextDouble();
var u2 = _rng.NextDouble();
return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
}
/// <summary>Il prezzo corrente, per chi vuole valutare la fine.</summary>
public double Price => _price;
public bool ImLeading => _imLeading;
public int MyBids => _myBids;
}
}
+214
View File
@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace AutoBidder.Ml
{
/// <summary>
/// Apprendimento per rinforzo nel simulatore: un agente Q-learning con funzione Q su
/// rete neurale (<see cref="NeuralNet"/>), replay delle esperienze, esplorazione
/// ε-greedy decrescente, e una penalità conservativa sulle azioni non viste.
///
/// <para><b>Il problema di decisione</b> (§3.5 del documento): stato = prezzo sul
/// valore, avversari attivi, ritmo delle puntate, presenza dell'autopuntata, quanto
/// abbiamo speso, se siamo in testa; azioni = NON PUNTARE, PUNTA; ricompensa = c a
/// ogni puntata, +(V prezzo fee spedizione) se si vince, 0 se si esce.
/// Sconto γ vicino a 1: la ricompensa che conta è quella finale.</para>
///
/// <para><b>Perché nel simulatore e non dal vivo.</b> Non si esplora con denaro
/// vero. La penalità conservativa (nello spirito di CQL, Kumar et al. 2020) abbassa
/// le Q delle azioni che il replay ha visto poco: un agente ottimista su ciò che non
/// conosce è esattamente ciò che si vuole evitare quando poi lo si guarda in
/// shadow.</para>
///
/// <para>L'agente è una <i>policy</i> come le altre: si confronta con la regola del
/// valore atteso e con il bandit su aste simulate identiche (stessi semi), e la
/// scheda mostra ROI per puntata, tasso di vittoria, costo per vittoria.</para>
/// </summary>
public sealed class QLearningAgent
{
public const int StateSize = 9;
public const int Actions = 2; // 0 = NON PUNTARE, 1 = PUNTA
private readonly NeuralNet _q;
private readonly Random _rng;
private readonly List<(double[] S, int A, double R, double[]? S2)> _replay = new();
private const int ReplayCap = 20_000;
public double Gamma { get; set; } = 0.98;
public double Epsilon { get; private set; } = 0.30;
public double EpsilonMin { get; set; } = 0.02;
public double EpsilonDecay { get; set; } = 0.999;
/// <summary>Quanto si penalizzano le Q delle azioni poco viste (0 = Q-learning puro).</summary>
public double ConservativeWeight { get; set; } = 0.02;
public long Episodes { get; private set; }
private readonly long[] _actionCounts = new long[Actions];
public QLearningAgent(int seed = 7)
{
_q = new NeuralNet(new[] { StateSize, 32, 16, Actions }, seed) { LearningRate = 0.0007, L2 = 1e-6 };
_rng = new Random(seed);
}
public static double[] Encode(in PennyAuctionEnv.Observation o) => new[]
{
Math.Min(1, o.PriceRatio * 5), // prezzo sul valore, scalato
Math.Min(1, o.ActiveOpponents / 8.0),
Math.Min(1, o.TotalBids / 500.0),
Math.Min(1, o.LastGapSeconds / 10.0),
o.AutoBidPresent ? 1 : 0,
Math.Min(1, o.MyBids / 50.0),
Math.Min(1, o.MyBidsEuro / 10.0),
o.ImLeading ? 1 : 0,
1.0 // bias
};
/// <summary>L'azione secondo la policy appresa (senza esplorazione).</summary>
public int Act(in PennyAuctionEnv.Observation o)
{
var q = _q.Forward(Encode(o));
return q[1] > q[0] ? 1 : 0;
}
public double[] QValues(in PennyAuctionEnv.Observation o) => _q.Forward(Encode(o));
/// <summary>Un episodio di addestramento (un'asta simulata). Restituisce la ricompensa totale.</summary>
public double TrainEpisode(PennyAuctionEnv env, double leadSeconds)
{
var obs = env.Reset();
var total = 0.0;
var steps = 0;
while (steps++ < 2000)
{
var s = Encode(obs);
int a;
if (_rng.NextDouble() < Epsilon) a = _rng.Next(Actions);
else { var q = _q.Forward(s); a = q[1] > q[0] ? 1 : 0; }
var r = env.Step(a == 1 ? leadSeconds : null);
total += r.Reward;
_actionCounts[a]++;
var s2 = r.Done ? null : Encode(r.Next);
Remember(s, a, r.Reward, s2);
Learn(4);
if (r.Done) break;
obs = r.Next;
}
Episodes++;
Epsilon = Math.Max(EpsilonMin, Epsilon * EpsilonDecay);
return total;
}
private void Remember(double[] s, int a, double r, double[]? s2)
{
if (_replay.Count >= ReplayCap) _replay.RemoveAt(_rng.Next(_replay.Count / 2));
_replay.Add((s, a, r, s2));
}
private void Learn(int batch)
{
if (_replay.Count < 64) return;
var total = Math.Max(1, _actionCounts.Sum());
for (var k = 0; k < batch; k++)
{
var (s, a, r, s2) = _replay[_rng.Next(_replay.Count)];
var target = r;
if (s2 != null)
{
var qNext = _q.Forward(s2);
target += Gamma * Math.Max(qNext[0], qNext[1]);
}
// Penalità conservativa: un'azione vista di rado ha un bersaglio più basso,
// in proporzione a quanto è rara. Evita Q ottimiste su ciò che non si conosce.
var rarity = 1.0 - (double)_actionCounts[a] / total;
target -= ConservativeWeight * rarity;
_q.UpdateOutput(s, a, target);
}
}
// ── Confronto fra policy nel simulatore ──────────────────────────
public sealed class PolicyResult
{
public string Name { get; init; } = "";
public int Auctions { get; init; }
public int Wins { get; init; }
public int Bids { get; init; }
public double Spent { get; init; }
public double Gained { get; init; }
public double MaxDrawdown { get; init; }
public double Net => Gained - Spent;
public double RoiPerBid => Bids > 0 ? Net / Bids : 0;
public double WinRate => Auctions > 0 ? (double)Wins / Auctions : 0;
public double CostPerWin => Wins > 0 ? Spent / Wins : double.NaN;
}
/// <summary>Una policy da valutare: dallo stato all'anticipo (null = non puntare).</summary>
public delegate double? Policy(in PennyAuctionEnv.Observation o);
/// <summary>
/// Gioca <paramref name="auctions"/> aste simulate con una policy e ne misura i
/// risultati. Gli stessi semi per ogni policy: le differenze sono della policy,
/// non del caso.
/// </summary>
public static PolicyResult Evaluate(string name, PennyAuctionEnv.Calibration cal, Policy policy, int auctions, int seed = 1000)
{
int wins = 0, bids = 0;
double spent = 0, gained = 0, balance = 0, peak = 0, drawdown = 0;
for (var i = 0; i < auctions; i++)
{
var env = new PennyAuctionEnv(cal, seed + i);
var obs = env.Reset();
var steps = 0;
while (steps++ < 2000)
{
var lead = policy(obs);
var r = env.Step(lead);
if (r.Reward < 0) { spent += -r.Reward; bids++; }
else if (r.Reward > 0) gained += r.Reward;
balance += r.Reward;
if (balance > peak) peak = balance;
if (peak - balance > drawdown) drawdown = peak - balance;
if (r.Done) { if (r.Won) wins++; break; }
obs = r.Next;
}
}
return new PolicyResult { Name = name, Auctions = auctions, Wins = wins, Bids = bids, Spent = spent, Gained = gained, MaxDrawdown = drawdown };
}
public static string Format(IEnumerable<PolicyResult> results)
{
var sb = new StringBuilder();
sb.AppendLine(" policy aste vinte puntate spesi € incassati € netto € ROI/puntata costo/vittoria drawdown €");
sb.AppendLine(" ---------------------------------------------------------------------------------------------------------------------");
foreach (var r in results)
{
sb.AppendLine(string.Format(System.Globalization.CultureInfo.GetCultureInfo("it-IT"),
" {0,-22} {1,6} {2,7} {3,9} {4,9:F2} {5,13:F2} {6,10:F2} {7,13:F3} {8,16} {9,12:F2}",
r.Name, r.Auctions, r.Wins, r.Bids, r.Spent, r.Gained, r.Net, r.RoiPerBid,
double.IsNaN(r.CostPerWin) ? "—" : r.CostPerWin.ToString("F2"), r.MaxDrawdown));
}
return sb.ToString();
}
public string ToJson() => _q.ToJson();
}
}
+165
View File
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder.Ml
{
/// <summary>
/// Cosa avrebbero reso le decisioni registrate, policy per policy, con l'esito noto.
///
/// <para>Ogni decisione ha un esito alla chiusura dell'asta: se il prezzo non è più
/// salito dopo quell'istante, una puntata lì sarebbe stata l'ultima. Con questo si
/// valuta ogni policy sugli stessi istanti, dal vivo e in shadow: quante volte
/// avrebbe puntato, quante di quelle sarebbero state vincenti, quanto avrebbe speso e
/// incassato al valore reale del bene. È la valutazione <i>off-policy</i> più onesta
/// che i dati permettono — con l'avvertenza che una puntata nostra può cambiare il
/// comportamento altrui, e questo nessun dato registrato lo dice.</para>
///
/// <para>Il confronto è sempre con il null-model: profitto atteso per puntata zero.
/// Un ROI per puntata positivo su poche decine di decisioni non prova nulla; su
/// migliaia, e con l'estremo inferiore dell'intervallo sopra lo zero, comincia a
/// dire qualcosa.</para>
/// </summary>
public static class ShadowReport
{
public sealed class PolicyStats
{
public string Name { get; init; } = "";
public int Decisions { get; set; }
public int Bids { get; set; }
public int Wins { get; set; }
public double Spent { get; set; }
public double Gained { get; set; }
public double ForecastEv { get; set; }
public readonly List<double> PerBid = new();
public double Net => Gained - Spent;
public double RoiPerBid => Bids > 0 ? Net / Bids : 0;
public double WinRate => Bids > 0 ? (double)Wins / Bids : 0;
/// <summary>Estremo inferiore (95%) del ROI per puntata: sotto zero, il null-model non è battuto.</summary>
public double RoiLower95
{
get
{
if (PerBid.Count < 2) return double.NaN;
var m = PerBid.Average();
var sd = Math.Sqrt(PerBid.Sum(x => (x - m) * (x - m)) / (PerBid.Count - 1));
return m - 1.96 * sd / Math.Sqrt(PerBid.Count);
}
}
}
public sealed class Report
{
public int Days { get; init; }
public int TotalDecisions { get; init; }
public int WithOutcome { get; init; }
public int LiveBids { get; init; }
public int LiveWins { get; init; }
public List<PolicyStats> Policies { get; } = new();
public string Text { get; set; } = "";
}
public static Report Compute(AuctionDatabase db, AppSettings settings, int days = 30)
{
db.Flush();
var since = AuctionDatabase.Iso(DateTime.Today.AddDays(-days));
var rows = db.Query(
"SELECT d.mode, d.action, d.outcome, d.ev_estimate, d.price, d.alt_action, d.executed, " +
" COALESCE(a.market_value, a.buy_now_price) AS v, a.shipping, a.bid_fee " +
"FROM bot_decisions d JOIN auctions a ON a.auction_id = d.auction_id " +
"WHERE d.ts >= ?1 AND d.outcome IS NOT NULL", since);
var total = (int)db.ScalarLong("SELECT COUNT(*) AS n FROM bot_decisions WHERE ts >= ?1", since);
var acting = new PolicyStats { Name = "modello + regime (in uso)" };
var alt = new PolicyStats { Name = "bandit Thompson (shadow)" };
var always = new PolicyStats { Name = "punta sempre" };
var never = new PolicyStats { Name = "non punta mai" };
int liveBids = 0, liveWins = 0;
foreach (var r in rows)
{
var outcome = r.Str("outcome");
var unanswered = outcome is "won" or "would_win" or "missed";
var v = r.DblOrNull("v") ?? 0;
var price = r.Dbl("price");
var fee = settings.TransactionFeeEuro;
var ship = r.DblOrNull("shipping") ?? 0;
var cost = r.DblOrNull("bid_fee") is > 0 ? r.Dbl("bid_fee") : settings.AverageBidCostEuro;
var gainIfWin = v - (price + Theory.PriceIncrement) - fee - ship;
void Apply(PolicyStats s, bool bid, double? forecast)
{
s.Decisions++;
if (!bid) return;
s.Bids++;
s.Spent += cost;
if (forecast is { } f) s.ForecastEv += f;
if (unanswered)
{
s.Wins++;
s.Gained += gainIfWin;
s.PerBid.Add(gainIfWin - cost);
}
else s.PerBid.Add(-cost);
}
var actingBid = r.Str("action") == "BID";
Apply(acting, actingBid, r.DblOrNull("ev_estimate"));
Apply(alt, r.Str("alt_action") == "BID", null);
Apply(always, true, null);
Apply(never, false, null);
if (r.Str("mode") == "live" && r.Bool("executed"))
{
liveBids++;
if (outcome == "won") liveWins++;
}
}
var report = new Report
{
Days = days, TotalDecisions = total, WithOutcome = rows.Count, LiveBids = liveBids, LiveWins = liveWins
};
report.Policies.AddRange(new[] { acting, alt, always, never });
report.Text = Format(report);
return report;
}
public static string Format(Report r)
{
var sb = new StringBuilder();
var c = System.Globalization.CultureInfo.GetCultureInfo("it-IT");
sb.AppendLine($"Decisioni negli ultimi {r.Days} giorni: {r.TotalDecisions:N0}, con esito noto {r.WithOutcome:N0}. " +
$"Puntate dal vivo: {r.LiveBids:N0}, di cui vincenti {r.LiveWins:N0}.");
sb.AppendLine();
sb.AppendLine(" policy decisioni puntate vincenti spesi € incassati € netto € ROI/puntata IC95% inf. EV previsto");
sb.AppendLine(" ------------------------------------------------------------------------------------------------------------------------");
foreach (var p in r.Policies)
{
sb.AppendLine(string.Format(c,
" {0,-30} {1,9} {2,8} {3,9} {4,9:F2} {5,12:F2} {6,9:F2} {7,12:F3} {8,10} {9,12}",
p.Name, p.Decisions, p.Bids, p.Wins, p.Spent, p.Gained, p.Net, p.RoiPerBid,
double.IsNaN(p.RoiLower95) ? "—" : p.RoiLower95.ToString("F3", c),
p.Name.Contains("in uso") ? p.ForecastEv.ToString("F2", c) : "—"));
}
sb.AppendLine();
sb.AppendLine(" Il null-model teorico (Augenblick 2016) prevede ROI per puntata = 0. Una policy batte il caso solo se");
sb.AppendLine(" l'estremo inferiore dell'intervallo di confidenza è sopra zero, e su abbastanza puntate da contare.");
sb.AppendLine(" «Vincenti» = il prezzo non è più salito dopo quell'istante: una puntata lì sarebbe stata l'ultima.");
sb.AppendLine(" Avvertenza: una puntata nostra può far rispondere chi stava per smettere; i dati non lo dicono.");
return sb.ToString();
}
}
}
+193
View File
@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder.Ml
{
/// <summary>
/// Il laboratorio: mette le policy a confronto nel simulatore, su aste identiche
/// (stessi semi), e scrive un rapporto. Le policy: non puntare mai, puntare sempre
/// all'anticipo, la regola del valore atteso con P empirica, il bandit Thompson che
/// impara giocando, e l'agente Q addestrato nel simulatore.
///
/// <para>La taratura viene dal database quando ci sono abbastanza aste; altrimenti
/// dai predefiniti, e il rapporto lo dice. Il simulatore non è Bidoo: il confronto
/// fra policy è ciò che vale, non i numeri assoluti.</para>
/// </summary>
public static class SimulationLab
{
public static string Run(AuctionDatabase? db, AppSettings settings, int auctions, Action<string>? progress, CancellationToken ct)
{
var sb = new StringBuilder();
var cal = db != null ? PennyAuctionEnv.Calibration.FromDatabase(db) : new PennyAuctionEnv.Calibration();
cal.BidCost = settings.AverageBidCostEuro > 0 ? settings.AverageBidCostEuro : cal.BidCost;
cal.TransactionFee = settings.TransactionFeeEuro;
var leadSeconds = Math.Clamp(LatencyModel.RecommendedLeadMs(settings) / 1000.0, 0.3, 3.0);
sb.AppendLine("SIMULATORE — confronto fra policy su aste identiche");
sb.AppendLine();
sb.AppendLine(cal.Auctions > 0
? $" Taratura da {cal.Auctions:N0} aste del database: {cal.MeanBidders:F1} puntatori per asta, autopuntate {cal.AutoShare:P0}, " +
$"chiusura mediana al {cal.MedianFinalPriceRatio:P1} del valore ({cal.Value:F2} €), puntata {cal.BidCost:F2} €."
: $" Nessuna asta nel database: taratura predefinita (valore {cal.Value:F2} €, {cal.MeanBidders:F0} puntatori, autopuntate {cal.AutoShare:P0}).");
sb.AppendLine($" Anticipo di puntata: {leadSeconds:F2} s (dal modello di latenza). Aste simulate per policy: {auctions:N0}.");
sb.AppendLine();
ct.ThrowIfCancellationRequested();
progress?.Invoke("policy di base…");
var results = new List<QLearningAgent.PolicyResult>
{
QLearningAgent.Evaluate("non punta mai", cal, (in PennyAuctionEnv.Observation _) => null, auctions),
QLearningAgent.Evaluate("punta sempre", cal, (in PennyAuctionEnv.Observation o) => o.ImLeading ? null : leadSeconds, auctions)
};
// P empirica per contesto, misurata proprio in questo simulatore: la quota di
// nostre puntate rimaste senza risposta, per numero di avversari attivi e
// fascia di prezzo. È il «modello» della regola del valore atteso.
ct.ThrowIfCancellationRequested();
progress?.Invoke("stima della P empirica…");
var empirical = EmpiricalP(cal, leadSeconds, Math.Max(200, auctions), seed: 5000);
double? EvRule(in PennyAuctionEnv.Observation o)
{
if (o.ImLeading) return null;
var p = empirical(o);
var s = new Theory.BidSituation(p, cal.Value, o.Price, cal.BidCost, cal.TransactionFee, cal.Shipping, cal.Value, o.MyBidsEuro, settings.BuyNowHedgeEnabled);
return Theory.BidExpectedValue(s) > 0 ? leadSeconds : null;
}
results.Add(QLearningAgent.Evaluate("valore atteso (P empirica)", cal, EvRule, auctions));
ct.ThrowIfCancellationRequested();
progress?.Invoke("bandit Thompson…");
results.Add(BanditPolicy(cal, leadSeconds, auctions, settings.BuyNowHedgeEnabled));
ct.ThrowIfCancellationRequested();
progress?.Invoke("addestramento dell'agente Q…");
var agent = new QLearningAgent(seed: 7);
var episodes = Math.Clamp(auctions * 2, 300, 1500);
for (var i = 0; i < episodes; i++)
{
if (i % 100 == 0) ct.ThrowIfCancellationRequested();
agent.TrainEpisode(new PennyAuctionEnv(cal, 10_000 + i), leadSeconds);
}
results.Add(QLearningAgent.Evaluate($"agente Q ({episodes} episodi)", cal, (in PennyAuctionEnv.Observation o) => agent.Act(o) == 1 ? leadSeconds : null, auctions));
sb.AppendLine(QLearningAgent.Format(results));
sb.AppendLine();
sb.AppendLine(" Il null-model prevede ROI per puntata = 0. Qui gli avversari sintetici hanno il costo affondato degli");
sb.AppendLine(" umani (Augenblick): una policy che guadagna lo fa sfruttando quello, non un vantaggio strutturale.");
sb.AppendLine(" I numeri assoluti sono del simulatore; il confronto fra policy è ciò che si porta a casa.");
return sb.ToString();
}
/// <summary>P(senza risposta) per contesto, misurata puntando sempre in aste simulate.</summary>
private static QLearningAgent.Policy EmpiricalPPolicy(Func<PennyAuctionEnv.Observation, double> p, double lead) =>
(in PennyAuctionEnv.Observation o) => p(o) > 0 ? lead : null;
private static Func<PennyAuctionEnv.Observation, double> EmpiricalP(PennyAuctionEnv.Calibration cal, double lead, int auctions, int seed)
{
var wins = new Dictionary<(int, int), int>();
var tries = new Dictionary<(int, int), int>();
for (var i = 0; i < auctions; i++)
{
var env = new PennyAuctionEnv(cal, seed + i);
var obs = env.Reset();
(int, int)? lastKey = null;
var steps = 0;
while (steps++ < 2000)
{
if (!obs.ImLeading)
{
var key = Key(obs);
tries[key] = tries.GetValueOrDefault(key) + 1;
lastKey = key;
var r = env.Step(lead);
if (r.Done) { if (r.Won && lastKey != null) wins[lastKey.Value] = wins.GetValueOrDefault(lastKey.Value) + 1; break; }
obs = r.Next;
}
else
{
var r = env.Step(null);
if (r.Done) { if (r.Won && lastKey != null) wins[lastKey.Value] = wins.GetValueOrDefault(lastKey.Value) + 1; break; }
obs = r.Next;
}
}
}
var overall = tries.Values.Sum() > 0 ? (double)wins.Values.Sum() / tries.Values.Sum() : 0.05;
return o =>
{
var k = Key(o);
var n = tries.GetValueOrDefault(k);
if (n < 20) return overall;
return (double)wins.GetValueOrDefault(k) / n;
};
static (int, int) Key(in PennyAuctionEnv.Observation o) =>
(Math.Min(5, o.ActiveOpponents), o.PriceRatio < 0.02 ? 0 : o.PriceRatio < 0.05 ? 1 : o.PriceRatio < 0.10 ? 2 : o.PriceRatio < 0.2 ? 3 : 4);
}
/// <summary>Il bandit impara giocando: a fine asta sa quale delle sue puntate è rimasta senza risposta.</summary>
private static QLearningAgent.PolicyResult BanditPolicy(PennyAuctionEnv.Calibration cal, double lead, int auctions, bool hedge)
{
var bandit = new ThompsonBandit { BaseRate = 0.05, PriorStrength = 2 };
var rng = new Random(77);
int wins = 0, bids = 0;
double spent = 0, gained = 0, balance = 0, peak = 0, drawdown = 0;
for (var i = 0; i < auctions; i++)
{
var env = new PennyAuctionEnv(cal, 1000 + i); // stessi semi di Evaluate
var obs = env.Reset();
var keys = new List<string>();
var steps = 0;
while (steps++ < 2000)
{
double? action = null;
string? key = null;
if (!obs.ImLeading)
{
key = $"o{Math.Min(5, obs.ActiveOpponents)}|r{(obs.PriceRatio < 0.02 ? 0 : obs.PriceRatio < 0.05 ? 1 : obs.PriceRatio < 0.10 ? 2 : obs.PriceRatio < 0.2 ? 3 : 4)}|a{(obs.AutoBidPresent ? 1 : 0)}";
var s = new Theory.BidSituation(0, cal.Value, obs.Price, cal.BidCost, cal.TransactionFee, cal.Shipping, cal.Value, obs.MyBidsEuro, hedge);
var (bid, _) = bandit.Decide(key, s, rng);
if (bid) action = lead;
}
var r = env.Step(action);
if (action != null && key != null) { keys.Add(key); bids++; spent += cal.BidCost; }
if (r.Reward > 0) gained += r.Reward;
balance += r.Reward;
if (balance > peak) peak = balance;
if (peak - balance > drawdown) drawdown = peak - balance;
if (r.Done)
{
if (r.Won) wins++;
// Tutte le puntate tranne l'ultima sono state coperte; l'ultima
// è rimasta senza risposta solo se abbiamo vinto.
for (var k = 0; k < keys.Count; k++) bandit.Observe(keys[k], r.Won && k == keys.Count - 1);
break;
}
obs = r.Next;
}
}
return new QLearningAgent.PolicyResult
{
Name = "bandit Thompson (impara)", Auctions = auctions, Wins = wins, Bids = bids,
Spent = spent, Gained = gained, MaxDrawdown = drawdown
};
}
}
}
+219
View File
@@ -0,0 +1,219 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AutoBidder.Ml
{
/// <summary>
/// Il modello matematico delle aste al centesimo, in formule esplicite e con la
/// fonte accanto. Tutto ciò che il motore calcola in euro passa di qui.
///
/// <para><b>Il fatto da cui partire.</b> Augenblick (2016, <i>Review of Economic
/// Studies</i> 83(1)), su 166.000 aste: il margine medio del banco supera il 50% del
/// valore del bene; il 75% dei puntatori abbandona prima di 50 puntate. Platt, Price e
/// Tappen (2013) e Hinnosaar (2016): in ogni equilibrio che non finisce subito, il
/// profitto atteso di ogni puntata è <b>zero</b>. Un vantaggio, se esiste, viene solo
/// dall'irrazionalità altrui — costo affondato, gusto per il rischio — non dalla
/// struttura del gioco. È il <i>null-model</i>: ogni stima di profittabilità va
/// confrontata con zero, e se lo supera con sistematicità va guardata con sospetto.</para>
/// </summary>
public static class Theory
{
/// <summary>Incremento di prezzo per puntata su Bidoo: un centesimo, verificato.</summary>
public const double PriceIncrement = 0.01;
/// <summary>Profitto atteso per puntata nell'equilibrio teorico (Augenblick 2016; Platt-Price-Tappen 2013).</summary>
public const double NullModelProfitPerBid = 0.0;
// ── 3.1 Valore atteso di una puntata ─────────────────────────────
/// <summary>
/// Ciò che serve a valutare una puntata in euro.
/// </summary>
/// <param name="PWin">Probabilità che, puntando adesso, nessuno rilanci prima della scadenza.</param>
/// <param name="Value">Valore reale V del bene (quello scelto dall'utente; non il listino gonfiato).</param>
/// <param name="CurrentPrice">Prezzo corrente dell'asta.</param>
/// <param name="BidCost">Costo della puntata c.</param>
/// <param name="TransactionFee">Fee di transazione sulla vincita (~1 € su Bidoo).</param>
/// <param name="Shipping">Spedizione.</param>
/// <param name="ListPrice">Prezzo «Compralo Ora» L, per la copertura. Null = nessuna copertura.</param>
/// <param name="BidsSpentEuro">Quanto si è già speso in puntate su quest'asta.</param>
/// <param name="HedgeEnabled">La copertura «Compralo Ora» va considerata.</param>
public readonly record struct BidSituation(
double PWin,
double Value,
double CurrentPrice,
double BidCost,
double TransactionFee,
double Shipping,
double? ListPrice,
double BidsSpentEuro,
bool HedgeEnabled);
/// <summary>
/// Valore atteso in euro di una puntata fatta adesso.
///
/// <para>Senza copertura:
/// <c>EV = P · (V (p + Δ) fee spedizione c) (1 P) · c</c>.
/// La puntata si paga in entrambi i rami — la piattaforma non la rimborsa — quindi
/// il costo c sta sia sotto P sia sotto (1 P). È la forma corretta della formula
/// di §3.1 del documento di progetto, che lo metteva solo nel ramo di sconfitta.</para>
///
/// <para>Con la copertura «Compralo Ora»: chi perde può comprare al listino L
/// recuperando le puntate spese come credito. La perdita finale, se si esercita,
/// è <c>L V</c> qualunque sia il numero di puntate; si esercita quando conviene,
/// cioè quando le puntate spese superano <c>L V</c>. Da quel punto in poi il
/// ramo di sconfitta non costa più nulla in più per ogni puntata: il suo costo è
/// già coperto. Quindi il costo marginale nel ramo di sconfitta è c finché
/// <c>spese + c ≤ L V</c>, zero dopo.</para>
///
/// <para>Con V = L la copertura è totale dal primo centesimo: ogni puntata ha
/// valore atteso non negativo appena il margine è positivo, a qualunque
/// probabilità — cioè si punta sempre. È un'ipotesi forte — presume che si
/// eserciti davvero il Compralo Ora e che il bene valga davvero il listino — e per
/// questo la strategia la applica solo quando l'utente ha scritto il valore reale
/// del prodotto (<c>BidStrategyService.HasExplicitValue</c>): con V lasciato al
/// listino la puntata si conta persa, come senza copertura.</para>
/// </summary>
public static double BidExpectedValue(in BidSituation s)
{
var p = Math.Clamp(s.PWin, 0, 1);
var gainIfWin = s.Value - (s.CurrentPrice + PriceIncrement) - s.TransactionFee - s.Shipping - s.BidCost;
var lossCost = LossBranchCost(s);
return p * gainIfWin - (1 - p) * lossCost;
}
/// <summary>Quanto costa questa puntata nel ramo di sconfitta, tenuto conto della copertura.</summary>
public static double LossBranchCost(in BidSituation s)
{
if (!s.HedgeEnabled || s.ListPrice is not > 0) return s.BidCost;
var boundedLoss = Math.Max(0, s.ListPrice.Value - s.Value);
return s.BidsSpentEuro + s.BidCost <= boundedLoss ? s.BidCost : 0;
}
/// <summary>
/// La regola operativa di §3.1: si punta se il guadagno atteso condizionato alla
/// vittoria supera il costo. Equivale a <c>BidExpectedValue &gt; 0</c>.
/// </summary>
public static bool ShouldBid(in BidSituation s) => BidExpectedValue(s) > 0;
/// <summary>
/// La probabilità di vittoria minima perché la puntata abbia valore atteso nullo:
/// il punto di pareggio. Sopra si guadagna in media, sotto si perde.
/// </summary>
public static double BreakEvenPWin(in BidSituation s)
{
var gainIfWin = s.Value - (s.CurrentPrice + PriceIncrement) - s.TransactionFee - s.Shipping - s.BidCost;
var lossCost = LossBranchCost(s);
var denom = gainIfWin + lossCost;
if (denom <= 0) return 1; // non conviene neanche vincendo
return Math.Clamp(lossCost / denom, 0, 1);
}
// ── 3.2 Equilibrio a profitto nullo ──────────────────────────────
/// <summary>
/// Il rapporto critico V/c (Hinnosaar 2016): se i puntatori attivi sono più di
/// questo numero, il payoff atteso di almeno uno è zero, e in ogni equilibrio
/// simmetrico lo è per tutti.
/// </summary>
public static double CriticalBidders(double value, double bidCost) =>
bidCost > 0 ? value / bidCost : double.PositiveInfinity;
// ── 3.3 Sopravvivenza e hazard empirico ──────────────────────────
/// <summary>
/// Un ciclo del timer osservato: fin dove è sceso il cronometro prima che
/// arrivasse la puntata successiva, e se invece nessuno ha puntato e l'asta è
/// finita. Sono le righe della tabella <c>resets</c>, più l'ultimo ciclo di ogni
/// asta.
/// </summary>
public readonly record struct CycleObservation(double MinTimerSeconds, bool EndedAuction);
/// <summary>
/// Probabilità empirica che, arrivati con il cronometro a <paramref name="leadSeconds"/>
/// senza che nessuno abbia puntato, nessuno punti fino alla fine — cioè la
/// P(vittoria) di una puntata piazzata a quell'anticipo, stimata come frequenza.
///
/// <para>È lo stimatore di sopravvivenza condizionata: fra i cicli che sono
/// scesi almeno fino a <c>lead</c>, la quota di quelli che hanno chiuso l'asta.
/// Un ciclo che ha chiuso l'asta è sceso fino a zero, quindi passa da ogni
/// soglia. Nulla quando non ci sono cicli abbastanza profondi da dirlo.</para>
/// </summary>
public static double? WinProbabilityAtLead(IEnumerable<CycleObservation> cycles, double leadSeconds, int minCycles = 20)
{
var reached = 0;
var survived = 0;
foreach (var c in cycles)
{
if (c.EndedAuction) { reached++; survived++; continue; }
if (c.MinTimerSeconds <= leadSeconds) reached++;
}
return reached >= minCycles ? (double)survived / reached : null;
}
/// <summary>
/// Curva di sopravvivenza di Kaplan-Meier del ciclo: per ogni profondità, la
/// probabilità che il cronometro scenda fin lì senza puntate. Le osservazioni
/// «censurate» sono i cicli che hanno chiuso l'asta (si sa che hanno superato
/// tutto). Restituisce coppie (profondità in secondi dalla scadenza, S).
/// </summary>
public static List<(double TimerSeconds, double Survival)> KaplanMeier(IEnumerable<CycleObservation> cycles)
{
// Il «tempo» è quanto il ciclo è sceso: dal timer pieno verso zero. Un evento
// è una puntata che ferma la discesa a MinTimer; una censura è il ciclo che
// arriva a zero (fine asta).
var obs = cycles.ToList();
var result = new List<(double, double)>();
if (obs.Count == 0) return result;
var events = obs.Where(c => !c.EndedAuction).GroupBy(c => Math.Round(c.MinTimerSeconds, 1))
.Select(g => (Timer: g.Key, Count: g.Count()))
.OrderByDescending(e => e.Timer) // la discesa va dal timer alto al basso
.ToList();
var survival = 1.0;
var atRisk = obs.Count;
foreach (var (timer, count) in events)
{
if (atRisk <= 0) break;
survival *= 1.0 - (double)count / atRisk;
result.Add((timer, survival));
atRisk -= count;
}
return result;
}
// ── 3.4 Processo di arrivo delle puntate ─────────────────────────
/// <summary>
/// Con arrivi di Poisson a intensità λ (puntate al secondo), la probabilità che
/// nessuno punti nei prossimi τ secondi: <c>exp(−λ τ)</c>. Alimenta P(vittoria)
/// quando si conosce solo il ritmo recente delle puntate.
/// </summary>
public static double NoBidProbability(double lambdaPerSecond, double tauSeconds) =>
lambdaPerSecond <= 0 || tauSeconds <= 0 ? 1.0 : Math.Exp(-lambdaPerSecond * tauSeconds);
/// <summary>
/// Intensità stimata dagli ultimi intervalli fra puntate (secondi): l'inverso
/// della media. Null senza intervalli.
/// </summary>
public static double? ArrivalRate(IEnumerable<double> recentGapsSeconds)
{
var gaps = recentGapsSeconds.Where(g => g > 0).ToList();
if (gaps.Count == 0) return null;
return 1.0 / gaps.Average();
}
// ── Contabilità ──────────────────────────────────────────────────
/// <summary>Profitto realizzato da una vincita: valore reale meno tutto ciò che è costata.</summary>
public static double RealizedWinProfit(double value, double finalPrice, double bidsSpentEuro, double transactionFee, double shipping) =>
value - finalPrice - bidsSpentEuro - transactionFee - shipping;
}
}
+210
View File
@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace AutoBidder.Ml
{
/// <summary>
/// Un bandit contestuale con campionamento di Thompson (Agrawal e Goyal, 2013) per
/// la scelta PUNTA / NON PUNTARE.
///
/// <para><b>Come funziona.</b> Il contesto — durata del ciclo, prezzo sul valore,
/// fascia oraria, puntatori distinti — viene ridotto a una chiave; per ogni chiave si
/// tiene una distribuzione Beta(α, β) sulla probabilità che una puntata resti senza
/// risposta. Al momento di decidere si <i>estrae</i> una probabilità dalla Beta
/// invece di usarne la media, e si applica la regola del valore atteso con quella.
/// Dove si è visto poco, la Beta è larga e l'estrazione varia: è l'esplorazione, che
/// si spegne da sola man mano che i dati arrivano.</para>
///
/// <para><b>Da cosa impara.</b> Da ogni decisione registrata, anche quelle in cui non
/// si è puntato: alla chiusura dell'asta si sa se il prezzo è rimasto fermo dopo
/// quell'istante (una puntata sarebbe stata l'ultima) oppure no. È informazione
/// completa, non solo sull'azione presa — cosa rara per un bandit, e che qui rende
/// l'apprendimento molto più rapido.</para>
///
/// <para>È la seconda policy, accanto a quella del modello: gira in shadow su ogni
/// decisione e si confronta con l'altra sugli esiti. Vedi <c>LEGGIMI.md</c>.</para>
/// </summary>
public sealed class ThompsonBandit
{
private sealed class Arm
{
public double Alpha;
public double Beta;
}
private readonly Dictionary<string, Arm> _arms = new(StringComparer.Ordinal);
private readonly object _sync = new();
/// <summary>Peso del prior: equivale a tante osservazioni fittizie al tasso di base.</summary>
public double PriorStrength { get; set; } = 4;
/// <summary>Tasso di base P(senza risposta) usato come prior dove non si è visto nulla.</summary>
public double BaseRate { get; set; } = 0.02;
public int Arms { get { lock (_sync) return _arms.Count; } }
public long Observations { get; private set; }
/// <summary>La chiave del contesto: le fasce che contano di più, poche per avere dati in ognuna.</summary>
public static string KeyOf(in BidContext c) =>
$"g{BidFeatures.GapBucket(Math.Max(0, c.CycleSeconds))}|r{BidFeatures.RatioBucket(c.PriceRatio)}|h{BidFeatures.HourBand(c.Hour)}|b{BidFeatures.BiddersBucket(c.DistinctBiddersRecent)}|a{(c.AutoShareRecent >= 0.5 ? 1 : 0)}";
private Arm ArmFor(string key)
{
if (!_arms.TryGetValue(key, out var arm))
{
arm = new Arm { Alpha = BaseRate * PriorStrength, Beta = (1 - BaseRate) * PriorStrength };
_arms[key] = arm;
}
return arm;
}
/// <summary>Aggiorna il braccio del contesto con un esito osservato.</summary>
public void Observe(string key, bool unanswered)
{
lock (_sync)
{
var arm = ArmFor(key);
if (unanswered) arm.Alpha += 1; else arm.Beta += 1;
Observations++;
}
}
/// <summary>La media a posteriori per il contesto.</summary>
public double Mean(string key)
{
lock (_sync)
{
var arm = ArmFor(key);
return arm.Alpha / (arm.Alpha + arm.Beta);
}
}
/// <summary>Quante osservazioni vere ha visto questo contesto.</summary>
public double Evidence(string key)
{
lock (_sync)
{
var arm = ArmFor(key);
return arm.Alpha + arm.Beta - PriorStrength;
}
}
/// <summary>Un'estrazione dalla Beta del contesto: è la probabilità «di oggi».</summary>
public double Sample(string key, Random rng)
{
double a, b;
lock (_sync)
{
var arm = ArmFor(key);
a = arm.Alpha; b = arm.Beta;
}
var x = GammaSample(a, rng);
var y = GammaSample(b, rng);
return x + y > 0 ? x / (x + y) : 0;
}
/// <summary>
/// La decisione: si estrae p e si punta se il valore atteso con quel p è positivo.
/// Restituisce anche il p estratto, per il registro.
/// </summary>
public (bool Bid, double SampledP) Decide(string key, Theory.BidSituation situation, Random rng)
{
var p = Sample(key, rng);
var s = situation with { PWin = p };
return (Theory.BidExpectedValue(s) > 0, p);
}
/// <summary>Campione da Gamma(shape, 1) con il metodo di Marsaglia e Tsang.</summary>
private static double GammaSample(double shape, Random rng)
{
if (shape < 1)
{
// Gamma(a) = Gamma(a+1) · U^(1/a)
var u = rng.NextDouble();
return GammaSample(shape + 1, rng) * Math.Pow(Math.Max(u, 1e-300), 1.0 / shape);
}
var d = shape - 1.0 / 3.0;
var c = 1.0 / Math.Sqrt(9.0 * d);
while (true)
{
double x, v;
do
{
x = Gaussian(rng);
v = 1.0 + c * x;
} while (v <= 0);
v = v * v * v;
var u = rng.NextDouble();
if (u < 1 - 0.0331 * x * x * x * x) return d * v;
if (Math.Log(u) < 0.5 * x * x + d * (1 - v + Math.Log(v))) return d * v;
}
}
private static double Gaussian(Random rng)
{
var u1 = 1.0 - rng.NextDouble();
var u2 = rng.NextDouble();
return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
}
// ── Persistenza ──────────────────────────────────────────────────
private sealed class Snapshot
{
public double PriorStrength { get; set; }
public double BaseRate { get; set; }
public long Observations { get; set; }
public Dictionary<string, double[]> Arms { get; set; } = new();
}
public string ToJson()
{
lock (_sync)
{
return JsonSerializer.Serialize(new Snapshot
{
PriorStrength = PriorStrength,
BaseRate = BaseRate,
Observations = Observations,
Arms = _arms.ToDictionary(kv => kv.Key, kv => new[] { kv.Value.Alpha, kv.Value.Beta })
});
}
}
public static ThompsonBandit FromJson(string json)
{
var bandit = new ThompsonBandit();
try
{
var s = JsonSerializer.Deserialize<Snapshot>(json);
if (s == null) return bandit;
bandit.PriorStrength = s.PriorStrength > 0 ? s.PriorStrength : 4;
bandit.BaseRate = s.BaseRate > 0 ? s.BaseRate : 0.02;
bandit.Observations = s.Observations;
foreach (var kv in s.Arms)
bandit._arms[kv.Key] = new Arm { Alpha = kv.Value[0], Beta = kv.Value[1] };
}
catch { }
return bandit;
}
/// <summary>Le braccia con più evidenza, per la scheda.</summary>
public IReadOnlyList<(string Key, double Mean, double Evidence)> TopArms(int max = 30)
{
lock (_sync)
{
return _arms
.Select(kv => (kv.Key, kv.Value.Alpha / (kv.Value.Alpha + kv.Value.Beta), kv.Value.Alpha + kv.Value.Beta - PriorStrength))
.OrderByDescending(t => t.Item3)
.Take(max)
.ToList();
}
}
}
}
+78 -61
View File
@@ -42,6 +42,14 @@ namespace AutoBidder.Models
/// Es: 200ms = punta 200ms prima che il timer raggiunga 0.
/// </summary>
public int BidBeforeDeadlineMs { get; set; } = 200;
/// <summary>
/// L'anticipo di quest'asta è stato scritto a mano dall'utente. Solo allora
/// <see cref="BidBeforeDeadlineMs"/> comanda: altrimenti il cecchino lo lascia
/// decidere al modello di latenza (vedi Ml/LatencyModel) e scrive qui il valore
/// scelto, così la griglia mostra l'anticipo davvero in uso.
/// </summary>
public bool BidLeadIsManual { get; set; }
public double MinPrice { get; set; } = 0;
public double MaxPrice { get; set; } = 0;
@@ -528,43 +536,77 @@ namespace AutoBidder.Models
public double AverageLatencyMs => LatencyHistory.Count > 0
? LatencyHistory.Average()
: PollingLatencyMs > 0 ? PollingLatencyMs : 60;
// ── Duello con l'autopuntata avversaria ──────────────────────────
// Vedi Utilities/AutoBidDuel per il perche' e per i numeri misurati.
/// <summary>Istante dell'ultima puntata nostra, per misurare quanto tarda la risposta.</summary>
[JsonIgnore]
public DateTime? LastMyBidAtUtc { get; set; }
/// <summary>
/// Heat metric (0-100) che indica quanto "calda" l'asta
/// Calcolato in base a: bidder attivi, frequenza puntate, collisioni
/// Risposte avversarie di fila con la firma dell'autopuntata (~6 s). Oltre la
/// soglia si smette di puntare su quest'asta: vedi <see cref="AutoBidDuelDetected"/>.
/// </summary>
[JsonIgnore]
public int HeatMetric { get; set; } = 0;
public int AutoResponsesInARow { get; set; }
/// <summary>Riconosciuta un'autopuntata armata dall'altra parte.</summary>
[JsonIgnore]
public bool AutoBidDuelDetected { get; set; }
/// <summary>
/// Numero di bidder unici attivi negli ultimi N secondi
/// Il regime di concorrenza di quest'asta: quando lasciar sfogare gli altri e
/// quando tornare a puntare. Vedi <see cref="Ml.CompetitionRegime"/>.
/// Stato di sessione: al riavvio riparte Calmo, e va bene così.
/// </summary>
[JsonIgnore]
public int ActiveBiddersCount { get; set; } = 0;
public Ml.CompetitionRegime Regime { get; } = new();
// ── Apprendimento: l'ultima risposta del modello per quest'asta ──
// Vedi Ml/LearningService. Non si salvano: valgono per l'istante in cui sono state
// calcolate, e al riavvio non ci sarebbe piu' lo stato che le ha prodotte.
/// <summary>
/// Numero di collisioni rilevate (puntate nello stesso secondo)
/// La decisione registrata nel database per il ciclo in corso: la puntata che
/// parte porta questo id, così esito e decisione restano legati.
/// </summary>
[JsonIgnore]
public int CollisionCount { get; set; } = 0;
/// <summary>
/// Collisioni consecutive senza puntata vincente
/// </summary>
public long? PendingDecisionId { get; set; }
/// <summary>Probabilità che una puntata fatta adesso resti senza risposta.</summary>
[JsonIgnore]
public int ConsecutiveCollisions { get; set; } = 0;
/// <summary>
/// Timestamp dell'ultimo soft retreat
/// </summary>
public double? LearnedUnansweredProbability { get; set; }
/// <summary>Valore atteso in euro di una puntata fatta adesso: sotto zero si perde.</summary>
[JsonIgnore]
public DateTime? LastSoftRetreatAt { get; set; }
public double? LearnedExpectedValue { get; set; }
/// <summary>
/// Se true, l'asta in soft retreat temporaneo
/// Registra la risposta avversaria arrivata adesso e aggiorna il conteggio.
/// Restituisce true se proprio con questa risposta il duello viene riconosciuto,
/// cosi' chi chiama puo' scriverlo nel registro una volta sola.
/// </summary>
[JsonIgnore]
public bool IsInSoftRetreat { get; set; } = false;
public bool NoteOpponentResponse(DateTime nowUtc, int soglia)
{
if (LastMyBidAtUtc is not { } miaUltima) return false;
var ritardo = (nowUtc - miaUltima).TotalSeconds;
// Consumata: la prossima risposta va misurata dalla prossima puntata nostra.
LastMyBidAtUtc = null;
AutoResponsesInARow = Utilities.AutoBidDuel.Aggiorna(AutoResponsesInARow, ritardo);
// Il regime vuole sapere se una puntata di prova è stata coperta subito.
Regime.RispostaAvversaria(ritardo);
if (AutoBidDuelDetected) return false;
if (!Utilities.AutoBidDuel.DuelloRiconosciuto(AutoResponsesInARow, soglia)) return false;
AutoBidDuelDetected = true;
return true;
}
/// <summary>
/// Contatore puntate effettuate in questa sessione su questa asta
@@ -590,12 +632,6 @@ namespace AutoBidder.Models
[JsonIgnore]
public int FailedBidCount { get; set; } = 0;
/// <summary>
/// Lista utenti identificati come aggressivi in questa asta
/// </summary>
[JsonIgnore]
public HashSet<string> AggressiveBidders { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Offset dinamico calcolato per questa asta (ms)
/// </summary>
@@ -608,6 +644,13 @@ namespace AutoBidder.Models
[JsonIgnore]
public int LastUsedOffsetMs { get; set; } = 0;
/// <summary>
/// La chiusura è già stata comunicata a storico, statistiche e apprendimento.
/// Si salva con l'asta: un'asta conclusa che resta in elenco fino al riavvio non
/// deve essere contata di nuovo.
/// </summary>
public bool CompletionReported { get; set; }
/// <summary>
/// Indica se questa asta stata seguita dall'inizio (per salvare storia completa)
/// </summary>
@@ -647,21 +690,17 @@ namespace AutoBidder.Models
// IMPOSTAZIONI PER-ASTA (override globali)
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Override: abilita/disabilita strategie avanzate per questa asta
/// null = usa impostazione globale
/// </summary>
public bool? AdvancedStrategiesEnabled { get; set; }
/// <summary>
/// Override: abilita/disabilita jitter per questa asta
/// </summary>
public bool? JitterEnabledOverride { get; set; }
/// <summary>
/// Override: abilita/disabilita soft retreat per questa asta
/// Override per questa asta della sospensione a fascia oraria.
/// <c>null</c> = come dice il prodotto, e se il prodotto tace come dicono le
/// impostazioni globali. Vedi <see cref="Utilities.BiddingHours"/>.
/// </summary>
public bool? SoftRetreatEnabledOverride { get; set; }
public bool? QuietHoursEnabledOverride { get; set; }
/// <summary>
/// Override: limite puntate per questa asta
@@ -670,25 +709,6 @@ namespace AutoBidder.Models
// ?? NUOVO: Rilevamento situazione di duello
/// <summary>
/// True se rilevata situazione di duello (solo 2 bidder dominanti)
/// </summary>
[JsonIgnore]
public bool IsDuelSituation { get; set; } = false;
/// <summary>
/// Username dell'avversario in caso di duello
/// </summary>
[JsonIgnore]
public string? DuelOpponent { get; set; }
/// <summary>
/// Vantaggio/svantaggio nel duello (% puntate mie - % puntate avversario)
/// Positivo = sto dominando, Negativo = sto perdendo
/// </summary>
[JsonIgnore]
public double DuelAdvantage { get; set; } = 0;
// ???????????????????????????????????????????????????????????????????
// GESTIONE MEMORIA
// ???????????????????????????????????????????????????????????????????
@@ -715,13 +735,10 @@ namespace AutoBidder.Models
LatencyHistory?.Clear();
LatencyHistory = null!;
AggressiveBidders?.Clear();
AggressiveBidders = null!;
// Pulisci oggetti complessi
LastState = null;
CalculatedValue = null;
DuelOpponent = null;
WinLimitDescription = null;
// Reset flag
+29 -3
View File
@@ -14,11 +14,37 @@ namespace AutoBidder.Models
[JsonPropertyName("Price")]
public decimal Price { get; set; }
/// <summary>Puntata fatta dall'autopuntata del sito: scatta a 2 secondi dalla fine.</summary>
public const string TipoAuto = "Auto";
/// <summary>Puntata fatta a mano. Vale 3,6 volte una automatica: vedi <see cref="Utilities.AutoBidDuel"/>.</summary>
public const string TipoManuale = "Manuale";
/// <summary>
/// Tipo di puntata (Auto/Manuale)
/// Il tipo non lo ha detto il server.
///
/// <para>Serve perché non tutte le puntate in elenco vengono dallo storico di
/// <c>data.php</c>: quando il motore si accorge di un cambio di prezzo fra due
/// interrogazioni ne ricostruisce una da sé, e di quella il tipo non lo sa. Prima
/// venivano marcate "Auto" comunque, il che faceva contare come automatiche delle
/// puntate su cui nessuno aveva detto niente.</para>
/// </summary>
public const string TipoSconosciuto = "—";
/// <summary>
/// Tipo di puntata: <see cref="TipoAuto"/>, <see cref="TipoManuale"/> oppure
/// <see cref="TipoSconosciuto"/>. Lo dichiara Bidoo nello storico dell'asta.
/// </summary>
[JsonPropertyName("BidType")]
public string BidType { get; set; } = "Auto";
public string BidType { get; set; } = TipoSconosciuto;
/// <summary>Vero solo se il server ha davvero detto che era automatica.</summary>
[JsonIgnore]
public bool IsAuto => string.Equals(BidType, TipoAuto, StringComparison.OrdinalIgnoreCase);
/// <summary>Vero solo se il server ha davvero detto che era manuale.</summary>
[JsonIgnore]
public bool IsManual => string.Equals(BidType, TipoManuale, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Timestamp della puntata (Unix timestamp)
@@ -55,7 +81,7 @@ namespace AutoBidder.Models
}
/// <summary>
/// Indica se la puntata è stata fatta dall'utente corrente
/// Indica se la puntata stata fatta dall'utente corrente
/// </summary>
[JsonIgnore]
public bool IsMyBid { get; set; }
+15
View File
@@ -32,12 +32,27 @@ namespace AutoBidder.Models
/// <summary>Prezzo minimo sotto il quale non puntare. <c>null</c> = predefinito.</summary>
public double? MinPrice { get; set; }
/// <summary>
/// Sospendere le puntate nella fascia oraria. <c>null</c> = come le impostazioni
/// generali. Un'asta può a sua volta scavalcare questo valore.
/// Vedi <see cref="Utilities.BiddingHours"/>.
/// </summary>
public bool? QuietHoursEnabled { get; set; }
/// <summary>Prezzo oltre il quale lasciar perdere l'asta. <c>null</c> = predefinito.</summary>
public double? MaxPrice { get; set; }
/// <summary>Tetto di puntate per singola asta. <c>null</c> = predefinito.</summary>
public int? MaxClicks { get; set; }
/// <summary>
/// Valore reale V del bene, scelto dall'utente. <c>null</c> = vale il «Compralo
/// Ora» di Bidoo. Il listino è gonfiato: un buono da 10 € spendibile solo su Bidoo
/// non vale 11 €. È il numero su cui si calcola il valore atteso di ogni puntata
/// (vedi Ml/Theory), quindi è il più importante di tutti.
/// </summary>
public double? MarketValue { get; set; }
/// <summary>
/// Stato con cui entra un'asta nuova di questo prodotto: <c>"Active"</c>,
/// <c>"Watch"</c> o <c>"Stopped"</c>. <c>null</c> = usa il predefinito generale.
-2
View File
@@ -1,2 +0,0 @@
Cose da fare:
- Nulla per ora
+70 -3
View File
@@ -111,6 +111,53 @@ namespace AutoBidder.Net
/// <summary>Aggiorna i limiti di ritmo a caldo, senza ricreare il trasporto.</summary>
public void Configure(double requestsPerSecond) => _limiter.SetRate(requestsPerSecond);
// ── Freno dettato dal server ─────────────────────────────────────────
//
// Senza tetto fisso serve qualcosa che reagisca, altrimenti l'unico segnale di
// "troppe richieste" sarebbe un blocco dell'account. Un 429 o un 503 mettono in
// pausa il polling per un secondo, raddoppiando fino a otto se insistono, e la
// pausa si azzera al primo giro andato bene.
//
// Le puntate hanno priorità Critical e non passano da qui: se il momento giusto
// è adesso, un freno preventivo costerebbe l'asta.
private const int FrenoMinimoMs = 1000;
private const int FrenoMassimoMs = 8000;
private long _frenoFinoA;
private int _frenoCorrenteMs = FrenoMinimoMs;
/// <summary>Millisecondi di pausa ancora da scontare. Zero quando si va liberi.</summary>
public int FrenoResiduoMs
{
get
{
var mancano = (Interlocked.Read(ref _frenoFinoA) - Stopwatch.GetTimestamp())
* 1000 / Stopwatch.Frequency;
return mancano > 0 ? (int)mancano : 0;
}
}
private void Frena()
{
var durata = Volatile.Read(ref _frenoCorrenteMs);
Interlocked.Exchange(ref _frenoFinoA,
Stopwatch.GetTimestamp() + durata * Stopwatch.Frequency / 1000);
Volatile.Write(ref _frenoCorrenteMs, Math.Min(FrenoMassimoMs, durata * 2));
}
private void Rilascia() => Volatile.Write(ref _frenoCorrenteMs, FrenoMinimoMs);
private ValueTask RispettaFrenoAsync(CancellationToken ct)
{
var mancano = FrenoResiduoMs;
return mancano <= 0
? ValueTask.CompletedTask
: new ValueTask(Task.Delay(mancano, ct));
}
/// <summary>Costruisce una GET già completa di header di autenticazione e di contesto.</summary>
public HttpRequestMessage BuildGet(string url, string? referer = null, bool ajax = true)
{
@@ -206,6 +253,7 @@ namespace AutoBidder.Net
if (!critical)
{
await RispettaFrenoAsync(ct).ConfigureAwait(false);
await _limiter.WaitAsync(ct).ConfigureAwait(false);
await _concurrency.WaitAsync(ct).ConfigureAwait(false);
}
@@ -224,6 +272,12 @@ namespace AutoBidder.Net
if (!response.IsSuccessStatusCode) Interlocked.Increment(ref _requestsFailed);
// Tolto il tetto fisso, il freno lo dà il server. 429 e 503 sono il modo in
// cui dice "stai chiedendo troppo": si smette per un attimo invece di
// insistere finché non arriva un blocco vero. Le puntate non passano di qui.
if ((int)response.StatusCode is 429 or 503) Frena();
else if (response.IsSuccessStatusCode) Rilascia();
return new HttpOutcome
{
Success = response.IsSuccessStatusCode,
@@ -304,9 +358,20 @@ namespace AutoBidder.Net
private double _tokens;
private long _lastTicks;
/// <summary>
/// Zero o negativo = nessun limite: il secchiello si toglie di mezzo del tutto.
///
/// <para>Serve perché il tetto globale era il vero collo di bottiglia. Misurato sui
/// registri: con 24 aste seguite insieme e 40 richieste al secondo di tetto, la
/// distribuzione delle richieste era tagliata netta sul limite (p90 39, p99 41) e
/// ogni asta finiva interrogata ogni ~596 ms invece dei 220 configurati — proprio
/// negli ultimi secondi, dove quel ritardo costa l'asta.</para>
/// </summary>
private bool Illimitato => _ratePerSecond <= 0;
public TokenBucket(double ratePerSecond, int capacity)
{
_ratePerSecond = Math.Max(1, ratePerSecond);
_ratePerSecond = ratePerSecond;
_capacity = Math.Max(1, capacity);
_tokens = _capacity;
_lastTicks = Stopwatch.GetTimestamp();
@@ -316,8 +381,8 @@ namespace AutoBidder.Net
{
lock (_sync)
{
_ratePerSecond = Math.Max(1, ratePerSecond);
_capacity = Math.Max(8, _ratePerSecond);
_ratePerSecond = ratePerSecond;
_capacity = Math.Max(8, Math.Abs(_ratePerSecond));
if (_tokens > _capacity) _tokens = _capacity;
}
}
@@ -328,6 +393,8 @@ namespace AutoBidder.Net
lock (_sync)
{
if (Illimitato) return ValueTask.CompletedTask;
var now = Stopwatch.GetTimestamp();
var elapsedSeconds = (now - _lastTicks) / (double)Stopwatch.Frequency;
_lastTicks = now;
+121 -120
View File
@@ -1,10 +1,11 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Engine;
using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Utilities;
@@ -151,7 +152,7 @@ namespace AutoBidder.Services
try
{
var won = finalState.Status == AuctionStatus.EndedWon || IsAuctionWonByUser(auction);
OnAuctionCompleted?.Invoke(auction, finalState, won);
RaiseCompleted(auction, finalState, won);
}
catch (Exception ex)
{
@@ -162,7 +163,7 @@ namespace AutoBidder.Services
// Il dossier di un'asta ancora viva va chiuso dicendo che è incompleto: se
// l'asta era conclusa il riepilogo l'ha già scritto OnAuctionCompleted, e questa
// chiamata non trova più nulla da chiudere.
AuctionDossier.Abandon(auctionId, "rimossa dal monitor");
AuctionRecorder.Abandon(auctionId, "rimossa dal monitor");
// Spegnimento e pulizia fuori dal lock e fuori dal thread dell'interfaccia:
// StopAsync attende la fine dei due anelli, che possono essere in mezzo a una
@@ -282,7 +283,9 @@ namespace AutoBidder.Services
_monitoringCts = new CancellationTokenSource();
_supervisorTask = Task.Run(() => SupervisorLoop(_monitoringCts.Token));
OnLog?.Invoke($"[START] Monitoraggio avviato (poll {settings.PollIntervalCriticalMs}-{settings.PollIntervalFarMs}ms, max {settings.MaxRequestsPerSecond:F0} req/s)");
OnLog?.Invoke(settings.MaxRequestsPerSecond > 0
? $"[START] Monitoraggio avviato (max {settings.MaxRequestsPerSecond:F0} richieste/s)"
: "[START] Monitoraggio avviato (nessun tetto alle richieste: frena solo se il server lo chiede)");
}
public void Stop()
@@ -430,6 +433,7 @@ namespace AutoBidder.Services
}
auction.AddLatencyMeasurement(state.PollingLatencyMs);
Ml.LatencyModel.NotePing(state.PollingLatencyMs);
// Serie prezzi: va raccolta ora, a posteriori Bidoo non la espone.
auction.TrackPrice(state.Price);
@@ -452,7 +456,7 @@ namespace AutoBidder.Services
// Il dossier registra tutto ciò che passa di qui: è materiale che esiste
// solo adesso.
var pollSettings = Settings;
AuctionDossier.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls);
AuctionRecorder.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.RecordPolls);
if (state.RecentBidsHistory != null && state.RecentBidsHistory.Count > 0)
{
@@ -505,16 +509,15 @@ namespace AutoBidder.Services
/// Verdetto sulle strategie. Il runner decide QUANDO puntare, questo metodo SE.
/// Viene chiamato una volta per ciclo, ~60 ms prima dell'istante di fuoco.
/// </summary>
public bool EvaluateBid(AuctionInfo auction, AuctionState state)
public bool EvaluateBid(AuctionInfo auction, AuctionState state, bool shadow = false)
{
var settings = Settings;
auction.PendingDecisionId = null;
if (state.IsMyBid) return false;
// L'asta dev'essere davvero aperta. Questo controllo è sempre attivo e non
// costa nulla: usa l'ultima risposta ricevuta, che nella finestra critica ha
// al massimo qualche centinaio di millisecondi. Una verifica dedicata via rete
// costerebbe invece un intero giro proprio nell'istante peggiore.
// costa nulla: usa l'ultima risposta ricevuta.
if (state.Status != AuctionStatus.Running && state.Status != AuctionStatus.Pending)
{
auction.AddLog($"⛔ Asta non aperta ({state.Status}): puntata annullata",
@@ -522,22 +525,38 @@ namespace AutoBidder.Services
return false;
}
if (!ShouldBid(auction, state, settings))
var mode = shadow ? "shadow" : "live";
if (!ShouldBid(auction, state, settings, out var limitReason))
{
AuctionRecorder.RecordDecision(auction, state, "NO-OP", null, null, limitReason ?? "limiti dell'asta", mode, null, "paletti", null);
return false;
}
var session = _apiClient.GetSession();
var decision = _bidStrategy.ShouldPlaceBid(auction, state, settings, session?.Username ?? "");
var decision = _bidStrategy.ShouldPlaceBid(auction, state, settings, session?.Username ?? "", GetAuctions(), shadow);
if (!decision.ShouldBid)
{
auction.AddLog($"⛔ Strategia blocca: {decision.Reason}",
auction.AddLog($"⛔ {(shadow ? "Shadow, non avrei puntato" : "Strategia blocca")}: {decision.Reason}",
AuctionLogLevel.Strategy, AuctionLogCategory.Strategy);
LogBlockThrottled(auction, "STRATEGY", $"[STRATEGY] {auction.Name}: {decision.Reason}");
if (!shadow) LogBlockThrottled(auction, "STRATEGY", $"[STRATEGY] {auction.Name}: {decision.Reason}");
AuctionRecorder.RecordDecision(auction, state, "NO-OP", decision.Probability, decision.ExpectedValue,
decision.Reason, mode, decision.Regime, decision.Policy, null, decision.ContextKey, decision.AltAction, decision.AltP);
return false;
}
var id = AuctionRecorder.RecordDecision(auction, state, "BID", decision.Probability, decision.ExpectedValue,
decision.Reason, mode, decision.Regime, decision.Policy, null, decision.ContextKey, decision.AltAction, decision.AltP);
if (shadow)
{
auction.AddLog($"[SHADOW] Avrei puntato a {state.Price:F2} € — {decision.Reason}",
AuctionLogLevel.Strategy, AuctionLogCategory.Strategy);
return false;
}
auction.PendingDecisionId = id;
auction.AddLog("✓ Tutti i controlli superati → PUNTATA!",
AuctionLogLevel.Bid, AuctionLogCategory.BidAttempt);
return true;
@@ -552,7 +571,7 @@ namespace AutoBidder.Services
// La coppia anticipo pianificato/effettivo è il dato per cui esiste il dossier:
// è l'unico modo, rileggendo a freddo, per capire se il valore impostato era
// quello giusto o se la puntata arrivava sistematicamente troppo presto o tardi.
AuctionDossier.For(auction.AuctionId)?.MyBid(
AuctionRecorder.For(auction.AuctionId)?.MyBid(
price: state.Price,
plannedLeadMs: leadMs,
actualLeadMs: actualLeadMs,
@@ -560,23 +579,10 @@ namespace AutoBidder.Services
success: result?.Success ?? false,
error: result?.Error,
bidsUsed: auction.BidsUsedOnThisAuction,
remainingBids: auction.RemainingBids);
remainingBids: auction.RemainingBids,
rttMs: result?.LatencyMs ?? 0,
decisionId: auction.PendingDecisionId);
// L'anticipo effettivo dice se il valore impostato e' quello giusto: da solo
// nel log non serviva a nulla, sommato su molte puntate diventa una taratura.
if (Settings.BidLeadTrackingEnabled && result != null)
{
BidLeadStats.Record(new BidLeadStats.Sample
{
At = DateTime.Now,
AuctionId = auction.AuctionId,
ConfiguredMs = leadMs,
ActualMs = actualLeadMs,
PingMs = auction.AverageLatencyMs,
Success = result.Success,
TooLate = !result.Success && IsLateBidError(result.Error)
});
}
}
/// <summary>Il server ha rifiutato perche' il tempo era gia' scaduto.</summary>
@@ -620,7 +626,7 @@ namespace AutoBidder.Services
Username = state.LastBidder,
Price = statePrice,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
BidType = "Auto"
BidType = BidHistoryEntry.TipoSconosciuto
});
}
}
@@ -638,33 +644,18 @@ namespace AutoBidder.Services
? DateTimeOffset.FromUnixTimeSeconds(state.ExpiryUnixSeconds).ToLocalTime().DateTime
: DateTime.Now;
if (won)
{
var valoreReale = BidStrategyService.ValueOf(auction) ?? 0;
var impostazioniFine = SettingsManager.Load();
var netto = Ml.Theory.RealizedWinProfit(valoreReale, state.Price, 0, impostazioniFine.TransactionFeeEuro, auction.ShippingCost ?? 0);
RiskManager.NoteWin(auction.AuctionId, netto, $"vinta a {state.Price:F2} €, valore reale {valoreReale:F2} €");
}
auction.AddLog($"[ASTA TERMINATA] {statusMsg}");
OnLog?.Invoke($"[FINE] [{auction.AuctionId}] Asta {statusMsg}");
// Feedback per aste perse
var settings = SettingsManager.Load();
if (!won && settings.ShowLateBidWarning)
{
// Se abbiamo provato a puntare ma fallito con errore timer
var lastBidAttempt = auction.BidHistory
.Where(b => b.EventType == BidEventType.MyBid && !b.Success)
.OrderByDescending(b => b.Timestamp)
.FirstOrDefault();
if (lastBidAttempt != null &&
(lastBidAttempt.Notes?.Contains("timer") == true ||
lastBidAttempt.Notes?.Contains("scaduto") == true))
{
int currentOffset = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
auction.AddLog($"[⚠️ SUGGERIMENTO] Puntata arrivata troppo tardi! " +
$"Tempo attuale: {currentOffset}ms. " +
$"Prova ad aumentarlo a {currentOffset + 500}ms o più.");
OnLog?.Invoke($"[LATE] {auction.Name}: aumenta il tempo di puntata (attuale: {currentOffset}ms)");
}
}
auction.BidHistory.Add(new BidHistory
{
@@ -680,7 +671,7 @@ namespace AutoBidder.Services
try
{
OnAuctionCompleted?.Invoke(auction, state, won);
RaiseCompleted(auction, state, won);
}
catch (Exception ex)
{
@@ -688,6 +679,25 @@ namespace AutoBidder.Services
}
}
private readonly object _completionGate = new();
/// <summary>
/// Comunica la chiusura di un'asta una volta sola. L'asta finisce (stato
/// terminale) e poco dopo viene tolta dall'elenco, e tutti e due i passaggi
/// arrivavano qui: storico, statistiche per prodotto, profilo e modello la
/// contavano due volte, e il bandit rivedeva gli stessi esiti.
/// </summary>
private void RaiseCompleted(AuctionInfo auction, AuctionState state, bool won)
{
lock (_completionGate)
{
if (auction.CompletionReported) return;
auction.CompletionReported = true;
}
OnAuctionCompleted?.Invoke(auction, state, won);
}
/// <summary>
/// Aggiorna il valore calcolato del prodotto
/// </summary>
@@ -759,10 +769,16 @@ namespace AutoBidder.Services
auction.LastClickAt = DateTime.UtcNow;
// Registra metriche
bool isCollision = result.Error?.Contains("timer") == true || result.Error?.Contains("scaduto") == true;
_bidStrategy.RecordBidAttempt(auction, result.Success, collision: isCollision);
if (!result.Success && isCollision)
bool isCollision = !result.Success && IsLateBidError(result.Error);
_bidStrategy.RecordBidAttempt(auction, result.Success);
if (result.Success) RiskManager.NoteBidSpent(auction.AuctionId, auction.BidCost);
// Il modello di latenza impara da ogni puntata: quanto ci ha messo, e se è
// arrivata tardi. Una tardiva alza l'anticipo subito; molte in tempo lo
// abbassano piano.
Ml.LatencyModel.NoteBid(result.LatencyMs, late: isCollision);
if (isCollision)
{
_bidStrategy.RecordTimerExpired(auction);
}
@@ -809,19 +825,18 @@ namespace AutoBidder.Services
auction.AddLog($"[BID FAIL] {result.Error} | Ping: {pollingPing}ms");
OnLog?.Invoke($"[FAIL] Puntata fallita su {auction.Name} ({auction.AuctionId}): {result.Error}");
// Feedback per puntata tardiva
// Puntata tardiva: l'anticipo lo corregge da solo il modello di
// latenza (è già stato avvisato sopra). Qui si dice solo cosa ha deciso.
if (isLateBid && settings.ShowLateBidWarning)
{
int currentOffset = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
int suggestedOffset = currentOffset + 300 + pollingPing;
auction.AddLog($"[⚠️ TIMING] Puntata arrivata troppo tardi! " +
$"Offset attuale: {currentOffset}ms. Latenza totale: ~{pollingPing + result.LatencyMs}ms. " +
$"Suggerimento: aumenta a {suggestedOffset}ms");
OnLog?.Invoke($"[LATE] {auction.Name}: puntata tardiva, aumenta offset a {suggestedOffset}ms");
var adattivo = Ml.LatencyModel.RecommendedLeadMs(settings);
var manuale = auction.BidLeadIsManual && auction.BidBeforeDeadlineMs > 0;
auction.AddLog($"[TIMING] Puntata arrivata troppo tardi (latenza ~{pollingPing + result.LatencyMs}ms). " +
(manuale
? $"L'anticipo di quest'asta è fisso a {auction.BidBeforeDeadlineMs}ms: il modello consiglierebbe {adattivo}ms."
: $"Anticipo adattivo portato a {adattivo}ms."));
OnLog?.Invoke($"[LATE] {auction.Name}: puntata tardiva, anticipo adattivo ora {adattivo}ms");
}
}
@@ -847,9 +862,10 @@ namespace AutoBidder.Services
}
}
private bool ShouldBid(AuctionInfo auction, AuctionState state, AppSettings? settings = null)
private bool ShouldBid(AuctionInfo auction, AuctionState state, AppSettings? settings, out string? reason)
{
settings ??= Utilities.SettingsManager.Load();
reason = null;
// CONTROLLO 0: Verifica convenienza (se abilitato e dati disponibili)
if (settings.ValueCheckEnabled &&
@@ -865,7 +881,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Risparmio {auction.CalculatedValue.SavingsPercentage.Value:F1}% < {settings.MinSavingsPercentage:F1}% richiesto",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Value);
LogBlockThrottled(auction, "VALUE", $"[VALUE] {auction.Name}: risparmio {auction.CalculatedValue.SavingsPercentage.Value:F1}% insufficiente");
return false;
reason = $"risparmio {auction.CalculatedValue.SavingsPercentage.Value:F1}% sotto il minimo {settings.MinSavingsPercentage:F1}%"; return false;
}
}
else
@@ -874,44 +890,6 @@ namespace AutoBidder.Services
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Value);
}
// CONTROLLO ANTI-COLLISIONE (OPZIONALE)
if (settings.HardcodedAntiCollisionEnabled)
{
var recentBidsThreshold = 10;
var maxActiveBidders = 3;
try
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var recentBids = auction.RecentBids
.Where(b => now - b.Timestamp <= recentBidsThreshold)
.ToList();
var activeBidders = recentBids
.Select(b => b.Username)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
auction.AddLog($"Competizione: {activeBidders} bidder attivi (soglia={maxActiveBidders})",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Competition);
if (activeBidders >= maxActiveBidders)
{
var session = _apiClient.GetSession();
var lastBid = recentBids.OrderByDescending(b => b.Timestamp).FirstOrDefault();
if (lastBid != null &&
!lastBid.Username.Equals(session?.Username, StringComparison.OrdinalIgnoreCase))
{
auction.AddLog($"⛔ Asta affollata: {activeBidders} bidder attivi",
Models.AuctionLogLevel.Strategy, Models.AuctionLogCategory.Competition);
return false;
}
}
}
catch { }
}
// CONTROLLO 1: Limite minimo puntate residue
if (settings.MinimumRemainingBids > 0)
{
@@ -921,7 +899,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Puntate residue ({session.RemainingBids}) ≤ limite ({settings.MinimumRemainingBids})",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "LIMIT", $"[LIMIT] {auction.Name}: puntate residue ({session.RemainingBids}) <= limite ({settings.MinimumRemainingBids})");
return false;
reason = $"puntate residue {session.RemainingBids} ≤ limite {settings.MinimumRemainingBids}"; return false;
}
else if (session != null)
{
@@ -935,7 +913,7 @@ namespace AutoBidder.Services
{
auction.AddLog($"✓ Sono già vincitore corrente - skip",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.BidAttempt);
return false;
reason = "già in testa"; return false;
}
// CONTROLLO 3: Limite puntate per questa asta
@@ -949,7 +927,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Limite puntate raggiunto ({usedBids}/{maxBids})",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "MAXBIDS", $"[LIMIT] {auction.Name}: puntate {usedBids}/{maxBids} - STOP");
return false;
reason = $"limite puntate dell'asta raggiunto ({usedBids}/{maxBids})"; return false;
}
if (maxBids > 0)
@@ -965,7 +943,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Prezzo €{state.Price:F2} < Min €{auction.MinPrice:F2}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Price);
LogBlockThrottled(auction, "PRICE_LOW", $"[PRICE] {auction.Name}: €{state.Price:F2} < Min €{auction.MinPrice:F2} - NON PUNTA");
return false;
reason = $"prezzo {state.Price:F2} € sotto il minimo {auction.MinPrice:F2} €"; return false;
}
if (auction.MaxPrice > 0 && state.Price > auction.MaxPrice)
@@ -973,7 +951,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Prezzo €{state.Price:F2} > Max €{auction.MaxPrice:F2}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Price);
LogBlockThrottled(auction, "PRICE_HIGH", $"[PRICE] {auction.Name}: €{state.Price:F2} > Max €{auction.MaxPrice:F2} - NON PUNTA");
return false;
reason = $"prezzo {state.Price:F2} € oltre il massimo {auction.MaxPrice:F2} €"; return false;
}
auction.AddLog($"✓ Prezzo OK (€{state.Price:F2} in [{auction.MinPrice:F2}, {(auction.MaxPrice > 0 ? auction.MaxPrice.ToString("F2") : "")}])",
@@ -1004,7 +982,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ {budget.Reason}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "BUDGET", $"[BUDGET] {auction.Name}: {budget.Reason}");
return false;
reason = budget.Reason; return false;
}
}
@@ -1016,7 +994,7 @@ namespace AutoBidder.Services
{
auction.AddLog($"Cooldown: {timeSinceLastClick.TotalMilliseconds:F0}ms < 800ms",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Ticker);
return false;
reason = $"cooldown {timeSinceLastClick.TotalMilliseconds:F0} ms"; return false;
}
}
@@ -1066,7 +1044,7 @@ namespace AutoBidder.Services
{
auction.ResetCount++;
AuctionDossier.For(auction.AuctionId)?.Reset(auction.ResetCount, state.Price, state.LastBidder);
AuctionRecorder.For(auction.AuctionId)?.Reset(auction.ResetCount, state.Price, state.LastBidder);
// Log quando c'è una nuova puntata (se logging attivo)
var settings = SettingsManager.Load();
@@ -1129,11 +1107,23 @@ namespace AutoBidder.Services
bids.Select(b => $"{b.Timestamp}_{b.Username}_{b.Price:F2}"));
var added = 0;
var dossier = AuctionDossier.For(auction.AuctionId);
var dossier = AuctionRecorder.For(auction.AuctionId);
// Le puntate nostre vanno marcate qui, dove il nome di sessione c'è: il
// parser lo fa solo se glielo si passa, e nel percorso dei poll non gli
// arriva. Senza, nessun dossier ha mai portato una puntata con mine:true,
// e l'analisi non poteva dire cosa fosse successo alle nostre.
var me = _apiClient.GetSession()?.Username;
if (!string.IsNullOrEmpty(me)) AuctionRecorder.CurrentUsername = me;
foreach (var bid in newBids)
{
if (!existing.Add($"{bid.Timestamp}_{bid.Username}_{bid.Price:F2}")) continue;
if (!bid.IsMyBid && !string.IsNullOrEmpty(me) &&
string.Equals(bid.Username, me, StringComparison.OrdinalIgnoreCase))
bid.IsMyBid = true;
bids.Add(bid);
added++;
@@ -1193,10 +1183,21 @@ namespace AutoBidder.Services
Username = currentBidder,
Price = statePrice,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
BidType = "Auto"
BidType = BidHistoryEntry.TipoSconosciuto
});
}
// Una puntata avversaria: quanto ha tardato rispetto alla nostra alimenta
// il riconoscimento del duello (~6 s = autopuntata) e il regime di
// concorrenza (una puntata di prova coperta subito). Si misura sempre;
// l'impostazione decide solo se il duello riconosciuto ferma le puntate.
var impostazioni = SettingsManager.Load();
if (auction.NoteOpponentResponse(DateTime.UtcNow, impostazioni.AutoBidDuelResponses))
{
auction.AddLog(AutoBidDuel.Spiegazione(auction.AutoResponsesInARow),
AuctionLogLevel.Warning, AuctionLogCategory.Strategy);
}
// I conteggi per utente restano a carico di UpdateBidderStatsFromRecentBids,
// unico punto che li tocca: contarli anche qui li raddoppierebbe.
}
+227 -446
View File
@@ -1,282 +1,102 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Ml;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per strategie avanzate di puntata.
/// Implementa: adaptive latency, jitter, dynamic offset, heat metric,
/// competition detection, soft retreat, probabilistic bidding, opponent profiling.
/// Decide se puntare. Cinque passi, sempre nello stesso ordine — vedi
/// <c>Ml/LEGGIMI.md</c> per il disegno completo e i numeri che lo giustificano.
///
/// <list type="number">
/// <item><b>Rischio</b> — il gestore del rischio: kill-switch, HALT, stop-loss,
/// drawdown, tetto del giorno, aste in gioco insieme. Solo dal vivo: in shadow la
/// decisione si registra com'è, per poterla giudicare.</item>
/// <item><b>Paletti</b> — regole dell'utente che non si discutono: tetti di
/// puntate, fascia oraria sospesa.</item>
/// <item><b>Duello</b> — riconoscimento dell'autopuntata avversaria dal ritmo
/// delle risposte.</item>
/// <item><b>Valore atteso</b> — il modello appreso dice con che probabilità una
/// puntata fatta adesso resterebbe senza risposta; <see cref="Theory"/> la
/// trasforma in euro con valore reale del bene, fee, spedizione e copertura.</item>
/// <item><b>Regime</b> — una macchina a stati per asta che trasforma la sequenza
/// dei valori attesi in una condotta: quando lasciar sfogare gli altri, quando
/// sondare, quando tornare a giocare.</item>
/// </list>
///
/// <para><i>Quando</i> puntare — l'anticipo — non si decide qui ma nel cecchino,
/// con <see cref="LatencyModel"/>.</para>
/// </summary>
public class BidStrategyService
{
private readonly Random _random = new();
private int _sessionTotalBids = 0;
private DateTime _sessionStartedAt = DateTime.UtcNow;
private readonly Random _rng = new();
/// <summary>
/// Aggiorna heat metric per un'asta
/// Il valore reale V del bene di quest'asta: quello scritto dall'utente sul
/// prodotto, se c'è; altrimenti il «Compralo Ora». Vedi <see cref="Theory"/>: il
/// listino di Bidoo è gonfiato, e un valore atteso calcolato su di esso mente.
/// </summary>
public void UpdateHeatMetric(AuctionInfo auction, AppSettings settings, string currentUsername = "")
public static double? ValueOf(AuctionInfo auction)
{
if (!settings.CompetitionDetectionEnabled) return;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var windowStart = now - settings.CompetitionWindowSeconds;
// Conta bidder unici nella finestra temporale (escludo me stesso)
var recentBids = auction.RecentBids
.Where(b => b.Timestamp >= windowStart)
.Where(b => !b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
.ToList();
auction.ActiveBiddersCount = recentBids
.Select(b => b.Username)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
// Conta collisioni (puntate nello stesso secondo)
var bidsBySecond = recentBids
.GroupBy(b => b.Timestamp)
.Where(g => g.Count() > 1)
.Count();
auction.CollisionCount = bidsBySecond;
// Calcola heat metric (0-100)
// Fattori: bidder attivi (40%), frequenza puntate (30%), collisioni (30%)
int bidderScore = Math.Min(auction.ActiveBiddersCount * 15, 40); // Max 40 punti
int frequencyScore = Math.Min(recentBids.Count * 3, 30); // Max 30 punti
int collisionScore = Math.Min(auction.CollisionCount * 10, 30); // Max 30 punti
auction.HeatMetric = bidderScore + frequencyScore + collisionScore;
// Identifica bidder aggressivi e situazioni di duello
if (settings.OpponentProfilingEnabled)
{
UpdateAggressiveBidders(auction, settings, currentUsername);
DetectDuelSituation(auction, settings, currentUsername);
}
if (auction == null) return null;
var scheda = WatchedProductsStore.FindByName(auction.Name);
return scheda?.MarketValue is > 0 ? scheda.MarketValue : auction.BuyNowPrice;
}
/// <summary>
/// Identifica e tracca bidder aggressivi (basato su ultime N puntate, esclude utente corrente)
/// Vero se l'utente ha scritto il valore reale del prodotto nella scheda Prodotti.
///
/// <para>Conta per la copertura «Compralo Ora»: senza un valore scelto, V coincide
/// con il listino L, la perdita coperta L V è zero e ogni puntata risulterebbe
/// gratis nel ramo di sconfitta — cioè si punterebbe sempre, a qualunque
/// probabilità. Nelle osservazioni in shadow è successo esattamente questo: BID
/// con P(senza risposta) dell'1%. La copertura vale solo quando l'utente ha
/// detto quanto vale davvero il bene.</para>
/// </summary>
private void UpdateAggressiveBidders(AuctionInfo auction, AppSettings settings, string currentUsername)
public static bool HasExplicitValue(AuctionInfo auction)
{
// ?? FIX: Usa finestra scorrevole di ultime N puntate
var windowSize = settings.AggressiveBidderWindowSize > 0 ? settings.AggressiveBidderWindowSize : 30;
var recentWindow = auction.RecentBids
.Take(windowSize)
.ToList();
var bidCounts = recentWindow
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
.ToList();
auction.AggressiveBidders.Clear();
foreach (var bidder in bidCounts)
if (auction == null) return false;
var scheda = WatchedProductsStore.FindByName(auction.Name);
return scheda?.MarketValue is > 0;
}
public BidDecision ShouldPlaceBid(
AuctionInfo auction,
AuctionState state,
AppSettings settings,
string currentUsername,
IReadOnlyList<AuctionInfo>? allAuctions = null,
bool shadow = false)
{
var decision = new BidDecision { ShouldBid = true, Policy = "ev-regime" };
// ── 1. Rischio ─────────────────────────────────────────────────
if (!shadow)
{
// ?? FIX: NON aggiungere l'utente corrente come aggressivo!
if (bidder.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
continue;
// ?? FIX: Soglia pi permissiva - usa percentuale invece di conteggio assoluto
// Un bidder "aggressivo" se ha pi del 40% delle puntate nella finestra (configurabile)
var percentageThreshold = settings.AggressiveBidderPercentageThreshold > 0 ? settings.AggressiveBidderPercentageThreshold : 40.0;
if (bidder.Percentage >= percentageThreshold || bidder.Count >= settings.AggressiveBidderThreshold)
var risk = RiskManager.Authorize(auction, settings, allAuctions);
if (!risk.Allowed)
{
auction.AggressiveBidders.Add(bidder.Username);
decision.ShouldBid = false;
decision.Reason = risk.Reason;
decision.Policy = "rischio";
return decision;
}
}
}
/// <summary>
/// Rileva situazione di "duello" (solo 2 bidder attivi che si contendono l'asta)
/// In questa situazione bisogna essere pronti perch se uno si ritira l'altro vince
/// </summary>
private void DetectDuelSituation(AuctionInfo auction, AppSettings settings, string currentUsername)
{
var windowSize = settings.DuelDetectionWindowSize > 0 ? settings.DuelDetectionWindowSize : 20;
var recentWindow = auction.RecentBids.Take(windowSize).ToList();
if (recentWindow.Count < 6) // Serve un minimo di puntate per rilevare un pattern
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
return;
}
var bidders = recentWindow
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
.OrderByDescending(b => b.Count)
.ToList();
// Duello: esattamente 2 bidder dominanti che coprono almeno l'80% delle puntate
if (bidders.Count >= 2)
{
var top2Percentage = bidders.Take(2).Sum(b => b.Percentage);
if (top2Percentage >= 80 && bidders.Count <= 3)
{
auction.IsDuelSituation = true;
// Trova l'avversario (chi NON sono io)
var opponent = bidders.FirstOrDefault(b =>
!b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
auction.DuelOpponent = opponent?.Username;
// Calcola chi sta dominando
var myStats = bidders.FirstOrDefault(b =>
b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
auction.DuelAdvantage = myStats != null && opponent != null
? myStats.Percentage - opponent.Percentage
: 0;
}
else
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
auction.DuelAdvantage = 0;
}
}
else
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
}
}
/// <summary>
/// Verifica se il caso di puntare considerando tutte le strategie
/// </summary>
public BidDecision ShouldPlaceBid(AuctionInfo auction, AuctionState state, AppSettings settings, string currentUsername)
{
var decision = new BidDecision { ShouldBid = true };
// Se le strategie avanzate sono disabilitate per questa asta, salta tutto
if (auction.AdvancedStrategiesEnabled == false)
// ── 2. Paletti ─────────────────────────────────────────────────
if (QuietHoursApply(auction, settings) &&
BiddingHours.IsQuiet(DateTime.Now, settings.QuietHoursStart, settings.QuietHoursEnd))
{
decision.ShouldBid = false;
decision.Reason = BiddingHours.Spiegazione(settings.QuietHoursStart, settings.QuietHoursEnd);
decision.Policy = "paletti";
return decision;
}
// ? RIMOSSO: Entry Point - Era sbagliato!
// I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
// Se l'utente imposta MaxPrice=2, vuole puntare FINO A 2, non fino al 70%!
// I controlli MinPrice/MaxPrice sono gi gestiti in AuctionMonitor.ShouldBid()
// L'Entry Point pu essere usato SOLO per calcolare limiti CONSIGLIATI, non per bloccare.
// 1. ANTI-BOT — riconoscimento del puntatore a cadenza fissa.
//
// Spento di proposito (vedi AppSettings.AntiBotDetectionEnabled): rigiocando i
// dossier raccolti la regola rifiutava fra il 4% e il 9% delle puntate, a seconda
// dell'anticipo. Chi lo accende lo fa sapendo che e' una scelta di
// prudenza, non una difesa: un avversario a cadenza fissa è il più facile da
// battere, perché punta sempre con secondi di anticipo.
if (settings.AntiBotDetectionEnabled && !string.IsNullOrEmpty(state.LastBidder))
{
var botCheck = DetectBotPattern(auction, state.LastBidder, currentUsername);
if (botCheck.IsBot)
{
decision.ShouldBid = false;
decision.Reason = $"Anti-bot: {state.LastBidder} punta a cadenza fissa ({botCheck.GapSeconds:F0}s)";
return decision;
}
}
// ?? 2. USER EXHAUSTION - Sfrutta utenti stanchi (info solo, non blocca)
if (settings.UserExhaustionEnabled && !string.IsNullOrEmpty(state.LastBidder))
{
var exhaustionCheck = CheckUserExhaustion(auction, state.LastBidder, currentUsername);
// Non blocchiamo, ma potremmo loggare per info
}
// 3. Verifica soft retreat
if (settings.SoftRetreatEnabled || (auction.SoftRetreatEnabledOverride ?? settings.SoftRetreatEnabled))
{
if (auction.IsInSoftRetreat)
{
var retreatEnd = auction.LastSoftRetreatAt?.AddSeconds(settings.SoftRetreatDurationSeconds);
if (retreatEnd > DateTime.UtcNow)
{
decision.ShouldBid = false;
decision.Reason = $"Soft retreat attivo (termina tra {(retreatEnd.Value - DateTime.UtcNow).TotalSeconds:F0}s)";
return decision;
}
else
{
// Fine soft retreat
auction.IsInSoftRetreat = false;
auction.ConsecutiveCollisions = 0;
}
}
// Verifica se attivare soft retreat
if (auction.ConsecutiveCollisions >= settings.SoftRetreatAfterCollisions)
{
auction.IsInSoftRetreat = true;
auction.LastSoftRetreatAt = DateTime.UtcNow;
decision.ShouldBid = false;
decision.Reason = $"Soft retreat attivato dopo {auction.ConsecutiveCollisions} collisioni";
return decision;
}
}
// 2. Verifica competition threshold
if (settings.CompetitionDetectionEnabled)
{
if (auction.ActiveBiddersCount >= settings.CompetitionThreshold)
{
// Controlla se l'ultimo bidder sono io - se s, posso continuare
var lastBid = auction.RecentBids.OrderByDescending(b => b.Timestamp).FirstOrDefault();
if (lastBid != null && !lastBid.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
{
if (settings.AutoPauseHotAuctions && auction.HeatMetric >= settings.HeatThresholdForPause)
{
decision.ShouldBid = false;
decision.Reason = $"Asta troppo calda (heat={auction.HeatMetric}%, bidder={auction.ActiveBiddersCount})";
return decision;
}
}
}
}
// 3. Verifica opponent profiling
if (settings.OpponentProfilingEnabled && auction.AggressiveBidders.Count > 0)
{
if (settings.AggressiveBidderAction == "Avoid")
{
decision.ShouldBid = false;
decision.Reason = $"Bidder aggressivi rilevati: {string.Join(", ", auction.AggressiveBidders.Take(3))}";
return decision;
}
}
// 4. Probabilistic bidding
if (settings.ProbabilisticBiddingEnabled)
{
var probability = CalculateBidProbability(auction, settings);
var roll = _random.NextDouble();
if (roll > probability)
{
decision.ShouldBid = false;
decision.Reason = $"Skip probabilistico (p={probability:P0}, roll={roll:P0})";
return decision;
}
}
// 5. Bankroll manager
if (settings.BankrollManagerEnabled)
{
var bankrollCheck = CheckBankrollLimits(auction, settings);
@@ -284,154 +104,135 @@ namespace AutoBidder.Services
{
decision.ShouldBid = false;
decision.Reason = bankrollCheck.Reason;
decision.Policy = "paletti";
return decision;
}
}
// ? RIMOSSO: DetectLastSecondSniper - causava falsi positivi
// In un duello, TUTTI i bidder hanno pattern regolari (ogni reset del timer)
// Questa strategia bloccava puntate legittime e faceva perdere aste
// 7. VELOCITA' DEL PREZZO - l'asta sta salendo troppo in fretta.
// ── Apprendimento spento: solo i limiti ────────────────────────
//
// La soglia era fissa a 0,10 EUR/s, cioe' dieci puntate al secondo: su 40.000
// valutazioni riprese dai dossier non e' scattata mai una volta, e il massimo
// mai osservato e' 0,016 EUR/s. Ora e' un'impostazione, spenta di predefinito:
// un controllo che non puo' scattare da' una falsa sensazione di protezione.
if (settings.PriceVelocityBlockPerSecond > 0)
// L'interruttore in barra. Spento, da qui in poi non si decide più niente:
// né duello, né valore atteso, né regime, né bandit. Si punta, entro i limiti
// già controllati sopra, come un cecchino senza cervello.
if (!settings.LearningEnabled)
{
var priceVelocity = CalculatePriceVelocity(auction);
if (priceVelocity > settings.PriceVelocityBlockPerSecond)
decision.Policy = "solo-limiti";
decision.Reason = "apprendimento spento: si punta entro i limiti (prezzo, puntate, budget, fascia oraria)";
return decision;
}
// ── 3. Duello con l'autopuntata ────────────────────────────────
if (auction.AutoBidDuelDetected)
{
auction.Regime.AutopuntataRiconosciuta();
if (settings.AutoBidDuelWithdrawEnabled)
{
decision.ShouldBid = false;
decision.Reason = $"Prezzo sale troppo in fretta ({priceVelocity:F3} EUR/s, soglia {settings.PriceVelocityBlockPerSecond:F3})";
decision.Reason = AutoBidDuel.Spiegazione(auction.AutoResponsesInARow);
decision.Policy = "duello";
decision.Regime = auction.Regime.StatoAttuale.ToString();
return decision;
}
}
return decision;
}
/// <summary>
/// Calcola la velocit di crescita del prezzo (/secondo)
/// </summary>
private double CalculatePriceVelocity(AuctionInfo auction)
{
if (auction.RecentBids.Count < 5) return 0;
var recentBids = auction.RecentBids.Take(10).ToList();
if (recentBids.Count < 2) return 0;
var first = recentBids.Last();
var last = recentBids.First();
var timeDiffSeconds = last.Timestamp - first.Timestamp;
if (timeDiffSeconds <= 0) return 0;
var priceDiff = last.Price - first.Price;
return (double)priceDiff / timeDiffSeconds;
}
/// <summary>
/// Riconosce un avversario che punta a cadenza fissa.
///
/// <para><b>Limite di misura, da tenere presente.</b> Le marche temporali dello
/// storico di Bidoo sono in <i>secondi interi</i>: le pause fra due puntate sono
/// quindi numeri interi, e la loro deviazione standard vale 0 ms (pause tutte
/// uguali) oppure almeno 500 ms. La vecchia soglia "deviazione &lt; 50 ms" si
/// riduceva percio' a "le ultime pause sono identiche al secondo" - condizione
/// comunissima fra utenti normali: rigiocando i dossier raccolti rifiutava fra il
/// 4% e il 9% delle puntate, proprio negli istanti in cui il motore avrebbe
/// sparato.</para>
///
/// <para>Ora servono <b>quattro</b> pause tutte uguali e brevi (sotto i 15 s):
/// resta un indizio, non una prova, ed e' il motivo per cui l'impostazione che
/// la usa nasce spenta.</para>
/// </summary>
private (bool IsBot, double GapSeconds) DetectBotPattern(AuctionInfo auction, string? lastBidder, string currentUsername)
{
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
return (false, 0);
var userBids = auction.RecentBids
.Where(b => b.Username.Equals(lastBidder, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(b => b.Timestamp)
.Take(5)
.ToList();
// ── 4. Valore atteso ───────────────────────────────────────────
//
// Il modello parla sempre; decide solo quando ha appreso abbastanza aste. La
// probabilità e il valore atteso finiscono comunque sull'asta e sulla decisione.
double? ev = null;
string? spiegazioneEv = null;
var pronto = false;
var value = ValueOf(auction);
if (userBids.Count < 5) return (false, 0);
var gaps = new List<long>();
for (var i = 0; i < userBids.Count - 1; i++)
gaps.Add(userBids[i].Timestamp - userBids[i + 1].Timestamp);
// Una pausa nulla vuol dire due puntate nello stesso secondo: e' rumore
// dello storico, non una cadenza.
if (gaps.Count < 4 || gaps.Any(g => g <= 0 || g > 15)) return (false, 0);
var isBot = gaps.All(g => g == gaps[0]);
return (isBot, gaps[0]);
}
/// <summary>
/// Verifica se un utente esausto (molte puntate, pu mollare)
/// </summary>
private (bool ShouldExploit, string Reason) CheckUserExhaustion(AuctionInfo auction, string? lastBidder, string currentUsername)
{
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
return (false, "");
// Verifica se l'utente un "heavy user" (>50 puntate totali)
if (auction.BidderStats.TryGetValue(lastBidder, out var stats))
if (settings.LearningGateEnabled && value is > 0)
{
if (stats.BidCount > 50)
var ctx = LearningService.BuildContext(auction, state, DateTime.Now, currentUsername);
var prob = ctx is { } c0 ? LearningService.PredictUnanswered(c0) : (double?)null;
if (prob is { } p && ctx is { } c)
{
// Se ci sono pochi altri bidder attivi, pu essere un buon momento
var activeBidders = auction.BidderStats.Values.Count(b => b.BidCount > 5);
if (activeBidders <= 3)
{
return (true, $"{lastBidder} ha {stats.BidCount} puntate, potrebbe mollare");
}
var costo = settings.AverageBidCostEuro * Math.Max(0.1, settings.LearningEvMultiplier);
var speso = (auction.BidsUsedOnThisAuction ?? 0) * auction.BidCost;
var situazione = new Theory.BidSituation(
PWin: p,
Value: value.Value,
CurrentPrice: state.Price,
BidCost: costo,
TransactionFee: settings.TransactionFeeEuro,
Shipping: auction.ShippingCost ?? 0,
ListPrice: auction.BuyNowPrice,
BidsSpentEuro: speso,
HedgeEnabled: settings.BuyNowHedgeEnabled && HasExplicitValue(auction));
var valore = Theory.BidExpectedValue(situazione);
auction.LearnedUnansweredProbability = p;
auction.LearnedExpectedValue = valore;
pronto = LearningService.IsReady(settings);
ev = valore;
decision.Probability = p;
decision.ExpectedValue = valore;
var margine = value.Value - state.Price - Theory.PriceIncrement - settings.TransactionFeeEuro - (auction.ShippingCost ?? 0);
spiegazioneEv =
$"valore atteso negativo: P(senza risposta) {p:P2} × margine {margine:F2} € " +
$" puntata {costo:F2} €{(Theory.LossBranchCost(situazione) == 0 ? " (coperta)" : "")} = {valore:+0.000;-0.000} €";
LearningService.RecordDecision(auction.Name, state.Price, p, valore,
blocked: pronto && valore < 0, ready: pronto);
// La seconda policy, sempre in shadow: il bandit dice la sua sullo
// stesso istante, e alla chiusura entrambe vengono giudicate sugli esiti.
var key = ThompsonBandit.KeyOf(c);
var (altBid, altP) = LearningService.Bandit.Decide(key, situazione, _rng);
decision.ContextKey = key;
decision.AltAction = altBid ? "BID" : "NO-OP";
decision.AltP = altP;
}
}
return (false, "");
}
/// <summary>
/// Calcola probabilit di puntata basata su competizione e ROI
/// </summary>
private double CalculateBidProbability(AuctionInfo auction, AppSettings settings)
{
var probability = settings.BaseBidProbability;
// Riduci probabilit per ogni bidder attivo oltre la soglia
var extraBidders = Math.Max(0, auction.ActiveBiddersCount - settings.CompetitionThreshold);
probability -= extraBidders * settings.ProbabilityReductionPerBidder;
// Riduci per heat metric alto
if (auction.HeatMetric > 70)
// ── 5. Regime di concorrenza ───────────────────────────────────
if (pronto && ev is { } valoreAtteso)
{
probability -= 0.1;
var statoPrima = auction.Regime.StatoAttuale;
var consentito = auction.Regime.Osserva(valoreAtteso >= 0);
decision.Regime = auction.Regime.StatoAttuale.ToString();
if (auction.Regime.StatoAttuale != statoPrima)
{
auction.AddLog($"[REGIME] {statoPrima} → {auction.Regime.StatoAttuale}: {auction.Regime.Spiegazione()}",
AuctionLogLevel.Strategy, AuctionLogCategory.Strategy);
}
if (!consentito)
{
decision.ShouldBid = false;
decision.Reason = auction.Regime.StatoAttuale == CompetitionRegime.Stato.Calmo
? spiegazioneEv
: auction.Regime.Spiegazione();
return decision;
}
decision.Reason = $"valore atteso {valoreAtteso:+0.000;-0.000} € (P {decision.Probability:P2}), regime {auction.Regime.StatoAttuale}";
}
// Aumenta se abbiamo un buon ROI potenziale
if (auction.CalculatedValue?.Savings > 0)
else if (ev is { } evNonPronto)
{
probability += 0.1;
decision.Reason = $"modello non ancora pronto ({LearningService.AuctionsLearned}/{settings.LearningMinAuctions} aste): parere EV {evNonPronto:+0.000;-0.000} €, si punta";
}
return Math.Clamp(probability, 0.1, 1.0);
else
{
decision.Reason = "nessuna stima disponibile (valore ignoto o nessuna puntata recente): si punta";
}
return decision;
}
/// <summary>
/// Verifica limiti bankroll
/// </summary>
private BankrollCheckResult CheckBankrollLimits(AuctionInfo auction, AppSettings settings)
{
var result = new BankrollCheckResult { CanBid = true };
// Limite puntate per asta
var maxPerAuction = auction.MaxBidsOverride ?? settings.MaxBidsPerAuction;
if (maxPerAuction > 0 && auction.SessionBidCount >= maxPerAuction)
{
@@ -439,82 +240,62 @@ namespace AutoBidder.Services
result.Reason = $"Limite puntate per asta raggiunto ({auction.SessionBidCount}/{maxPerAuction})";
return result;
}
// Limite puntate per sessione
if (settings.MaxBidsPerSession > 0 && _sessionTotalBids >= settings.MaxBidsPerSession)
{
result.CanBid = false;
result.Reason = $"Limite puntate per sessione raggiunto ({_sessionTotalBids}/{settings.MaxBidsPerSession})";
return result;
}
// Budget giornaliero
if (settings.DailyBudgetEuro > 0)
{
var spent = _sessionTotalBids * settings.AverageBidCostEuro;
if (spent >= settings.DailyBudgetEuro)
{
result.CanBid = false;
result.Reason = $"Budget giornaliero esaurito ({spent:F2}/{settings.DailyBudgetEuro:F2})";
return result;
}
}
return result;
}
/// <summary>
/// Registra una puntata effettuata (per tracking)
/// La sospensione a fascia oraria vale per quest'asta? Tre livelli, dal più
/// specifico al più generale: l'asta scavalca il prodotto, il prodotto scavalca
/// le impostazioni generali.
/// </summary>
public void RecordBidAttempt(AuctionInfo auction, bool success, bool collision = false)
public static bool QuietHoursApply(AuctionInfo auction, AppSettings settings)
{
if (auction?.QuietHoursEnabledOverride is { } perAsta) return perAsta;
var scheda = WatchedProductsStore.FindByName(auction?.Name);
if (scheda?.QuietHoursEnabled is { } perProdotto) return perProdotto;
return settings.QuietHoursEnabled;
}
/// <summary>Registra una puntata inviata, riuscita o no.</summary>
public void RecordBidAttempt(AuctionInfo auction, bool success)
{
auction.SessionBidCount++;
_sessionTotalBids++;
if (success)
{
auction.SuccessfulBidCount++;
auction.ConsecutiveCollisions = 0;
auction.LastMyBidAtUtc = DateTime.UtcNow;
auction.Regime.PuntataPiazzata();
}
else
{
auction.FailedBidCount++;
}
if (collision)
{
auction.CollisionCount++;
auction.ConsecutiveCollisions++;
}
}
/// <summary>
/// Registra un ciclo perso perche' la puntata e' arrivata a giochi chiusi.
///
/// <para>Qui <b>non</b> si tocca <c>ConsecutiveCollisions</c>: chi chiama questo
/// metodo ha gia' chiamato <see cref="RecordBidAttempt"/> con <c>collision: true</c>
/// sulla stessa puntata, e il contatore veniva percio' incrementato due volte.
/// Con la soglia predefinita di tre collisioni bastavano <i>due</i> puntate tardive
/// per far scattare il ritiro - e un ritiro di trenta secondi, su cicli da otto o
/// dieci, significa perdere l'asta.</para>
/// </summary>
/// <summary>Un ciclo perso perche' la puntata e' arrivata a giochi chiusi.</summary>
public void RecordTimerExpired(AuctionInfo auction)
{
auction.TimerExpiredCount++;
}
/// <summary>
/// Reset contatori sessione
/// </summary>
public void ResetSession()
{
_sessionTotalBids = 0;
_sessionStartedAt = DateTime.UtcNow;
}
/// <summary>
/// Ottiene statistiche sessione corrente
/// </summary>
public SessionStats GetSessionStats()
{
return new SessionStats
@@ -524,41 +305,41 @@ namespace AutoBidder.Services
};
}
}
/// <summary>
/// Risultato calcolo timing puntata
/// </summary>
public class BidTimingResult
{
public int BaseOffsetMs { get; set; }
public int LatencyCompensationMs { get; set; }
public int DynamicAdjustmentMs { get; set; }
public int JitterMs { get; set; }
public int FinalOffsetMs { get; set; }
public bool ShouldBid { get; set; }
}
/// <summary>
/// Decisione se puntare
/// </summary>
/// <summary>Il verdetto della strategia, con i numeri che l'hanno prodotto.</summary>
public class BidDecision
{
public bool ShouldBid { get; set; }
public string? Reason { get; set; }
/// <summary>P(senza risposta) stimata dal modello, se c'era.</summary>
public double? Probability { get; set; }
/// <summary>Valore atteso in euro della puntata, se c'era.</summary>
public double? ExpectedValue { get; set; }
/// <summary>Regime di concorrenza al momento della decisione.</summary>
public string? Regime { get; set; }
/// <summary>Quale parte ha deciso: rischio, paletti, duello, ev-regime.</summary>
public string Policy { get; set; } = "";
/// <summary>Chiave del contesto per il bandit, se il modello ha parlato.</summary>
public string? ContextKey { get; set; }
/// <summary>Cosa avrebbe fatto il bandit Thompson sullo stesso istante.</summary>
public string? AltAction { get; set; }
/// <summary>La probabilità estratta dal bandit.</summary>
public double? AltP { get; set; }
}
/// <summary>
/// Risultato verifica bankroll
/// </summary>
public class BankrollCheckResult
{
public bool CanBid { get; set; }
public string? Reason { get; set; }
}
/// <summary>
/// Statistiche sessione
/// </summary>
public class SessionStats
{
public int TotalBids { get; set; }
+4 -2
View File
@@ -192,8 +192,10 @@ namespace AutoBidder.Services
var settings = config.PromoHarvest;
var text = body ?? "";
if (FreeBidsPageParser.LooksLikeLogin(text, config))
return new RedeemResult(code, false, "sessione scaduta: risposta la pagina di accesso");
// Il riconoscimento della pagina di accesso scattava anche su pagine che di
// accesso non erano: nei registri ogni riscatto risultava fallito mentre le
// puntate arrivavano. Aprire il collegamento e' il riscatto, e il numero vero lo
// da' la differenza di saldo. Vedi FreeBidsResponseValidator.Validate.
var success = settings.SuccessMarkers
.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m) &&
+10 -3
View File
@@ -47,11 +47,18 @@ namespace AutoBidder.Services
if (html.Length < 200)
return FreeBidsPageScan.Unrecognised($"risposta troppo corta ({html.Length} caratteri)");
if (LooksLikeLogin(html, config))
// Prima si guarda se è la pagina delle ricompense: anche da collegati la
// pagina porta un collegamento a /login.php (menu, modulo nascosto), e
// giudicarla «pagina di accesso» per quello dava una sessione scaduta che
// non c'era. Solo una pagina senza nessun segno delle ricompense, e con i
// segni dell'accesso, è davvero il modulo di login.
var markers = config.PageRecognition.PageMarkers;
var looksLikeRewards = markers.Count > 0 && markers.Any(m => Contains(html, m));
if (!looksLikeRewards && LooksLikeLogin(html, config))
return FreeBidsPageScan.Unrecognised("sessione scaduta: Bidoo ha risposto con la pagina di accesso");
var markers = config.PageRecognition.PageMarkers;
if (markers.Count > 0 && !markers.Any(m => Contains(html, m)))
if (markers.Count > 0 && !looksLikeRewards)
return FreeBidsPageScan.Unrecognised("pagina delle ricompense non riconosciuta (formato cambiato?)");
var patterns = PatternsFor(config);
@@ -35,10 +35,51 @@ namespace AutoBidder.Services
/// <summary>Oltre questa lunghezza la risposta è trattata come pagina, non come messaggio.</summary>
private const int FragmentMaxLength = 2000;
/// <summary>
/// Aprire il collegamento <b>è</b> il riscatto: non si giudica cosa risponde.
///
/// <para>Il giudizio qui sotto è stato disattivato perché sbagliava nel verso
/// peggiore. Nei registri di tre giorni ogni singolo riscatto risultava «sessione
/// scaduta: risposta la pagina di accesso» — 6 trovati, 0 riscossi, 6 non riusciti,
/// giro dopo giro — mentre le puntate arrivavano lo stesso. Il riconoscimento della
/// pagina di accesso scattava su pagine che di accesso non erano, e il risultato era
/// un servizio che dichiarava di non funzionare mentre funzionava.</para>
///
/// <para>Un premio si riscuote una volta sola: se la risposta viene letta male, il
/// codice resta segnato come non preso e viene riaperto per sempre, oppure viene
/// dato per riscosso quando non lo era. Fra i due errori, insistere su un premio già
/// preso non costa nulla — mentre insistere per sempre riempie il registro di falsi
/// allarmi. Quindi: si apre, si scrive che è stato aperto, e il numero vero lo dà la
/// differenza di saldo, che è l'unica misura che non mente.</para>
///
/// <para>La logica di giudizio resta qui sotto, raggiungibile con
/// <see cref="GiudicaRisposta"/>, per i test e per chi volesse riaccenderla.</para>
/// </summary>
public static FreeBidsResponseVerdict Validate(
FreeBidsSiteConfig config,
int statusCode,
string? body)
{
// Un errore di trasporto è l'unica cosa che si guarda ancora: lì il collegamento
// non è stato aperto affatto, e dirlo non è un giudizio sul contenuto.
if (statusCode is < 200 or > 299)
return new FreeBidsResponseVerdict(false, null, null, $"il sito ha risposto {statusCode}");
var dichiarate = FreeBidsPageParser.ReadGainedBids(body ?? "", (config ?? new FreeBidsSiteConfig()).Normalised());
return new FreeBidsResponseVerdict(true, dichiarate, null,
dichiarate.HasValue ? $"collegamento aperto (+{dichiarate} puntate dichiarate)"
: "collegamento aperto");
}
/// <summary>
/// Il vecchio giudizio sul contenuto della risposta. Non più sul percorso normale:
/// vedi <see cref="Validate"/> per il perché.
/// </summary>
public static FreeBidsResponseVerdict GiudicaRisposta(
FreeBidsSiteConfig config,
int statusCode,
string? body)
{
config = (config ?? new FreeBidsSiteConfig()).Normalised();
+232
View File
@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Il gestore del rischio: i paletti duri che nessuna strategia può scavalcare.
/// Ogni puntata dal vivo passa da <see cref="Authorize"/> prima di partire.
///
/// <para><b>Quattro vincoli, tutti in euro o in numero, tutti dell'utente.</b>
/// Il tetto di spesa giornaliero, lo stop-loss giornaliero (quando la perdita netta
/// del giorno supera la soglia si entra in HALT), il drawdown massimo dal picco del
/// saldo cumulato, e il numero massimo di aste su cui puntare insieme.</para>
///
/// <para><b>HALT è persistente.</b> Si scrive nel database e sopravvive al riavvio:
/// una regola di sicurezza che si azzera chiudendo e riaprendo il programma non è una
/// regola. Si toglie a mano, dalla scheda Impostazioni, e la rimozione finisce nel
/// registro.</para>
///
/// <para>La contabilità sta nella tabella <c>risk_ledger</c>: una riga per puntata
/// riuscita (uscita) e una per vincita (entrata, al valore reale del bene meno i
/// costi). I totali del giorno si tengono in memoria e si rileggono a ogni cambio di
/// giorno, così la verifica per puntata non interroga il disco.</para>
/// </summary>
public static class RiskManager
{
public readonly record struct Verdict(bool Allowed, string? Reason);
private static readonly object Sync = new();
private static AuctionDatabase Db => AuctionDatabase.Instance;
private static DateTime _ledgerDay = DateTime.MinValue;
private static double _todaySpent; // euro usciti oggi (puntate)
private static double _todayGained; // euro entrati oggi (vincite)
private static double _cumulative; // saldo cumulato di sempre
private static double _peak; // massimo del saldo cumulato
private static bool _ledgerLoaded;
private static string? _haltReason;
private static bool _haltLoaded;
// ── HALT ─────────────────────────────────────────────────────────
/// <summary>Il motivo dell'HALT in corso, o null.</summary>
public static string? HaltReason
{
get
{
lock (Sync)
{
if (!_haltLoaded)
{
_haltLoaded = true;
try { _haltReason = Db.ScalarString("SELECT value FROM meta WHERE key = 'risk_halt'"); }
catch { _haltReason = null; }
}
return _haltReason;
}
}
}
public static bool Halted => !string.IsNullOrEmpty(HaltReason);
public static void Halt(string reason)
{
lock (Sync)
{
_haltLoaded = true;
_haltReason = $"{DateTime.Now:dd/MM HH:mm} — {reason}";
Db.Enqueue("INSERT INTO meta(key, value) VALUES('risk_halt', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", _haltReason);
Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, note) VALUES(?1, 'halt', 0, ?2)", AuctionDatabase.Now(), reason);
}
}
public static void Resume()
{
lock (Sync)
{
_haltLoaded = true;
_haltReason = null;
Db.Enqueue("DELETE FROM meta WHERE key = 'risk_halt'");
Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, note) VALUES(?1, 'resume', 0, 'HALT tolto a mano')", AuctionDatabase.Now());
}
}
// ── Contabilità ──────────────────────────────────────────────────
private static void EnsureLedger()
{
var today = DateTime.Today;
if (_ledgerLoaded && _ledgerDay == today) return;
_ledgerDay = today;
_ledgerLoaded = true;
_todaySpent = 0;
_todayGained = 0;
_cumulative = 0;
_peak = 0;
try
{
var since = AuctionDatabase.Iso(today); // mezzanotte locale, scritta in UTC
foreach (var r in Db.Query("SELECT kind, amount_euro FROM risk_ledger WHERE ts >= ?1 AND kind IN ('bid', 'win')", since))
{
if (r.Str("kind") == "bid") _todaySpent += -r.Dbl("amount_euro");
else _todayGained += r.Dbl("amount_euro");
}
// Il picco del saldo si ricostruisce scorrendo la storia in ordine.
foreach (var r in Db.Query("SELECT amount_euro FROM risk_ledger WHERE kind IN ('bid', 'win') ORDER BY id"))
{
_cumulative += r.Dbl("amount_euro");
if (_cumulative > _peak) _peak = _cumulative;
}
}
catch { /* senza contabilità si applicano solo i vincoli che non ne hanno bisogno */ }
}
/// <summary>Una puntata riuscita: esce il suo costo.</summary>
public static void NoteBidSpent(string auctionId, double bidCost)
{
if (bidCost <= 0) return;
lock (Sync)
{
EnsureLedger();
_todaySpent += bidCost;
_cumulative -= bidCost;
Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, auction_id) VALUES(?1, 'bid', ?2, ?3)",
AuctionDatabase.Now(), -bidCost, auctionId);
}
}
/// <summary>Una vincita: entra il valore reale del bene meno prezzo, fee e spedizione (le puntate sono già uscite).</summary>
public static void NoteWin(string auctionId, double netGain, string note)
{
lock (Sync)
{
EnsureLedger();
_todayGained += netGain;
_cumulative += netGain;
if (_cumulative > _peak) _peak = _cumulative;
Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, auction_id, note) VALUES(?1, 'win', ?2, ?3, ?4)",
AuctionDatabase.Now(), netGain, auctionId, note);
}
}
/// <summary>I numeri del giorno e di sempre, per la scheda.</summary>
public static (double TodaySpent, double TodayGained, double TodayNet, double Cumulative, double Peak, double Drawdown) Snapshot()
{
lock (Sync)
{
EnsureLedger();
return (_todaySpent, _todayGained, _todayGained - _todaySpent, _cumulative, _peak, _peak - _cumulative);
}
}
// ── Autorizzazione ───────────────────────────────────────────────
/// <summary>
/// Può partire una puntata dal vivo su quest'asta? Controlla, nell'ordine:
/// l'HALT, lo stop-loss del giorno, il drawdown, il tetto di spesa del giorno, le
/// aste in gioco insieme. Il primo è assoluto; gli altri valgono solo se impostati
/// (0 = nessun limite).
/// </summary>
public static Verdict Authorize(AuctionInfo auction, AppSettings settings, IReadOnlyList<AuctionInfo>? allAuctions = null)
{
if (Halted)
return new Verdict(false, $"HALT del gestore del rischio: {HaltReason}. Si toglie dalle Impostazioni");
lock (Sync)
{
EnsureLedger();
if (settings.DailyStopLossEuro > 0 && _todaySpent - _todayGained >= settings.DailyStopLossEuro)
{
var reason = $"stop-loss giornaliero raggiunto: perdita netta di oggi {_todaySpent - _todayGained:F2} € ≥ {settings.DailyStopLossEuro:F2} €";
HaltUnlocked(reason);
return new Verdict(false, reason);
}
if (settings.MaxDrawdownEuro > 0 && _peak - _cumulative >= settings.MaxDrawdownEuro)
{
var reason = $"drawdown massimo raggiunto: {_peak - _cumulative:F2} € sotto il picco (limite {settings.MaxDrawdownEuro:F2} €)";
HaltUnlocked(reason);
return new Verdict(false, reason);
}
if (settings.DailyBudgetEuro > 0 && _todaySpent + Math.Max(0, auction.BidCost) > settings.DailyBudgetEuro)
return new Verdict(false, $"tetto di spesa del giorno: spesi {_todaySpent:F2} € su {settings.DailyBudgetEuro:F2} €");
}
if (settings.MaxConcurrentActiveAuctions > 0 && allAuctions != null)
{
// Aste su cui si sta già spendendo in questa sessione, quest'asta esclusa.
var inGioco = allAuctions.Count(a =>
a.State == RunState.Active && a.SessionBidCount > 0 &&
!string.Equals(a.AuctionId, auction.AuctionId, StringComparison.Ordinal));
if (auction.SessionBidCount == 0 && inGioco >= settings.MaxConcurrentActiveAuctions)
return new Verdict(false, $"già {inGioco} aste in gioco insieme (limite {settings.MaxConcurrentActiveAuctions})");
}
return new Verdict(true, null);
}
private static void HaltUnlocked(string reason)
{
_haltLoaded = true;
_haltReason = $"{DateTime.Now:dd/MM HH:mm} — {reason}";
Db.Enqueue("INSERT INTO meta(key, value) VALUES('risk_halt', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", _haltReason);
Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, note) VALUES(?1, 'halt', 0, ?2)", AuctionDatabase.Now(), reason);
}
/// <summary>Solo per i test: dimentica contabilità e HALT in memoria.</summary>
public static void ResetForTests()
{
lock (Sync)
{
_ledgerLoaded = false;
_haltLoaded = false;
_haltReason = null;
}
}
}
}
+69 -37
View File
@@ -56,57 +56,89 @@ namespace AutoBidder.Services
var failed = 0;
var skipped = 0;
for (var i = 0; i < missing.Count; i++)
// ── Perché si salva a blocchi ────────────────────────────────
// Un Append per asta rilegge e riscrive l'intero storico ogni volta:
// su un recupero completo (4675 aste) erano minuti di solo rimescolamento
// JSON e svariati gigabyte scritti sul disco. Un blocco ogni cinquanta
// taglia i salvataggi di cinquanta volte senza mettere a rischio più di
// cinquanta richieste già pagate: quello che non è ancora stato salvato
// viene comunque scritto all'uscita, annullamento compreso.
const int DimensioneBlocco = 50;
var daSalvare = new List<CompletedAuctionRecord>(DimensioneBlocco);
void Deposita()
{
if (ct.IsCancellationRequested) break;
if (daSalvare.Count == 0) return;
var record = missing[i];
CompletedAuctionsStore.AppendMany(daSalvare);
daSalvare.Clear();
}
try
try
{
for (var i = 0; i < missing.Count; i++)
{
var state = await FetchAsync(record.AuctionId, ct).ConfigureAwait(false);
if (ct.IsCancellationRequested) break;
if (state?.WinnerBidsTotal is null)
{
skipped++;
}
else
{
record.WinnerBidsPaid = state.WinnerBidsPaid;
record.WinnerBidsFree = state.WinnerBidsFree;
var record = missing[i];
var check = AuctionIntegrity.CheckWinnerBids(record);
if (check.IsTrustworthy)
try
{
var state = await FetchAsync(record.AuctionId, ct).ConfigureAwait(false);
if (state?.WinnerBidsTotal is null)
{
// Si riscrive solo il record toccato: Append sostituisce
// quello con lo stesso id.
CompletedAuctionsStore.Append(record);
updated++;
skipped++;
}
else
{
// Il valore c'è ma non regge il confronto col prezzo: non si
// salva, e si dice perché. Meglio un buco che una media storta.
record.WinnerBidsPaid = null;
record.WinnerBidsFree = null;
skipped++;
OnLog?.Invoke($"[RECUPERO] {record.Name}: scartato ({check.Reason})");
record.WinnerBidsPaid = state.WinnerBidsPaid;
record.WinnerBidsFree = state.WinnerBidsFree;
var check = AuctionIntegrity.CheckWinnerBids(record);
if (check.IsTrustworthy)
{
// Va nel blocco: AppendMany sostituisce i record con lo
// stesso id, quindi il risultato è identico a un Append
// per ciascuno, con un salvataggio invece di cinquanta.
daSalvare.Add(record);
if (daSalvare.Count >= DimensioneBlocco) Deposita();
updated++;
}
else
{
// Il valore c'è ma non regge il confronto col prezzo: non
// si salva, e si dice perché. Meglio un buco che una media
// storta.
record.WinnerBidsPaid = null;
record.WinnerBidsFree = null;
skipped++;
OnLog?.Invoke($"[RECUPERO] {record.Name}: scartato ({check.Reason})");
}
}
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
failed++;
OnLog?.Invoke($"[RECUPERO] {record.AuctionId}: {ex.Message}");
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
failed++;
OnLog?.Invoke($"[RECUPERO] {record.AuctionId}: {ex.Message}");
}
OnProgress?.Invoke(i + 1, missing.Count, updated);
OnProgress?.Invoke(i + 1, missing.Count, updated);
if (!await Wait.DelayAsync(DelayBetweenRequestsMs, ct).ConfigureAwait(false)) break;
if (!await Wait.DelayAsync(DelayBetweenRequestsMs, ct).ConfigureAwait(false)) break;
}
}
finally
{
// Anche se si esce per annullamento o per un errore: le richieste già
// fatte sono state pagate al server, buttarne i risultati significa
// rifarle al giro dopo.
Deposita();
}
return new Result(missing.Count, updated, failed, skipped);
-232
View File
@@ -1,232 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoBidder.Models;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il dossier è l'unico posto in cui certi dati esistono: Bidoo non espone né i tempi delle
/// puntate né l'andamento del prezzo di un'asta conclusa. Le proprietà da difendere sono
/// quindi tre — che il file <b>si scriva</b>, che ogni riga sia <b>JSON valido da sola</b>
/// (è quello che permette di leggerlo a pezzi e di sopravvivere a un'interruzione), e che il
/// riepilogo finale dica se la storia è completa.
/// </summary>
public class AuctionDossierTests
{
private static AppSettings Settings(bool rawPolls = true) => new()
{
WriteAuctionDossiers = true,
DossierIncludeRawPolls = rawPolls
};
private static AuctionInfo NewAuction() => new()
{
AuctionId = "test-" + Guid.NewGuid().ToString("N")[..8],
Name = "Cuffie Sony WH-1000XM5",
BidBeforeDeadlineMs = 800,
BuyNowPrice = 299,
ShippingCost = 6.90
};
private static AuctionState State(double price, double timer, int ping = 70) => new()
{
Price = price,
Timer = timer,
PollingLatencyMs = ping,
Status = AuctionStatus.Running,
LastBidder = "marco_82",
ExpiryUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (long)timer
};
private static AuctionDetailRecord Detail(AuctionInfo auction) => new()
{
AuctionId = auction.AuctionId,
Name = auction.Name,
Outcome = "Persa",
FinalPrice = 4.87,
BuyNowPrice = auction.BuyNowPrice,
ShippingCost = auction.ShippingCost,
MyBids = 9,
EndedAt = DateTime.Now,
FirstSeenAt = DateTime.Now.AddMinutes(-35),
ObservedMinutes = 35,
ConfiguredLeadMs = 800
};
private static string[] LinesOf(AuctionDossier dossier)
{
FileLogWriter.FlushAll();
return File.ReadAllLines(dossier.Path).Where(l => l.Length > 0).ToArray();
}
[Fact]
public void Aprire_il_dossier_scrive_lintestazione()
{
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
var header = JsonDocument.Parse(LinesOf(dossier)[0]).RootElement;
Assert.Equal("header", header.GetProperty("type").GetString());
Assert.Equal(auction.AuctionId, header.GetProperty("auctionId").GetString());
// Il valore del prodotto deve stare nel file: senza, il prezzo finale non dice
// se è stato un affare.
Assert.Equal(299, header.GetProperty("product").GetProperty("buyNowPrice").GetDouble());
// E con che anticipo la si stava seguendo: due aste con anticipi diversi non sono
// confrontabili fra loro.
Assert.Equal(800, header.GetProperty("config").GetProperty("bidBeforeDeadlineMs").GetInt32());
AuctionDossier.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Ogni_riga_e_json_valido_per_conto_suo()
{
// È la ragione del formato: un file troncato a metà perde l'ultima riga, non tutto.
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
dossier.Poll(State(1.24, 15), includeRaw: true);
dossier.Reset(1, 1.25, "marco_82");
dossier.MyBid(1.25, 800, 762.4, 68, success: true, error: null, bidsUsed: 4, remainingBids: 255);
dossier.Log("info", "Status", "asta in corso");
var lines = LinesOf(dossier);
Assert.All(lines, line => JsonDocument.Parse(line));
Assert.Equal(5, lines.Length); // intestazione + quattro eventi
AuctionDossier.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void La_mia_puntata_registra_anticipo_voluto_e_ottenuto()
{
// È la coppia su cui si tara l'anticipo: da sola nessuna delle due dice nulla.
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
dossier.MyBid(1.25, plannedLeadMs: 800, actualLeadMs: 762.4, pingMs: 68,
success: true, error: null, bidsUsed: 4, remainingBids: 255);
var bid = JsonDocument.Parse(LinesOf(dossier)[1]).RootElement;
Assert.Equal("my_bid", bid.GetProperty("type").GetString());
Assert.Equal(800, bid.GetProperty("plannedLeadMs").GetInt32());
Assert.Equal(762.4, bid.GetProperty("actualLeadMs").GetDouble(), 1);
Assert.Equal(-37.6, bid.GetProperty("leadErrorMs").GetDouble(), 1);
Assert.Equal(68, bid.GetProperty("pingMs").GetInt32());
Assert.Equal("ok", bid.GetProperty("result").GetString());
AuctionDossier.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Senza_poll_grezzi_il_ping_si_misura_lo_stesso()
{
// Spegnere i poll grezzi riduce la dimensione del file, non la qualità delle
// statistiche di rete: quelle si accumulano comunque.
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings(rawPolls: false))!;
dossier.Poll(State(1.24, 15, ping: 60), includeRaw: false);
dossier.Poll(State(1.30, 12, ping: 80), includeRaw: false);
Assert.Single(LinesOf(dossier)); // solo l'intestazione
AuctionDossier.Close(auction, Detail(auction), null);
var summary = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
var network = summary.GetProperty("network");
Assert.Equal(2, network.GetProperty("samples").GetInt32());
Assert.Equal(70, network.GetProperty("avgPingMs").GetDouble());
Assert.Equal(60, network.GetProperty("minPingMs").GetInt32());
Assert.Equal(80, network.GetProperty("maxPingMs").GetInt32());
}
[Fact]
public void Il_riepilogo_dice_se_la_storia_e_completa()
{
var auction = NewAuction();
auction.ObservedFromStart = true;
auction.ObservedToEnd = true;
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
AuctionDossier.Close(auction, Detail(auction), null);
var summary = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
Assert.Equal("summary", summary.GetProperty("type").GetString());
Assert.True(summary.GetProperty("coverage").GetProperty("complete").GetBoolean());
// Costo reale se avessi vinto: prezzo + puntate + spedizione.
Assert.True(summary.GetProperty("value").GetProperty("totalCostIfWon").GetDouble() > 4.87);
}
[Fact]
public void Dopo_il_riepilogo_non_si_scrive_piu_nulla()
{
// Una riga in coda al riepilogo farebbe dubitare di tutto ciò che c'è sopra.
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
AuctionDossier.Close(auction, Detail(auction), null);
var before = LinesOf(dossier).Length;
dossier.Log("info", "Status", "questa riga non deve comparire");
Assert.Equal(before, LinesOf(dossier).Length);
}
[Fact]
public void Unasta_tolta_prima_della_fine_lo_dichiara()
{
// Una serie di prezzi che si interrompe sembra un'asta finita a quel prezzo: va
// detto che i dati sono incompleti.
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
AuctionDossier.Abandon(auction.AuctionId, "rimossa dal monitor");
var last = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
Assert.Equal("abandoned", last.GetProperty("type").GetString());
Assert.Contains("incompleti", last.GetProperty("note").GetString());
}
[Fact]
public void Riaprire_la_stessa_asta_riprende_lo_stesso_file()
{
// Un riavvio a metà asta non deve spezzare la storia in due file.
var auction = NewAuction();
var first = AuctionDossier.OpenFor(auction, Settings())!;
var path = first.Path;
AuctionDossier.Abandon(auction.AuctionId, "simulazione riavvio");
var second = AuctionDossier.OpenFor(auction, Settings())!;
Assert.Equal(path, second.Path);
var lines = LinesOf(second);
Assert.Equal("resumed", JsonDocument.Parse(lines[^1]).RootElement.GetProperty("type").GetString());
AuctionDossier.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Con_i_dossier_spenti_non_si_scrive_nessun_file()
{
var auction = NewAuction();
Assert.Null(AuctionDossier.OpenFor(auction, new AppSettings { WriteAuctionDossiers = false }));
}
}
+254
View File
@@ -0,0 +1,254 @@
using System;
using System.Linq;
using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il registratore è l'unico posto in cui certi dati esistono: Bidoo non espone né i
/// tempi delle puntate né l'andamento del prezzo di un'asta conclusa. Le proprietà da
/// difendere: l'apertura crea la riga dell'asta e del prodotto, ogni evento finisce
/// nella sua tabella, il riepilogo chiude la riga, l'abbandono la marca, e le
/// interrogazioni uguali non si scrivono due volte.
/// </summary>
[Collection("Database")]
public class AuctionRecorderTests
{
private static AuctionDatabase Db => AuctionDatabase.Instance;
private static AppSettings Settings(bool rawPolls = true) => new()
{
RecordAuctions = true,
RecordPolls = rawPolls
};
private static AuctionInfo NewAuction() => new()
{
AuctionId = "rec-" + Guid.NewGuid().ToString("N")[..10],
Name = "Cuffie Sony WH-1000XM5",
BidBeforeDeadlineMs = 800,
BuyNowPrice = 299,
ShippingCost = 6.90
};
private static AuctionState State(double price, double timer, int ping = 70, string bidder = "marco_82") => new()
{
Price = price,
Timer = timer,
PollingLatencyMs = ping,
Status = AuctionStatus.Running,
LastBidder = bidder,
ExpiryUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (long)timer,
ServerUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
private static AuctionDetailRecord Detail(AuctionInfo auction) => new()
{
AuctionId = auction.AuctionId,
Name = auction.Name,
ProductKey = ProductKeyHelper.GenerateProductKey(auction.Name),
Outcome = "Persa",
Winner = "marco_82",
FinalPrice = 4.87,
BuyNowPrice = auction.BuyNowPrice,
ShippingCost = auction.ShippingCost,
MyBids = 9,
EndedAt = DateTime.Now,
FirstSeenAt = DateTime.Now.AddMinutes(-35),
ObservedMinutes = 35,
ConfiguredLeadMs = 800,
DistinctBidders = 2,
TotalObservedBids = 12,
Resets = 11
};
[Fact]
public void Aprire_crea_la_riga_dellasta_e_del_prodotto()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
Db.Flush();
var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auction.AuctionId)!;
Assert.Equal("open", row.Str("status"));
Assert.Equal(auction.Name, row.Str("name"));
Assert.Equal(299, row.Dbl("buy_now_price"));
Assert.Equal(800, row.Int("configured_lead_ms"));
Assert.True(row.Long("item_id") > 0);
var item = Db.QueryOne("SELECT * FROM items WHERE item_id = ?1", row.Long("item_id"))!;
Assert.Equal(ProductKeyHelper.GenerateProductKey(auction.Name), item.Str("product_key"));
AuctionRecorder.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Ogni_evento_finisce_nella_sua_tabella()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
rec.Poll(State(1.24, 15), includeRaw: true);
rec.ForeignBid(new BidHistoryEntry { Username = "marco_82", Price = 1.25m, BidType = "Manuale", Timestamp = 1_700_000_000 }, 1.25);
rec.Reset(1, 1.25, "marco_82");
rec.MyBid(1.26, 800, 762.4, 68, success: true, error: null, bidsUsed: 4, remainingBids: 255, rttMs: 90);
rec.Log("info", "Status", "asta in corso");
Db.Flush();
Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM polls WHERE auction_id = ?1", auction.AuctionId));
Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM bids WHERE auction_id = ?1", auction.AuctionId));
Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM resets WHERE auction_id = ?1", auction.AuctionId));
Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM my_bids WHERE auction_id = ?1", auction.AuctionId));
Assert.True(Db.ScalarLong("SELECT COUNT(*) AS n FROM auction_log WHERE auction_id = ?1", auction.AuctionId) >= 2);
var bid = Db.QueryOne("SELECT * FROM bids WHERE auction_id = ?1", auction.AuctionId)!;
Assert.Equal("Manuale", bid.Str("bid_type"));
Assert.Equal(1_700_000_000, bid.Long("server_ts"));
Assert.Equal(15, bid.Dbl("timer_before"));
var mine = Db.QueryOne("SELECT * FROM my_bids WHERE auction_id = ?1", auction.AuctionId)!;
Assert.Equal(800, mine.Int("planned_lead_ms"));
Assert.Equal(762.4, mine.Dbl("actual_lead_ms"), 1);
Assert.Equal(90, mine.Int("rtt_ms"));
AuctionRecorder.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Una_puntata_ricostruita_non_sovrascrive_il_tipo_vero()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
rec.ForeignBid(new BidHistoryEntry { Username = "anna", Price = 2.00m, BidType = "Auto", Timestamp = 1_700_000_100 }, 2.00);
rec.ForeignBid(new BidHistoryEntry { Username = "anna", Price = 2.00m, BidType = BidHistoryEntry.TipoSconosciuto }, 2.00);
Db.Flush();
var rows = Db.Query("SELECT * FROM bids WHERE auction_id = ?1", auction.AuctionId);
Assert.Single(rows);
Assert.Equal("Auto", rows[0].Str("bid_type"));
AuctionRecorder.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Le_interrogazioni_uguali_non_si_scrivono_due_volte()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
for (var i = 0; i < 10; i++) rec.Poll(State(1.24, 15.9 - i * 0.05), includeRaw: true);
rec.Poll(State(1.25, 15), includeRaw: true); // cambia il prezzo: si scrive
Db.Flush();
Assert.Equal(2, Db.ScalarLong("SELECT COUNT(*) AS n FROM polls WHERE auction_id = ?1", auction.AuctionId));
AuctionRecorder.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Il_riepilogo_chiude_la_riga_e_aggrega_i_puntatori()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
rec.ForeignBid(new BidHistoryEntry { Username = "marco_82", Price = 1.25m, BidType = "Manuale", Timestamp = 1_700_000_000 }, 1.25);
rec.ForeignBid(new BidHistoryEntry { Username = "anna", Price = 1.26m, BidType = "Auto", Timestamp = 1_700_000_006 }, 1.26);
rec.ForeignBid(new BidHistoryEntry { Username = "marco_82", Price = 1.27m, BidType = "Manuale", Timestamp = 1_700_000_012 }, 1.27);
AuctionRecorder.Close(auction, Detail(auction), State(4.87, 0));
Db.Flush();
var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auction.AuctionId)!;
Assert.Equal("closed", row.Str("status"));
Assert.Equal("Persa", row.Str("outcome"));
Assert.Equal(4.87, row.Dbl("final_price"));
Assert.Equal(9, row.Int("my_bids"));
Assert.Null(AuctionRecorder.For(auction.AuctionId));
var marco = Db.QueryOne("SELECT * FROM bidders WHERE username = 'marco_82'")!;
Assert.True(marco.Int("n_bids") >= 2);
Assert.True(marco.Int("n_manual") >= 2);
Assert.True(marco.Int("win_count") >= 1);
// Lo storico compatto vede la stessa riga.
var record = CompletedAuctionsStore.LoadAll().FirstOrDefault(r => r.AuctionId == auction.AuctionId);
Assert.NotNull(record);
Assert.Equal("Persa", record!.Outcome);
// E la scheda dettagliata pure.
var detail = AuctionDetailStore.Load(auction.AuctionId);
Assert.NotNull(detail);
Assert.Equal(2, detail!.DistinctBidders);
}
[Fact]
public void Le_decisioni_ricevono_lesito_alla_chiusura()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
// Shadow: avrei puntato a 4.86 e nessuno ha più puntato -> avrei vinto.
var vincente = AuctionRecorder.RecordDecision(auction, State(4.86, 0.5), "BID", 0.4, 0.5, "ev positivo", "shadow", "Calmo", "ev-regime", 500);
// Shadow: non avrei puntato a 3.00 e poi il prezzo è salito -> giusto non puntare.
var giusta = AuctionRecorder.RecordDecision(auction, State(3.00, 0.5), "NO-OP", 0.01, -0.1, "ev negativo", "shadow", "Sfogo", "ev-regime", 500);
// Live: ho puntato a 4.85 (prezzo dopo la mia puntata 4.86) e nessuno ha rilanciato -> vinta.
var coperta = AuctionRecorder.RecordDecision(auction, State(4.85, 0.5), "BID", 0.4, 0.5, "ev positivo", "live", "Calmo", "ev-regime", 500);
rec.MyBid(4.85, 500, 480, 60, success: true, error: null, bidsUsed: 1, remainingBids: 10, rttMs: 80, decisionId: coperta);
var detail = Detail(auction);
detail.FinalPrice = 4.86;
AuctionRecorder.Close(auction, detail, State(4.86, 0));
Db.Flush();
Assert.Equal("would_win", Db.ScalarString("SELECT outcome FROM bot_decisions WHERE decision_id = ?1", vincente));
Assert.Equal("right_skip", Db.ScalarString("SELECT outcome FROM bot_decisions WHERE decision_id = ?1", giusta));
Assert.Equal("won", Db.ScalarString("SELECT outcome FROM bot_decisions WHERE decision_id = ?1", coperta));
Assert.Equal(1, Db.ScalarLong("SELECT executed FROM bot_decisions WHERE decision_id = ?1", coperta));
}
[Fact]
public void Labbandono_marca_la_riga_senza_riepilogo()
{
var auction = NewAuction();
AuctionRecorder.OpenFor(auction, Settings());
AuctionRecorder.Abandon(auction.AuctionId, "rimossa dal monitor");
Db.Flush();
var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auction.AuctionId)!;
Assert.Equal("abandoned", row.Str("status"));
Assert.Null(row.StrOrNull("outcome"));
Assert.Equal("rimossa dal monitor", row.Str("abandon_reason"));
// Non è nello storico delle concluse.
Assert.DoesNotContain(CompletedAuctionsStore.LoadAll(), r => r.AuctionId == auction.AuctionId);
}
[Fact]
public void Riaprire_la_stessa_asta_riusa_la_riga()
{
var auction = NewAuction();
var first = AuctionRecorder.OpenFor(auction, Settings())!;
first.Poll(State(1.00, 10), includeRaw: true);
AuctionRecorder.Abandon(auction.AuctionId, "simulazione riavvio");
var second = AuctionRecorder.OpenFor(auction, Settings())!;
second.Poll(State(1.01, 10), includeRaw: true);
Db.Flush();
Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM auctions WHERE auction_id = ?1", auction.AuctionId));
Assert.Equal(2, Db.ScalarLong("SELECT COUNT(*) AS n FROM polls WHERE auction_id = ?1", auction.AuctionId));
AuctionRecorder.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Con_la_registrazione_spenta_non_si_apre_nulla()
{
var auction = NewAuction();
Assert.Null(AuctionRecorder.OpenFor(auction, new AppSettings { RecordAuctions = false }));
}
}
+136
View File
@@ -0,0 +1,136 @@
using System;
using AutoBidder.Models;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Riconoscimento dell'autopuntata avversaria.
///
/// <para>La proprieta' da difendere non e' "riconosce le macchine": e' che <b>non si ritira
/// da un'asta che si poteva vincere</b>. Sbagliare in quel verso costa un'asta; sbagliare
/// nell'altro costa cinque puntate. Da qui la scelta di azzerare il conteggio a ogni
/// risposta fuori firma invece di scalarlo.</para>
///
/// <para>I numeri vengono dai dossier: 2.799.681 intervalli fra puntate consecutive in 5037
/// aste, mediana 6,0 s, quartili 5,3 e 7,1 s.</para>
/// </summary>
public class AutoBidDuelTests
{
[Theory]
[InlineData(6.0, true)] // la mediana misurata
[InlineData(5.3, true)] // primo quartile
[InlineData(7.1, true)] // terzo quartile
[InlineData(3.4, false)] // troppo svelta: qualcuno che stava guardando
[InlineData(13.0, false)] // troppo lenta
[InlineData(0.0, false)] // nessun ritardo: non e' una risposta
[InlineData(-2.0, false)] // prima della nostra puntata: non e' una risposta
public void La_firma_e_sei_secondi_piu_o_meno_due_e_mezzo(double ritardo, bool atteso)
{
Assert.Equal(atteso, AutoBidDuel.ERispostaAutomatica(ritardo));
}
[Fact]
public void Le_risposte_in_firma_si_sommano()
{
var n = 0;
for (var i = 0; i < 5; i++) n = AutoBidDuel.Aggiorna(n, 6.0);
Assert.Equal(5, n);
Assert.True(AutoBidDuel.DuelloRiconosciuto(n));
}
[Fact]
public void Una_risposta_fuori_firma_azzera_tutto()
{
// Il punto della regola: quattro indizi piu' uno contrario non fanno quattro
// indizi. Serve una sequenza pulita, perche' ritirarsi per sbaglio costa l'asta.
var n = 0;
for (var i = 0; i < 4; i++) n = AutoBidDuel.Aggiorna(n, 6.0);
Assert.Equal(4, n);
n = AutoBidDuel.Aggiorna(n, 2.0);
Assert.Equal(0, n);
Assert.False(AutoBidDuel.DuelloRiconosciuto(n));
}
[Fact]
public void Sotto_la_soglia_non_ci_si_ritira()
{
var n = 0;
for (var i = 0; i < 4; i++) n = AutoBidDuel.Aggiorna(n, 6.0);
Assert.False(AutoBidDuel.DuelloRiconosciuto(n));
}
// ── Lo stato sull'asta ───────────────────────────────────────────────
private static AuctionInfo Asta() => new() { AuctionId = "1", Name = "prova" };
[Fact]
public void Il_duello_si_riconosce_una_volta_sola()
{
var a = Asta();
var t = DateTime.UtcNow;
var riconosciuto = 0;
for (var i = 0; i < 8; i++)
{
a.LastMyBidAtUtc = t;
t = t.AddSeconds(6);
if (a.NoteOpponentResponse(t, 5)) riconosciuto++;
t = t.AddSeconds(7);
}
Assert.True(a.AutoBidDuelDetected);
Assert.Equal(1, riconosciuto); // non una volta per ogni risposta successiva
}
[Fact]
public void Senza_una_nostra_puntata_non_si_misura_niente()
{
// Una risposta avversaria che non risponde a noi non dice nulla sulla loro
// autopuntata: puo' essere un terzo che punta per conto suo.
var a = Asta();
Assert.False(a.NoteOpponentResponse(DateTime.UtcNow, 5));
Assert.Equal(0, a.AutoResponsesInARow);
Assert.False(a.AutoBidDuelDetected);
}
[Fact]
public void Ogni_risposta_si_misura_dalla_puntata_sua()
{
// Consumato il riferimento, una seconda risposta di fila non deve contare due
// volte lo stesso intervallo.
var a = Asta();
var t = DateTime.UtcNow;
a.LastMyBidAtUtc = t;
a.NoteOpponentResponse(t.AddSeconds(6), 5);
Assert.Equal(1, a.AutoResponsesInARow);
a.NoteOpponentResponse(t.AddSeconds(12), 5);
Assert.Equal(1, a.AutoResponsesInARow);
}
[Fact]
public void Un_avversario_umano_irregolare_non_viene_scambiato_per_macchina()
{
var a = Asta();
var t = DateTime.UtcNow;
// Ritardi veri, presi dalla distribuzione misurata fuori dalla firma.
foreach (var ritardo in new[] { 6.0, 3.1, 6.2, 9.4, 5.9, 2.2, 6.0 })
{
a.LastMyBidAtUtc = t;
t = t.AddSeconds(ritardo);
a.NoteOpponentResponse(t, 5);
t = t.AddSeconds(7);
}
Assert.False(a.AutoBidDuelDetected);
}
}
+217
View File
@@ -0,0 +1,217 @@
using AutoBidder.Ml;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il regime di concorrenza deve avere isteresi nel verso giusto: uscire e' facile, rientrare
/// e' difficile, e ogni sondaggio coperto rende il rientro successivo piu' difficile.
/// Sbagliare a rientrare costa una puntata; sbagliare a uscire costa un'attesa.
/// </summary>
public class CompetitionRegimeTests
{
[Fact]
public void In_calmo_si_punta_quando_il_valore_atteso_e_positivo()
{
var r = new CompetitionRegime();
Assert.True(r.Osserva(true));
Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
}
[Fact]
public void Tre_negativi_di_fila_portano_allo_sfogo_ma_uno_solo_no()
{
var r = new CompetitionRegime();
Assert.False(r.Osserva(false));
Assert.False(r.Osserva(false));
Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
Assert.False(r.Osserva(false));
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
}
[Fact]
public void Un_positivo_in_mezzo_azzera_il_conto_dei_negativi()
{
var r = new CompetitionRegime();
r.Osserva(false); r.Osserva(false);
r.Osserva(true);
r.Osserva(false); r.Osserva(false);
Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
}
private static CompetitionRegime InSfogo()
{
var r = new CompetitionRegime();
for (var i = 0; i < CompetitionRegime.NegativiPerSfogo; i++) r.Osserva(false);
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
return r;
}
[Fact]
public void Dallo_sfogo_si_rientra_solo_dopo_abbastanza_cicli_buoni_di_fila()
{
var r = InSfogo();
Assert.False(r.Osserva(true));
Assert.False(r.Osserva(true));
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
Assert.True(r.Osserva(true)); // terzo: puntata di prova concessa
Assert.Equal(CompetitionRegime.Stato.Sondaggio, r.StatoAttuale);
}
[Fact]
public void Un_ciclo_cattivo_durante_lo_sfogo_azzera_la_serie()
{
var r = InSfogo();
r.Osserva(true); r.Osserva(true);
r.Osserva(false);
r.Osserva(true); r.Osserva(true);
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
}
[Fact]
public void Un_sondaggio_coperto_subito_raddoppia_la_pazienza()
{
var r = InSfogo();
for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
r.PuntataPiazzata();
r.RispostaAvversaria(6.0); // la firma dell'autopuntata
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
Assert.Equal(CompetitionRegime.PazienzaIniziale * 2, r.Pazienza);
Assert.Equal(1, r.SondaggiFalliti);
}
[Fact]
public void Un_sondaggio_senza_risposta_riporta_al_calmo()
{
var r = InSfogo();
for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
r.PuntataPiazzata();
r.NessunaRisposta();
Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
}
[Fact]
public void Una_risposta_lenta_al_sondaggio_non_e_una_macchina()
{
var r = InSfogo();
for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
r.PuntataPiazzata();
r.RispostaAvversaria(25.0);
Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
Assert.Equal(0, r.SondaggiFalliti);
}
[Fact]
public void Durante_il_sondaggio_si_punta_una_volta_sola()
{
var r = InSfogo();
for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
r.PuntataPiazzata();
Assert.False(r.Osserva(true)); // la prova e' in corso: si aspetta l'esito
}
[Fact]
public void La_pazienza_ha_un_tetto()
{
var r = new CompetitionRegime();
for (var k = 0; k < 10; k++)
{
if (r.StatoAttuale == CompetitionRegime.Stato.Calmo)
for (var i = 0; i < CompetitionRegime.NegativiPerSfogo; i++) r.Osserva(false);
while (r.StatoAttuale == CompetitionRegime.Stato.Sfogo) r.Osserva(true);
r.PuntataPiazzata();
r.RispostaAvversaria(6.0);
}
Assert.Equal(CompetitionRegime.PazienzaMassima, r.Pazienza);
}
[Fact]
public void L_autopuntata_riconosciuta_manda_in_sfogo_con_pazienza_doppia()
{
var r = new CompetitionRegime();
r.AutopuntataRiconosciuta();
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
Assert.True(r.Pazienza >= CompetitionRegime.PazienzaIniziale * 2);
}
}
/// <summary>
/// L'anticipo adattivo si muove solo dentro i paletti, sale subito su una puntata
/// tardiva e scende piano, e senza campioni usa il predefinito.
/// </summary>
public class LatencyModelTests
{
private static AppSettings Paletti(int min = 300, int max = 1500, int predefinito = 500) =>
new() { LeadMinMs = min, LeadMaxMs = max, DefaultBidBeforeDeadlineMs = predefinito };
[Fact]
public void Senza_campioni_vale_il_predefinito_entro_i_paletti()
{
LatencyModel.ResetForTests();
Assert.Equal(500, LatencyModel.RecommendedLeadMs(Paletti()));
Assert.Equal(600, LatencyModel.RecommendedLeadMs(Paletti(min: 600)));
}
[Fact]
public void L_anticipo_e_margine_piu_coda_della_latenza()
{
LatencyModel.ResetForTests(marginMs: 300);
for (var i = 0; i < 98; i++) LatencyModel.NotePing(50);
LatencyModel.NotePing(400); // la coda: due campioni su cento
LatencyModel.NotePing(400);
// p99 su 100 campioni = il 99-esimo ordinato = 400 -> 300 + 400 = 700
Assert.Equal(700, LatencyModel.RecommendedLeadMs(Paletti()));
}
[Fact]
public void Una_puntata_tardiva_alza_il_margine_subito()
{
LatencyModel.ResetForTests(marginMs: 300);
for (var i = 0; i < 20; i++) LatencyModel.NotePing(50);
var prima = LatencyModel.RecommendedLeadMs(Paletti());
LatencyModel.NoteBid(60, late: true);
var dopo = LatencyModel.RecommendedLeadMs(Paletti());
Assert.Equal(prima + 150, dopo);
}
[Fact]
public void Molte_puntate_in_tempo_lo_abbassano_piano()
{
LatencyModel.ResetForTests(marginMs: 600);
for (var i = 0; i < 20; i++) LatencyModel.NotePing(50);
var prima = LatencyModel.RecommendedLeadMs(Paletti());
// Stessa latenza dei ping, cosi' cambia solo il margine e non la coda.
for (var i = 0; i < 20; i++) LatencyModel.NoteBid(50, late: false);
var dopo = LatencyModel.RecommendedLeadMs(Paletti());
Assert.Equal(prima - 25, dopo);
}
[Fact]
public void Non_esce_mai_dai_paletti()
{
LatencyModel.ResetForTests(marginMs: 2000);
for (var i = 0; i < 20; i++) LatencyModel.NotePing(900);
Assert.Equal(1500, LatencyModel.RecommendedLeadMs(Paletti(max: 1500)));
LatencyModel.ResetForTests(marginMs: 150);
for (var i = 0; i < 20; i++) LatencyModel.NotePing(10);
Assert.Equal(300, LatencyModel.RecommendedLeadMs(Paletti(min: 300)));
}
}
+4 -39
View File
@@ -35,13 +35,9 @@ public class BacktestTests
/// <summary>Impostazioni con tutte le strategie che potrebbero bloccare messe a tacere.</summary>
private static AppSettings Neutral() => new()
{
AntiBotDetectionEnabled = false,
SoftRetreatEnabled = false,
ProbabilisticBiddingEnabled = false,
CompetitionDetectionEnabled = false,
OpponentProfilingEnabled = false,
BankrollManagerEnabled = false,
PriceVelocityBlockPerSecond = 0
AutoBidDuelWithdrawEnabled = false,
LearningGateEnabled = false
};
// ── Lettura ──────────────────────────────────────────────────────────
@@ -195,37 +191,6 @@ public class BacktestTests
// ── Le strategie che bloccano ────────────────────────────────────────
[Fact]
public void Lanti_bot_acceso_blocca_su_cadenza_fissa_e_spento_no()
{
// Un avversario che punta esattamente ogni 4 secondi: e' il caso che la vecchia
// regola scambiava per automatismo, e sui dossier veri costava fra il 4% e il 9%
// delle puntate.
var lines = new List<string> { Header };
long unix = 1000;
for (var i = 0; i < 8; i++)
{
lines.Add(Bid(i * 0.1, "tizio", unix + i * 4, 1.0 + i * 0.01));
}
lines.Add(Poll(1.0, 1100, 1100, bidder: "tizio"));
lines.Add(SummaryLine);
var session = DossierReader.Read(lines);
var acceso = Neutral();
acceso.AntiBotDetectionEnabled = true;
var bloccato = BacktestRunner.Run(session, new BacktestRunner.Options(1000, acceso));
Assert.Equal(1, bloccato.Reached);
Assert.Equal(0, bloccato.Bids);
Assert.Equal(1, bloccato.Blocks["anti-bot"]);
// Con il predefinito nuovo (spento) la puntata parte.
var spento = BacktestRunner.Run(session, new BacktestRunner.Options(1000, Neutral()));
Assert.Equal(1, spento.Bids);
Assert.Empty(spento.Blocks);
}
[Fact]
public void Il_predefinito_dellapplicazione_non_blocca_le_puntate()
{
@@ -253,7 +218,7 @@ public class BacktestTests
{
new() { Cycles = 10, Reached = 1, Bids = 1, FinalPrice = 1 },
new() { Cycles = 20, Reached = 5, Bids = 3, FinalPrice = 2,
Blocks = new Dictionary<string, int>(StringComparer.Ordinal) { ["anti-bot"] = 2 } },
Blocks = new Dictionary<string, int>(StringComparer.Ordinal) { ["sfogo"] = 2 } },
new() { Cycles = 30, Reached = 1, Bids = 1, FinalPrice = 3 }
};
@@ -269,7 +234,7 @@ public class BacktestTests
Assert.Equal(2, a.AuctionsWithOneBid);
var text = BacktestReport.Format(new[] { a });
Assert.Contains("anti-bot=2", text);
Assert.Contains("sfogo=2", text);
}
[Fact]
+152
View File
@@ -309,4 +309,156 @@ public class StatsMaintenanceTests
Assert.Equal(report.Kept, kept.Count);
Assert.Equal(report.Removed, list.Count - kept.Count);
}
// ── Aste seguite solo in parte ───────────────────────────────────────
//
// Il prezzo finale in centesimi e' il totale delle puntate dell'asta: confrontarlo
// con i reset osservati dice quanta asta si e' vista, senza bisogno di registrare
// nulla di nuovo. Vale quindi anche sullo storico raccolto prima della regola.
private static CompletedAuctionRecord Seguita(string id, double price, int resets)
{
var r = R(id: id, price: price, bidsFree: 1);
r.TotalResets = resets;
return r;
}
[Theory]
[InlineData(1.00, 100, 1.0)] // 1,00 EUR = 100 puntate, viste tutte
[InlineData(1.00, 50, 0.5)] // agganciata a meta'
[InlineData(1.00, 0, 0.0)] // vista solo la fine
public void La_copertura_e_i_reset_sul_totale_di_puntate(
double price, int resets, double attesa)
{
Assert.Equal(attesa, AuctionIntegrity.ObservedCoverage(price, resets)!.Value, 3);
}
[Fact]
public void Senza_prezzo_la_copertura_non_si_puo_dire()
{
Assert.Null(AuctionIntegrity.ObservedCoverage(0, 42));
}
[Fact]
public void La_copertura_non_supera_mai_uno()
{
// L'arrotondamento del prezzo puo' produrre qualche reset di troppo: non e'
// un'asta osservata piu' del possibile, e non deve diventare 103%.
Assert.Equal(1.0, AuctionIntegrity.ObservedCoverage(1.00, 103)!.Value, 3);
}
[Fact]
public void Toglie_le_aste_seguite_sotto_la_soglia()
{
var list = new List<CompletedAuctionRecord>
{
Seguita("intera", 1.00, 100),
Seguita("quasi", 1.00, 95),
Seguita("meta", 1.00, 50),
};
var options = new StatsMaintenance.CleanupOptions
{
MinObservedCoverage = 0.90
};
var kept = StatsMaintenance.Apply(list, options);
Assert.Equal(2, kept.Count);
Assert.DoesNotContain(kept, r => r.AuctionId == "meta");
}
[Fact]
public void Spenta_la_soglia_non_toglie_nulla_per_copertura()
{
var list = new List<CompletedAuctionRecord> { Seguita("meta", 1.00, 1) };
var report = StatsMaintenance.Preview(list,
new StatsMaintenance.CleanupOptions { MinObservedCoverage = null });
Assert.DoesNotContain("seguita solo in parte", report.ByReason.Keys);
}
// ── Per prodotto, per esborso, per anomalia ───────────────────────────
private static CompletedAuctionRecord Con(
string id, string key, int mie = 0, double price = 1.00, double? value = 11.00)
{
var r = R(id: id, key: key, price: price, value: value, bidsFree: 1);
r.MyBidsUsed = mie;
return r;
}
[Fact]
public void Toglie_solo_i_prodotti_scelti_senza_badare_alle_maiuscole()
{
var list = new List<CompletedAuctionRecord>
{
Con("1", "buono a"), Con("2", "buono b"), Con("3", "buono a")
};
var options = new StatsMaintenance.CleanupOptions
{
RemoveProductKeys = new HashSet<string>(new[] { "BUONO A" }, StringComparer.OrdinalIgnoreCase)
};
var kept = StatsMaintenance.Apply(list, options);
Assert.Single(kept);
Assert.Equal("2", kept[0].AuctionId);
}
[Fact]
public void Solo_osservate_tiene_quelle_con_mie_puntate()
{
var list = new List<CompletedAuctionRecord> { Con("mia", "x", mie: 5), Con("vista", "x") };
var kept = StatsMaintenance.Apply(list,
new StatsMaintenance.CleanupOptions { RemoveObservedOnly = true });
Assert.Single(kept);
Assert.Equal("mia", kept[0].AuctionId);
}
[Fact]
public void Con_mie_puntate_tiene_solo_il_mercato()
{
var list = new List<CompletedAuctionRecord> { Con("mia", "x", mie: 5), Con("vista", "x") };
var kept = StatsMaintenance.Apply(list,
new StatsMaintenance.CleanupOptions { RemoveWithMyBids = true });
Assert.Single(kept);
Assert.Equal("vista", kept[0].AuctionId);
}
[Fact]
public void Il_prezzo_fuori_scala_si_misura_sul_valore_e_senza_valore_non_si_indovina()
{
// 6 EUR su 10 di valore = 60%; 0,50 su 10 = 5%.
var list = new List<CompletedAuctionRecord>
{
Con("cara", "x", price: 6.00, value: 10.00),
Con("normale", "x", price: 0.50, value: 10.00),
Con("senza valore", "x", price: 6.00, value: null),
};
var kept = StatsMaintenance.Apply(list,
new StatsMaintenance.CleanupOptions { MaxPriceRatioPercent = 40 });
Assert.Equal(2, kept.Count);
Assert.DoesNotContain(kept, r => r.AuctionId == "cara");
Assert.Contains(kept, r => r.AuctionId == "senza valore");
}
[Fact]
public void Un_elenco_prodotti_vuoto_non_toglie_niente()
{
var list = new List<CompletedAuctionRecord> { Con("1", "a"), Con("2", "b") };
var kept = StatsMaintenance.Apply(list,
new StatsMaintenance.CleanupOptions { RemoveProductKeys = new HashSet<string>() });
Assert.Equal(2, kept.Count);
}
}
-223
View File
@@ -1,223 +0,0 @@
using AutoBidder.Models;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// La taratura dell'anticipo tocca il parametro che decide se una puntata arriva o no.
/// Un consiglio troppo aggressivo si paga in aste perse, quindi la proprietà da
/// verificare è la prudenza: mai scendere sotto il margine di rete.
/// </summary>
public class BidLeadAdviceTests
{
// Le prove usano lo store reale, quindi si parte sempre da pulito.
private static void Reset() => BidLeadStats.Clear();
private static void Add(int configured, double actual, double ping = 60, bool ok = true, bool late = false)
=> BidLeadStats.Record(new BidLeadStats.Sample
{
AuctionId = "t",
ConfiguredMs = configured,
ActualMs = actual,
PingMs = ping,
Success = ok,
TooLate = late
});
[Fact]
public void Senza_dati_sufficienti_non_da_consigli()
{
Reset();
for (var i = 0; i < 5; i++) Add(800, 700);
var advice = BidLeadStats.Suggest(currentLeadMs: 800, minSamples: 15);
Assert.False(advice.HasAdvice);
Assert.Equal(800, advice.SuggestedLeadMs);
}
[Fact]
public void Con_margine_ampio_propone_di_scendere()
{
Reset();
// Le puntate arrivano sempre ~1700 ms prima della scadenza, ping 60 ms:
// c'è spazio abbondante sopra il margine di sicurezza.
for (var i = 0; i < 20; i++) Add(1800, 1700 + i % 5, ping: 60);
var advice = BidLeadStats.Suggest(1800, minSamples: 15);
Assert.True(advice.HasAdvice);
Assert.True(advice.SuggestedLeadMs < 1800, "avrebbe dovuto proporre di scendere");
Assert.True(advice.SuggestedLeadMs >= BidLeadStats.SafeFloorMs,
"non deve scendere sotto il pavimento ricavato dai dossier");
}
[Fact]
public void Non_scende_mai_sotto_il_pavimento_anche_con_ping_minimo()
{
Reset();
// Ping bassissimo e puntate larghissime: la vecchia regola avrebbe proposto
// un anticipo di poche centinaia di ms, dove basta un picco di rete per
// arrivare a giochi chiusi (p99,9 del ping misurato: 444 ms).
for (var i = 0; i < 20; i++) Add(3000, 2900, ping: 10);
var advice = BidLeadStats.Suggest(3000, minSamples: 15);
Assert.True(advice.SuggestedLeadMs >= BidLeadStats.SafeFloorMs,
$"proposto {advice.SuggestedLeadMs}, sotto il pavimento di {BidLeadStats.SafeFloorMs} ms");
}
[Fact]
public void Senza_misure_avverte_comunque_se_lanticipo_e_fuori_fascia()
{
Reset();
// Troppo stretto: il rischio è la puntata che arriva tardi.
var stretto = BidLeadStats.Suggest(200, minSamples: 15);
Assert.True(stretto.HasAdvice);
Assert.Equal(BidLeadStats.SafeFloorMs, stretto.SuggestedLeadMs);
// Troppo largo: il costo sono le puntate spese in cicli che si sarebbero
// risolti da soli.
var largo = BidLeadStats.Suggest(4000, minSamples: 15);
Assert.True(largo.HasAdvice);
Assert.Equal(BidLeadStats.SafeCeilingMs, largo.SuggestedLeadMs);
// Dentro la fascia, senza misure non c'è nulla da dire.
var giusto = BidLeadStats.Suggest(1000, minSamples: 15);
Assert.False(giusto.HasAdvice);
}
[Fact]
public void Non_scende_mai_sotto_il_margine_di_rete()
{
Reset();
// Ping alto: il margine di sicurezza è grande e va rispettato.
for (var i = 0; i < 20; i++) Add(2000, 1900, ping: 400);
var advice = BidLeadStats.Suggest(2000, minSamples: 15);
if (advice.HasAdvice)
{
// safety = ping (400) + 150 = 550
Assert.True(advice.SuggestedLeadMs >= 550,
$"proposto {advice.SuggestedLeadMs}, sotto il margine di sicurezza");
}
}
[Fact]
public void Con_puntate_tardive_propone_di_allargare()
{
Reset();
for (var i = 0; i < 18; i++) Add(300, 120, ping: 90);
for (var i = 0; i < 3; i++) Add(300, -20, ping: 90, ok: false, late: true);
var advice = BidLeadStats.Suggest(300, minSamples: 15);
Assert.True(advice.HasAdvice);
Assert.True(advice.SuggestedLeadMs > 300, "con puntate tardive deve allargare, non stringere");
}
[Fact]
public void Se_l_anticipo_e_gia_stretto_non_propone_nulla()
{
Reset();
// Anticipo 250 ms, ping 60: margine 210, quasi nessuno spazio.
for (var i = 0; i < 20; i++) Add(250, 240, ping: 60);
var advice = BidLeadStats.Suggest(250, minSamples: 15);
Assert.False(advice.HasAdvice);
}
[Fact]
public void Il_riepilogo_conta_riuscite_e_tardive()
{
Reset();
for (var i = 0; i < 10; i++) Add(800, 700);
Add(800, -5, ok: false, late: true);
var s = BidLeadStats.Summarize();
Assert.Equal(11, s.Count);
Assert.Equal(10, s.Successes);
Assert.Equal(1, s.TooLate);
// La media considera solo le puntate accettate: una rifiutata non è mai arrivata.
Assert.Equal(700, s.AverageActualMs, 1);
}
}
/// <summary>
/// La scheda dettagliata è la materia prima per migliorare le strategie: i valori
/// derivati devono essere corretti, o le analisi partiranno da numeri sbagliati.
/// </summary>
public class AuctionDetailRecordTests
{
[Fact]
public void La_quota_del_puntatore_principale_e_una_percentuale()
{
var bids = new Dictionary<string, int> { ["tizio"] = 6, ["caio"] = 3, ["sempronio"] = 1 };
Assert.Equal(60.0, AuctionDetailRecord.ComputeTopShare(bids), 1);
}
[Fact]
public void Senza_puntatori_la_quota_e_zero()
{
Assert.Equal(0, AuctionDetailRecord.ComputeTopShare(new Dictionary<string, int>()));
}
[Fact]
public void Il_costo_reale_somma_prezzo_puntate_e_spedizione()
{
var r = new AuctionDetailRecord { FinalPrice = 12.50, MyBids = 40, ShippingCost = 5.90 };
// 12,50 + 40 x 0,20 + 5,90 = 26,40
Assert.Equal(26.40, r.TotalCostIfWon(0.20), 2);
}
[Fact]
public void Il_rapporto_col_valore_prodotto_manca_se_il_valore_e_ignoto()
{
Assert.Null(new AuctionDetailRecord { FinalPrice = 10 }.FinalPriceRatio);
Assert.Equal(0.1, new AuctionDetailRecord { FinalPrice = 10, BuyNowPrice = 100 }.FinalPriceRatio!.Value, 3);
}
}
/// <summary>La serie prezzi si raccoglie solo mentre l'asta vive: va registrata bene.</summary>
public class PriceSeriesTests
{
[Fact]
public void Registra_solo_i_cambi_di_prezzo()
{
var a = new AuctionInfo();
a.TrackPrice(1.00);
a.TrackPrice(1.00); // identico: ignorato
a.TrackPrice(1.02);
Assert.Equal(2, a.SnapshotPriceSeries().Count);
}
[Fact]
public void Ignora_i_prezzi_non_validi()
{
var a = new AuctionInfo();
a.TrackPrice(0);
a.TrackPrice(-1);
Assert.Empty(a.SnapshotPriceSeries());
}
[Fact]
public void Non_cresce_oltre_il_tetto()
{
var a = new AuctionInfo();
for (var i = 1; i <= 1500; i++) a.TrackPrice(i * 0.01);
// Il diradamento tiene la serie sotto controllo senza perderne la forma.
Assert.True(a.SnapshotPriceSeries().Count <= 600);
}
}
+92
View File
@@ -0,0 +1,92 @@
using System;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Fascia oraria sospesa.
///
/// <para>La proprieta' da difendere e' che la fascia <b>non fermi l'asta</b>: salta la
/// puntata e basta, perche' le aste lunghe attraversano la fascia e fermarle davvero
/// significherebbe perderle. Qui si prova il calcolo della fascia; che lo stato resti
/// Attiva e' garantito da come la regola e' collegata, non da questo file.</para>
///
/// <para>I numeri che motivano il predefinito 0-10: su 6060 aste concluse la chiusura
/// mediana e' il 4,6% del valore alle 12 e il 5,3% fra le 10 e le 13, contro l'8,6% a
/// mezzanotte e il 9,7% alle 9.</para>
/// </summary>
public class BiddingHoursTests
{
[Theory]
[InlineData(0, true)] // mezzanotte: la peggiore, 8,6%
[InlineData(3, true)]
[InlineData(9, true)] // ultima ora dentro: 9,7%
[InlineData(10, false)] // si riparte: 4,9%
[InlineData(12, false)] // la migliore: 4,6%
[InlineData(23, false)]
public void La_fascia_predefinita_va_da_mezzanotte_alle_dieci(int ora, bool sospesa)
{
Assert.Equal(sospesa,
BiddingHours.IsQuiet(ora, BiddingHours.DefaultStart, BiddingHours.DefaultEnd));
}
[Fact]
public void Inizio_incluso_e_fine_esclusa()
{
Assert.True(BiddingHours.IsQuiet(1, 1, 5));
Assert.True(BiddingHours.IsQuiet(4, 1, 5));
Assert.False(BiddingHours.IsQuiet(5, 1, 5));
}
[Theory]
[InlineData(22, true)]
[InlineData(23, true)]
[InlineData(0, true)]
[InlineData(5, true)]
[InlineData(6, false)]
[InlineData(12, false)]
public void Una_fascia_a_cavallo_di_mezzanotte_funziona(int ora, bool sospesa)
{
Assert.Equal(sospesa, BiddingHours.IsQuiet(ora, 22, 6));
}
[Fact]
public void Inizio_uguale_a_fine_significa_filtro_spento()
{
// L'alternativa sarebbe non puntare mai, che non e' quello che intende nessuno
// scrivendo due volte la stessa ora.
for (var ora = 0; ora < 24; ora++)
Assert.False(BiddingHours.IsQuiet(ora, 3, 3));
}
[Theory]
[InlineData(24, 0)]
[InlineData(25, 1)]
[InlineData(-1, 23)]
public void Le_ore_fuori_scala_si_riportano_dentro(int fuori, int atteso)
{
// Un valore scritto a mano nelle impostazioni non deve produrre una fascia che
// non sospende mai o che sospende sempre.
Assert.Equal(BiddingHours.IsQuiet(atteso, 0, 10), BiddingHours.IsQuiet(fuori, 0, 10));
}
[Fact]
public void La_spiegazione_dice_la_fascia_e_che_riprende_da_sola()
{
var testo = BiddingHours.Spiegazione(0, 10);
Assert.Contains("00:00-10:00", testo);
Assert.Contains("resta attiva", testo);
}
[Fact]
public void La_versione_con_data_usa_solo_l_ora()
{
var dentro = new DateTime(2026, 8, 7, 3, 45, 0);
var fuori = new DateTime(2026, 8, 7, 15, 45, 0);
Assert.True(BiddingHours.IsQuiet(dentro, 0, 10));
Assert.False(BiddingHours.IsQuiet(fuori, 0, 10));
}
}
@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoBidder.Models;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Archivio delle aste concluse: copia in memoria e salvataggio a blocchi.
///
/// <para>Sono le due modifiche che hanno tolto dal percorso caldo la rilettura dell'intero
/// file — ~72 ms su 5876 record, pagati sul thread dell'interfaccia a ogni asta che finisce.
/// La proprieta' da difendere e' che andare piu' veloce non cambi <b>cosa</b> viene salvato:
/// AppendMany deve dare lo stesso risultato di tanti Append, e la copia in memoria non deve
/// mai servire un contenuto che sul disco non c'e' piu'.</para>
/// </summary>
[Collection("Database")]
public class CompletedAuctionsStoreTests
{
private static CompletedAuctionRecord R(string id, double price = 1.00, string? nome = null) =>
new()
{
AuctionId = id,
Name = nome ?? $"Asta {id}",
ProductKey = "prova",
FinalPrice = price,
Winner = "tizio",
EndedAt = DateTime.Now
};
private static void Svuota() => CompletedAuctionsStore.ReplaceAll(new List<CompletedAuctionRecord>());
[Fact]
public void Append_sostituisce_quella_con_lo_stesso_id()
{
Svuota();
CompletedAuctionsStore.Append(R("100", price: 1.00));
CompletedAuctionsStore.Append(R("100", price: 2.50));
var tutte = CompletedAuctionsStore.LoadAll();
Assert.Single(tutte);
Assert.Equal(2.50, tutte[0].FinalPrice);
}
[Fact]
public void AppendMany_da_lo_stesso_risultato_di_tanti_Append()
{
var lotto = new[] { R("a", 1), R("b", 2), R("c", 3) };
Svuota();
foreach (var r in lotto) CompletedAuctionsStore.Append(r);
var unoAllaVolta = CompletedAuctionsStore.LoadAll()
.OrderBy(r => r.AuctionId).Select(r => (r.AuctionId, r.FinalPrice)).ToList();
Svuota();
CompletedAuctionsStore.AppendMany(lotto);
var inBlocco = CompletedAuctionsStore.LoadAll()
.OrderBy(r => r.AuctionId).Select(r => (r.AuctionId, r.FinalPrice)).ToList();
Assert.Equal(unoAllaVolta, inBlocco);
}
[Fact]
public void AppendMany_sostituisce_e_aggiunge_nello_stesso_giro()
{
Svuota();
CompletedAuctionsStore.AppendMany(new[] { R("x", 1), R("y", 2) });
CompletedAuctionsStore.AppendMany(new[] { R("y", 9), R("z", 3) });
var tutte = CompletedAuctionsStore.LoadAll().ToDictionary(r => r.AuctionId, r => r.FinalPrice);
Assert.Equal(3, tutte.Count);
Assert.Equal(9, tutte["y"]);
Assert.Equal(1, tutte["x"]);
Assert.Equal(3, tutte["z"]);
}
[Fact]
public void AppendMany_senza_niente_da_fare_non_esplode()
{
Svuota();
CompletedAuctionsStore.AppendMany(Array.Empty<CompletedAuctionRecord>());
Assert.Empty(CompletedAuctionsStore.LoadAll());
}
[Fact]
public void I_record_senza_id_restano_tutti()
{
// Un id vuoto non identifica niente: due record cosi' non sono lo stesso record,
// e accorparli farebbe sparire un'asta davvero conclusa.
Svuota();
CompletedAuctionsStore.AppendMany(new[] { R(""), R("") });
Assert.Equal(2, CompletedAuctionsStore.LoadAll().Count);
}
[Fact]
public void La_copia_in_memoria_si_accorge_delle_scritture_fatte_da_altri()
{
Svuota();
CompletedAuctionsStore.Append(R("uno"));
Assert.Single(CompletedAuctionsStore.LoadAll());
// Una scrittura che non passa da questo archivio (il registratore, una
// riparazione): la copia non deve continuare a servire il contenuto vecchio.
var db = AutoBidder.Data.AuctionDatabase.Instance;
db.ExecuteNow("DELETE FROM auctions WHERE auction_id = 'uno'");
var now = AutoBidder.Data.AuctionDatabase.Now();
db.ExecuteNow("INSERT INTO auctions(auction_id, name, added_at, first_seen_at, opened_at, end_time, outcome, status) VALUES('due', 'Asta due', ?1, ?1, ?1, ?1, 'Persa', 'closed')", now);
db.ExecuteNow("INSERT INTO auctions(auction_id, name, added_at, first_seen_at, opened_at, end_time, outcome, status) VALUES('tre', 'Asta tre', ?1, ?1, ?1, ?1, 'Persa', 'closed')", now);
var dopo = CompletedAuctionsStore.LoadAll();
Assert.Equal(2, dopo.Count);
Assert.DoesNotContain(dopo, r => r.AuctionId == "uno");
}
[Fact]
public void Dopo_ReplaceAll_si_legge_quello_nuovo()
{
Svuota();
CompletedAuctionsStore.AppendMany(new[] { R("vecchia") });
CompletedAuctionsStore.ReplaceAll(new List<CompletedAuctionRecord> { R("nuova") });
var tutte = CompletedAuctionsStore.LoadAll();
Assert.Single(tutte);
Assert.Equal("nuova", tutte[0].AuctionId);
}
}
+1 -140
View File
@@ -13,7 +13,7 @@ namespace AutoBidder.Tests;
/// un'asta sbagliata non si nota — si notano invece le conclusioni sbagliate che se ne
/// traggono un mese dopo, quando non c'è più modo di capire da dove venivano.
/// </summary>
public class AuctionExportFilterTests
public class AuctionDetailRecordTests
{
private static AuctionDetailRecord Record(
string id = "1",
@@ -67,145 +67,6 @@ public class AuctionExportFilterTests
Assert.Equal(0.1, withValue.FinalPriceRatio!.Value, 3);
}
[Fact]
public void I_filtri_predefiniti_lasciano_passare_tutto()
{
var filter = new AuctionExportFilter();
Assert.True(filter.IncludeWon);
Assert.True(filter.IncludeLost);
Assert.True(filter.IncludeClosed);
Assert.False(filter.OnlyComplete);
Assert.Equal(0, filter.MaxAuctions);
}
[Fact]
public void Il_percorso_predefinito_sta_nella_cartella_esportazioni()
{
var path = AuctionExporter.DefaultPath();
Assert.StartsWith(AppPaths.ExportFolder, path);
Assert.EndsWith(".json", path);
}
[Fact]
public void Senza_storico_lesportazione_lo_dice_invece_di_scrivere_un_file_vuoto()
{
var result = AuctionExporter.Export(new AuctionExportFilter
{
// Un periodo in cui non può esserci nulla.
From = new DateTime(1990, 1, 1),
To = new DateTime(1990, 1, 2)
});
Assert.False(result.Success);
Assert.Equal(0, result.AuctionCount);
Assert.Contains("nessuna asta", result.Message);
}
}
/// <summary>
/// Il registro passa da una coda: chi scrive non aspetta il disco. La proprietà da
/// difendere è che le righe arrivino comunque nel file, e nell'ordine giusto — un registro
/// che perde o mescola le righe è peggio di nessun registro, perché lo si legge credendogli.
/// </summary>
public class FileLogWriterTests
{
private static string TempFile() =>
Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", $"log-{Guid.NewGuid():N}.log");
[Fact]
public void Quello_che_si_scrive_finisce_nel_file()
{
var path = TempFile();
var writer = FileLogWriter.For(path);
writer.Write("prima");
writer.Write("seconda");
writer.Flush();
var lines = File.ReadAllLines(path);
Assert.Equal(new[] { "prima", "seconda" }, lines);
writer.Dispose();
}
[Fact]
public void Lo_stesso_percorso_da_sempre_lo_stesso_scrittore()
{
// Due scrittori sullo stesso file mescolerebbero le righe fra loro.
var path = TempFile();
Assert.Same(FileLogWriter.For(path), FileLogWriter.For(path));
FileLogWriter.For(path).Dispose();
}
[Fact]
public void Le_righe_si_accodano_a_quelle_gia_scritte()
{
var path = TempFile();
var first = FileLogWriter.For(path);
first.Write("sessione 1");
first.Flush();
first.Dispose();
var second = FileLogWriter.For(path);
second.Write("sessione 2");
second.Flush();
second.Dispose();
Assert.Equal(new[] { "sessione 1", "sessione 2" }, File.ReadAllLines(path));
}
[Fact]
public void Un_percorso_impossibile_non_solleva()
{
// Il registro non deve mai poter interrompere un'asta.
var writer = FileLogWriter.For(Path.Combine("Z:\\", "inesistente", "x.log"));
writer.Write("qualcosa");
writer.Flush();
writer.Dispose();
}
[Fact]
public void I_registri_scaduti_si_cancellano_e_quelli_recenti_no()
{
var folder = Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
var old = Path.Combine(folder, "app-2020-01-01.log");
var recent = Path.Combine(folder, "app-oggi.log");
File.WriteAllText(old, "vecchio");
File.WriteAllText(recent, "recente");
File.SetLastWriteTime(old, DateTime.Now.AddDays(-120));
var removed = FileLogWriter.PurgeOlderThan(folder, "app-*.log", days: 90);
Assert.Equal(1, removed);
Assert.False(File.Exists(old));
Assert.True(File.Exists(recent));
}
[Fact]
public void Con_zero_giorni_non_si_cancella_nulla()
{
// 0 = "tienili per sempre": interpretarlo come "cancellali tutti" distruggerebbe
// esattamente i dati che si voleva conservare.
var folder = Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
var file = Path.Combine(folder, "app-vecchio.log");
File.WriteAllText(file, "x");
File.SetLastWriteTime(file, DateTime.Now.AddYears(-3));
Assert.Equal(0, FileLogWriter.PurgeOlderThan(folder, "app-*.log", days: 0));
Assert.True(File.Exists(file));
}
}
/// <summary>
@@ -0,0 +1,111 @@
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Rimozione automatica delle aste concluse dall'elenco del monitor.
///
/// <para>La proprieta' da difendere e' asimmetrica, e va nel verso della prudenza: togliere
/// un'asta solo osservata non costa niente, perche' lo storico la racconta meglio
/// dell'elenco; togliere un'asta su cui si e' speso significa perdere l'occasione di
/// controllare dove sono finiti dei soldi veri. Nel dubbio si trattiene.</para>
/// </summary>
public class FinishedAuctionCleanupTests
{
private static FinishedAuctionCleanup.Verdict Decidi(
bool conclusa = true, bool vinta = false, int puntate = 0, bool fineVista = true,
FinishedAuctionCleanup.Options? opzioni = null) =>
FinishedAuctionCleanup.Decide(conclusa, vinta, puntate, fineVista,
opzioni ?? new FinishedAuctionCleanup.Options());
[Fact]
public void Un_asta_persa_senza_mie_puntate_si_toglie()
{
Assert.True(Decidi().Remove);
}
[Fact]
public void Un_asta_ancora_in_corso_non_si_tocca()
{
var v = Decidi(conclusa: false);
Assert.False(v.Remove);
Assert.Contains("non conclusa", v.Reason);
}
[Fact]
public void Le_aste_su_cui_ho_puntato_restano()
{
// Sono quelle costate qualcosa, ed e' l'unico elenco in cui si vedono accanto ai
// loro dati: farle sparire da sole significa perdere il controllo a mano.
var v = Decidi(puntate: 12);
Assert.False(v.Remove);
Assert.Contains("12 puntate", v.Reason);
}
[Fact]
public void Le_vinte_restano_perche_vanno_confermate_su_Bidoo()
{
var v = Decidi(vinta: true);
Assert.False(v.Remove);
Assert.Contains("conferma", v.Reason);
}
[Fact]
public void Se_la_fine_non_e_stata_vista_l_asta_resta()
{
var v = Decidi(fineVista: false);
Assert.False(v.Remove);
Assert.Contains("fine non", v.Reason);
}
[Fact]
public void Una_vinta_su_cui_ho_puntato_resta_per_il_motivo_piu_importante()
{
// Due ragioni per trattenerla: quella che finisce nel registro deve essere la
// vittoria, non le puntate, perche' e' quella che richiede un'azione.
var v = Decidi(vinta: true, puntate: 30);
Assert.False(v.Remove);
Assert.Contains("vinta", v.Reason.ToLowerInvariant());
}
[Fact]
public void Spegnendo_la_rimozione_non_si_toglie_piu_niente()
{
var v = Decidi(opzioni: new FinishedAuctionCleanup.Options { Enabled = false });
Assert.False(v.Remove);
Assert.Contains("spenta", v.Reason);
}
[Fact]
public void Ogni_eccezione_si_puo_spegnere_da_sola()
{
var solo = new FinishedAuctionCleanup.Options
{
KeepWithMyBids = false,
KeepWon = false,
KeepUnclear = false
};
Assert.True(Decidi(puntate: 40, opzioni: solo).Remove);
Assert.True(Decidi(vinta: true, opzioni: solo).Remove);
Assert.True(Decidi(fineVista: false, opzioni: solo).Remove);
}
[Fact]
public void I_predefiniti_tolgono_le_osservate_e_trattengono_il_resto()
{
var o = new FinishedAuctionCleanup.Options();
Assert.True(o.Enabled);
Assert.True(o.KeepWithMyBids);
Assert.True(o.KeepWon);
Assert.True(o.KeepUnclear);
}
}
+66 -9
View File
@@ -464,7 +464,7 @@ public class FreeBidsResponseValidatorTests
[Fact]
public void Un_errore_http_non_e_un_riscatto()
{
var verdict = FreeBidsResponseValidator.Validate(Config, 500, "");
var verdict = FreeBidsResponseValidator.GiudicaRisposta(Config, 500, "");
Assert.False(verdict.Accepted);
Assert.Contains("500", verdict.Reason);
@@ -473,7 +473,7 @@ public class FreeBidsResponseValidatorTests
[Fact]
public void Il_json_del_sito_ha_lultima_parola()
{
var verdict = FreeBidsResponseValidator.Validate(Config, 200,
var verdict = FreeBidsResponseValidator.GiudicaRisposta(Config, 200,
"""{"success": true, "bids": 5, "message": "Hai riscattato 5 puntate!"}""");
Assert.True(verdict.Accepted);
@@ -484,7 +484,7 @@ public class FreeBidsResponseValidatorTests
[Fact]
public void Un_rifiuto_con_200_resta_un_rifiuto()
{
var verdict = FreeBidsResponseValidator.Validate(Config, 200,
var verdict = FreeBidsResponseValidator.GiudicaRisposta(Config, 200,
"""{"success": false, "message": "Premio già riscattato"}""");
Assert.False(verdict.Accepted);
@@ -496,7 +496,7 @@ public class FreeBidsResponseValidatorTests
{
// Chiedere all'utente di indovinare il percorso completo sarebbe un modo per
// fargli sbagliare la configurazione.
var verdict = FreeBidsResponseValidator.Validate(Config, 200,
var verdict = FreeBidsResponseValidator.GiudicaRisposta(Config, 200,
"""{"status": 200, "data": {"success": "true", "bids": "3"}}""");
Assert.True(verdict.Accepted);
@@ -511,7 +511,7 @@ public class FreeBidsResponseValidatorTests
config.ResponseValidation.SuccessValue = "ok";
config.ResponseValidation.BidsCountKey = "bids_added";
var verdict = FreeBidsResponseValidator.Validate(config, 200,
var verdict = FreeBidsResponseValidator.GiudicaRisposta(config, 200,
"""{"esito": "ok", "bids_added": 12}""");
Assert.True(verdict.Accepted);
@@ -523,7 +523,7 @@ public class FreeBidsResponseValidatorTests
{
// È il caso peggiore: sessione scaduta, HTTP 200, e ogni giro conterebbe premi
// che non sono mai stati presi.
var verdict = FreeBidsResponseValidator.Validate(Config, 200,
var verdict = FreeBidsResponseValidator.GiudicaRisposta(Config, 200,
"<html><body><form action=\"/login.php\"><input name=\"password\"></form></body></html>");
Assert.False(verdict.Accepted);
@@ -533,7 +533,7 @@ public class FreeBidsResponseValidatorTests
[Fact]
public void Una_pagina_intera_vale_come_accettata_e_le_puntate_si_leggono_dal_testo()
{
var verdict = FreeBidsResponseValidator.Validate(Config, 200,
var verdict = FreeBidsResponseValidator.GiudicaRisposta(Config, 200,
"<html><body><h1>Complimenti! Hai ricevuto 3 puntate</h1></body></html>");
Assert.True(verdict.Accepted);
@@ -543,7 +543,7 @@ public class FreeBidsResponseValidatorTests
[Fact]
public void In_un_messaggio_breve_le_parole_di_rifiuto_contano()
{
var verdict = FreeBidsResponseValidator.Validate(Config, 200, "Premio già riscattato oggi");
var verdict = FreeBidsResponseValidator.GiudicaRisposta(Config, 200, "Premio già riscattato oggi");
Assert.False(verdict.Accepted);
Assert.Contains("rifiutato", verdict.Reason);
@@ -552,7 +552,7 @@ public class FreeBidsResponseValidatorTests
[Fact]
public void Una_risposta_vuota_si_accetta_e_decide_il_saldo()
{
var verdict = FreeBidsResponseValidator.Validate(Config, 200, "");
var verdict = FreeBidsResponseValidator.GiudicaRisposta(Config, 200, "");
Assert.True(verdict.Accepted);
Assert.Null(verdict.BidsDeclared);
@@ -794,3 +794,60 @@ public class FreeBidsStatsTests
Assert.Equal(7, FreeBidsStats.Read().TotalBids);
}
}
/// <summary>
/// La politica nuova: aprire il collegamento <b>e'</b> il riscatto, e non si giudica cosa
/// risponde il sito.
///
/// <para>Nasce da tre giorni di registri in cui ogni singolo riscatto risultava fallito con
/// "sessione scaduta: risposta la pagina di accesso" — 6 trovati, 0 riscossi, 6 non
/// riusciti, giro dopo giro — mentre le puntate arrivavano lo stesso. Un premio si riscuote
/// una volta sola: fra i due errori possibili, insistere su un premio gia' preso non costa
/// niente, mentre dichiarare falliti dei riscatti riusciti riempie il registro di allarmi
/// che non servono a nessuno.</para>
/// </summary>
public class FreeBidsPoliticaApertaTests
{
private static FreeBidsSiteConfig Config() => new FreeBidsSiteConfig().Normalised();
[Fact]
public void La_pagina_di_accesso_non_fa_piu_fallire_il_riscatto()
{
// Esattamente il caso che nei registri falliva sempre.
var login = "<html><body><form action=\"/login.php\">" +
new string('x', 500) + "<input name=\"password\"></form></body></html>";
var esito = FreeBidsResponseValidator.Validate(Config(), 200, login);
Assert.True(esito.Accepted);
Assert.Contains("aperto", esito.Reason);
}
[Fact]
public void Una_risposta_di_rifiuto_non_fa_piu_fallire_il_riscatto()
{
var esito = FreeBidsResponseValidator.Validate(Config(), 200, "{\"success\":false}");
Assert.True(esito.Accepted);
}
[Fact]
public void Un_errore_di_trasporto_resta_un_fallimento()
{
// Qui il collegamento non e' stato aperto affatto: dirlo non e' un giudizio sul
// contenuto, e riprovare ha senso.
var esito = FreeBidsResponseValidator.Validate(Config(), 500, "");
Assert.False(esito.Accepted);
Assert.Contains("500", esito.Reason);
}
[Fact]
public void Il_giudizio_vecchio_resta_disponibile()
{
// Non e' stato cancellato: serve ai test e a chi volesse riaccenderlo.
var esito = FreeBidsResponseValidator.GiudicaRisposta(Config(), 200, "{\"success\":false}");
Assert.False(esito.Accepted);
}
}
+59
View File
@@ -0,0 +1,59 @@
using System;
using AutoBidder.Ml;
using AutoBidder.Models;
using AutoBidder.Services;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// La copertura «Compralo Ora» con V lasciato al listino rende gratis ogni puntata: la
/// strategia la concede solo ai prodotti con il valore reale scritto dall'utente.
/// </summary>
[Collection("Database")]
public class HedgeRequiresValueTests
{
[Fact]
public void Con_V_uguale_al_listino_la_copertura_rende_gratis_ogni_puntata()
{
// È il caso degenere: P dell'uno per mille e valore atteso comunque positivo.
var s = new Theory.BidSituation(PWin: 0.001, Value: 11, CurrentPrice: 1.48, BidCost: 0.20,
TransactionFee: 1, Shipping: 0, ListPrice: 11, BidsSpentEuro: 0, HedgeEnabled: true);
Assert.Equal(0, Theory.LossBranchCost(s));
Assert.True(Theory.BidExpectedValue(s) > 0);
// Senza copertura la stessa puntata è in perdita: P deve superare c / (guadagno + c).
var senza = s with { HedgeEnabled = false };
Assert.Equal(0.20, Theory.LossBranchCost(senza));
Assert.True(Theory.BidExpectedValue(senza) < 0);
}
[Fact]
public void La_copertura_vale_solo_con_il_valore_reale_scritto()
{
var nome = $"Buono di prova {Guid.NewGuid():N}";
var catalogo = new CatalogAuction { AuctionId = "prova-copertura", Name = nome, BuyNowPrice = 11m, ProductId = 987654 };
Assert.True(WatchedProductsStore.Add(catalogo));
try
{
var asta = new AuctionInfo { Name = nome, BuyNowPrice = 11 };
Assert.False(BidStrategyService.HasExplicitValue(asta));
Assert.Equal(11, BidStrategyService.ValueOf(asta));
var scheda = WatchedProductsStore.FindByName(nome);
Assert.NotNull(scheda);
scheda!.MarketValue = 9.5;
WatchedProductsStore.Persist();
Assert.True(BidStrategyService.HasExplicitValue(asta));
Assert.Equal(9.5, BidStrategyService.ValueOf(asta));
}
finally
{
WatchedProductsStore.Remove(catalogo);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Data;
using AutoBidder.Ml;
using AutoBidder.Models;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// La chiusura di un'asta arrivava due volte all'apprendimento (fine, poi rimozione dal
/// monitor) e di nuovo al riavvio: la stessa asta va studiata una volta sola.
/// </summary>
[Collection("Database")]
public class LearningDoubleCloseTests
{
[Fact]
public void Una_asta_gia_appresa_non_viene_ristudiata()
{
var db = AuctionDatabase.Instance;
var id = "doppia-" + Guid.NewGuid().ToString("N");
var now = AuctionDatabase.Now();
db.ExecuteNow(
"INSERT INTO auctions(auction_id, product_key, name, added_at, first_seen_at, opened_at, status, outcome, learned_at) " +
"VALUES(?1, 'prova', 'Prova doppia', ?2, ?2, ?2, 'closed', 'Persa', ?2)", id, now);
var apprese = LearningService.AuctionsLearned;
var profilo = LearningService.ProfileAuctions;
var messaggi = new List<string>();
void Raccogli(string m) { lock (messaggi) messaggi.Add(m); }
LearningService.OnLog += Raccogli;
try
{
var record = new CompletedAuctionRecord { AuctionId = id, Name = "Prova doppia", ProductKey = "prova", EndedAt = DateTime.Now };
LearningService.OnAuctionClosed(record);
LearningService.OnAuctionClosed(record);
}
finally
{
LearningService.OnLog -= Raccogli;
}
Assert.Equal(apprese, LearningService.AuctionsLearned);
Assert.Equal(profilo, LearningService.ProfileAuctions);
// La prima chiamata vede learned_at e lo dice; la seconda si ferma prima ancora
// di guardare il database.
Assert.Equal(1, messaggi.Count(m => m.Contains("già appresa")));
}
}
+58
View File
@@ -0,0 +1,58 @@
using System;
using System.IO;
using AutoBidder.Ml;
using Xunit;
using Xunit.Abstractions;
namespace AutoBidder.Tests;
/// <summary>
/// Valuta il modello sui dossier veri. La logica sta in <see cref="LearningEvaluator"/>,
/// condivisa con la scheda dell'applicazione: qui si aggiunge solo l'attivazione da
/// variabili d'ambiente e la condizione minima.
///
/// <list type="bullet">
/// <item><c>AUTOBIDDER_ML_DB</c> — file del database SQLite;</item>
/// <item><c>AUTOBIDDER_ML_MAX</c> — quante aste al massimo (0 = tutte);</item>
/// <item><c>AUTOBIDDER_ML_OUT</c> — dove scrivere il rapporto;</item>
/// <item><c>AUTOBIDDER_ML_ME</c> — il nostro nome utente, per i dossier vecchi.</item>
/// </list>
/// </summary>
public class MlModelBacktest
{
private readonly ITestOutputHelper _output;
public MlModelBacktest(ITestOutputHelper output) => _output = output;
[Fact]
public void Valuta_il_modello_sulle_aste_reali()
{
var dbPath = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_DB");
if (string.IsNullOrWhiteSpace(dbPath) || !File.Exists(dbPath))
{
_output.WriteLine("AUTOBIDDER_ML_DB non impostata o inesistente: valutazione saltata.");
return;
}
var max = int.TryParse(Environment.GetEnvironmentVariable("AUTOBIDDER_ML_MAX"), out var m) ? m : 0;
var me = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_ME");
using var db = AutoBidder.Data.AuctionDatabase.Open(dbPath);
var report = LearningEvaluator.Run(db, max, me, _output.WriteLine);
_output.WriteLine(report.Text);
var outFile = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_OUT");
if (!string.IsNullOrWhiteSpace(outFile))
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outFile))!);
File.WriteAllText(outFile, report.Text);
}
if (!report.Enough) return;
// La condizione minima perche' il modello serva a qualcosa: nella cima della
// classifica le puntate finali devono essere piu' frequenti che a caso.
Assert.True(report.Lift5 > 1.0, $"il modello non separa: sollevamento al 5% = {report.Lift5:N2}");
}
}
+225
View File
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Ml;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// La rete neurale impara una funzione che un modello lineare non può (XOR), regredisce
/// una funzione continua, si salva e si ricarica identica.
/// </summary>
public class NeuralNetTests
{
[Fact]
public void Impara_lo_xor()
{
var net = new NeuralNet(new[] { 2, 8, 1 }, seed: 3) { LearningRate = 0.02 };
var data = new[]
{
(new[] { 0.0, 0.0 }, false), (new[] { 0.0, 1.0 }, true),
(new[] { 1.0, 0.0 }, true), (new[] { 1.0, 1.0 }, false)
};
var rng = new Random(1);
for (var i = 0; i < 6000; i++)
{
var (x, y) = data[rng.Next(4)];
net.Update(x, y);
}
Assert.True(net.Predict(new[] { 0.0, 0.0 }) < 0.2);
Assert.True(net.Predict(new[] { 0.0, 1.0 }) > 0.8);
Assert.True(net.Predict(new[] { 1.0, 0.0 }) > 0.8);
Assert.True(net.Predict(new[] { 1.0, 1.0 }) < 0.2);
}
[Fact]
public void Regredisce_una_funzione_continua()
{
var net = new NeuralNet(new[] { 1, 16, 1 }, seed: 5) { LearningRate = 0.005 };
var rng = new Random(2);
for (var i = 0; i < 8000; i++)
{
var x = rng.NextDouble() * 2 - 1;
net.UpdateRegression(new[] { x }, new[] { x * x });
}
var err = 0.0;
for (var x = -1.0; x <= 1.0; x += 0.1) err += Math.Abs(net.Forward(new[] { x })[0] - x * x);
Assert.True(err / 21 < 0.05, $"errore medio {err / 21:F3}");
}
[Fact]
public void Si_salva_e_si_ricarica_identica()
{
var net = new NeuralNet(new[] { 3, 4, 1 }, seed: 9);
for (var i = 0; i < 100; i++) net.Update(new[] { 0.1 * i, 0.5, 1.0 }, i % 3 == 0);
var back = NeuralNet.FromJson(net.ToJson(), new[] { 3, 4, 1 });
Assert.Equal(net.Updates, back.Updates);
Assert.Equal(net.Predict(new[] { 0.3, 0.5, 1.0 }), back.Predict(new[] { 0.3, 0.5, 1.0 }), 12);
}
[Fact]
public void Una_forma_diversa_riparte_da_capo_senza_eccezioni()
{
var net = new NeuralNet(new[] { 3, 4, 1 });
var back = NeuralNet.FromJson(net.ToJson(), new[] { 5, 4, 1 });
Assert.Equal(0, back.Updates);
Assert.Equal(5, back.InputSize);
}
[Fact]
public void Aggiornare_una_sola_uscita_non_tocca_le_altre_in_modo_diretto()
{
var net = new NeuralNet(new[] { 2, 4, 2 }, seed: 11) { LearningRate = 0.01 };
var x = new[] { 0.5, -0.5 };
for (var i = 0; i < 500; i++) net.UpdateOutput(x, 1, 3.0);
var q = net.Forward(x);
Assert.True(Math.Abs(q[1] - 3.0) < 0.3, $"q1 = {q[1]:F2}");
}
}
/// <summary>
/// Il bandit: parte dal prior, converge all'evidenza, esplora dove ha visto poco, e
/// decide con la regola del valore atteso sul p estratto.
/// </summary>
public class ThompsonBanditTests
{
private static BidContext Ctx(double gap = 5, double ratio = 0.05, int hour = 12, int bidders = 2) =>
new(gap, 0.3, bidders, ratio, hour, DayOfWeek.Monday, 10, false, 1, 50, false, "p");
[Fact]
public void Senza_osservazioni_la_media_e_il_prior()
{
var b = new ThompsonBandit { BaseRate = 0.05 };
Assert.Equal(0.05, b.Mean("qualunque"), 6);
Assert.Equal(0, b.Evidence("qualunque"), 6);
}
[Fact]
public void Con_le_osservazioni_la_media_si_sposta_verso_la_frequenza()
{
var b = new ThompsonBandit { BaseRate = 0.05, PriorStrength = 4 };
var key = ThompsonBandit.KeyOf(Ctx());
for (var i = 0; i < 100; i++) b.Observe(key, i % 2 == 0);
Assert.True(Math.Abs(b.Mean(key) - 0.5) < 0.05);
Assert.Equal(100, b.Evidence(key), 6);
}
[Fact]
public void Lestrazione_varia_dove_si_e_visto_poco_e_si_stringe_con_i_dati()
{
var b = new ThompsonBandit { BaseRate = 0.3, PriorStrength = 2 };
var rng = new Random(4);
var poco = Enumerable.Range(0, 400).Select(_ => b.Sample("poco", rng)).ToList();
for (var i = 0; i < 2000; i++) b.Observe("tanto", i % 10 < 3);
var tanto = Enumerable.Range(0, 400).Select(_ => b.Sample("tanto", rng)).ToList();
Assert.True(Std(poco) > 3 * Std(tanto), $"poco {Std(poco):F3} vs tanto {Std(tanto):F3}");
Assert.True(Math.Abs(tanto.Average() - 0.3) < 0.03);
}
[Fact]
public void Decide_con_il_valore_atteso_sul_p_estratto()
{
var b = new ThompsonBandit { BaseRate = 0.5, PriorStrength = 1000 }; // praticamente fisso a 0,5
var rng = new Random(1);
var conviene = new Theory.BidSituation(0, 11, 0.50, 0.20, 1.0, 0, null, 0, false);
var (bid, p) = b.Decide("k", conviene, rng);
Assert.True(bid);
Assert.True(Math.Abs(p - 0.5) < 0.1);
var nonConviene = conviene with { Value = 1.0, CurrentPrice = 0.95 };
Assert.False(b.Decide("k", nonConviene, rng).Bid);
}
[Fact]
public void Si_salva_e_si_ricarica()
{
var b = new ThompsonBandit { BaseRate = 0.1 };
for (var i = 0; i < 20; i++) b.Observe("k", i % 4 == 0);
var back = ThompsonBandit.FromJson(b.ToJson());
Assert.Equal(b.Mean("k"), back.Mean("k"), 9);
Assert.Equal(20, back.Observations);
}
private static double Std(List<double> xs)
{
var m = xs.Average();
return Math.Sqrt(xs.Average(x => (x - m) * (x - m)));
}
}
/// <summary>
/// Il simulatore produce aste con la forma giusta — finiscono, il prezzo in centesimi è
/// il numero di puntate, chi non punta mai non vince mai, chi punta sempre spende — e
/// l'agente Q impara a non buttare via soldi.
/// </summary>
public class SimulatorTests
{
private static PennyAuctionEnv.Calibration Cal() => new() { Value = 11, BidCost = 0.20, MeanBidders = 3, MedianFinalPriceRatio = 0.05 };
[Fact]
public void Le_aste_finiscono_e_il_prezzo_conta_le_puntate()
{
var env = new PennyAuctionEnv(Cal(), seed: 3);
var obs = env.Reset();
var steps = 0;
PennyAuctionEnv.StepResult r;
do { r = env.Step(null); steps++; } while (!r.Done && steps < 5000);
Assert.True(r.Done);
Assert.False(r.Won);
Assert.Equal(r.Next.TotalBids, (int)Math.Round(r.Next.Price * 100));
}
[Fact]
public void Chi_non_punta_mai_non_vince_mai_e_non_spende()
{
var mai = QLearningAgent.Evaluate("mai", Cal(), (in PennyAuctionEnv.Observation _) => null, 200);
Assert.Equal(0, mai.Wins);
Assert.Equal(0, mai.Spent);
}
[Fact]
public void Chi_punta_sempre_vince_qualcosa_ma_spende_molto()
{
var sempre = QLearningAgent.Evaluate("sempre", Cal(), (in PennyAuctionEnv.Observation o) => o.ImLeading ? null : 0.5, 200);
Assert.True(sempre.Wins > 0);
Assert.True(sempre.Bids > sempre.Wins);
Assert.True(sempre.Spent > 0);
}
[Fact]
public void Una_regola_prudente_spende_meno_di_chi_punta_sempre()
{
// Punta solo quando gli avversari attivi sono pochi e il prezzo è basso.
var prudente = QLearningAgent.Evaluate("prudente", Cal(),
(in PennyAuctionEnv.Observation o) => !o.ImLeading && o.ActiveOpponents <= 1 && o.PriceRatio < 0.3 ? 0.5 : null, 200);
var sempre = QLearningAgent.Evaluate("sempre", Cal(), (in PennyAuctionEnv.Observation o) => o.ImLeading ? null : 0.5, 200);
Assert.True(prudente.Spent < sempre.Spent);
}
[Fact]
public void Lagente_q_impara_a_fare_meglio_del_caso()
{
var cal = Cal();
var agent = new QLearningAgent(seed: 5);
for (var i = 0; i < 600; i++) agent.TrainEpisode(new PennyAuctionEnv(cal, 100 + i), 0.5);
var q = QLearningAgent.Evaluate("q", cal, (in PennyAuctionEnv.Observation o) => agent.Act(o) == 1 ? 0.5 : null, 300);
var caso = QLearningAgent.Evaluate("caso", cal, (in PennyAuctionEnv.Observation o) =>
new Random(o.TotalBids * 31 + o.MyBids).NextDouble() < 0.5 ? 0.5 : null, 300);
Assert.True(q.Net > caso.Net, $"q {q.Net:F2} vs caso {caso.Net:F2}");
Assert.True(agent.Episodes == 600);
}
}
+318
View File
@@ -0,0 +1,318 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Ml;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il modello impara un esempio alla volta e deve restare <b>calibrato</b>: la probabilità
/// che produce viene moltiplicata per euro, quindi la scala conta quanto l'ordine.
/// </summary>
public class OnlineLogitTests
{
[Fact]
public void Impara_una_regola_semplice()
{
var m = new OnlineLogit(2);
var rnd = new Random(7);
// Positivo se e solo se la prima variabile e' accesa. Col passo di serie (0,01,
// scelto sui dossier veri) servono piu' esempi che con uno grande: qui conta che
// impari, non quanto in fretta.
for (var i = 0; i < 20000; i++)
{
var a = rnd.Next(2) == 1;
var x = new[] { a ? 1.0 : 0.0, rnd.Next(2) };
m.Update(x, a);
}
Assert.True(m.Predict(new[] { 1.0, 0.0 }) > 0.9);
Assert.True(m.Predict(new[] { 0.0, 1.0 }) < 0.1);
}
[Fact]
public void Resta_calibrato_su_una_classe_rara()
{
// Positivi al 2%: la probabilita' media prevista deve stare vicino al 2%,
// non gonfiata verso il 50%. E' quello che rende usabile il valore atteso.
var m = new OnlineLogit(1);
var rnd = new Random(3);
for (var i = 0; i < 40000; i++) m.Update(new[] { 1.0 }, rnd.NextDouble() < 0.02);
var p = m.Predict(new[] { 1.0 });
Assert.InRange(p, 0.01, 0.04);
}
[Fact]
public void Si_salva_e_si_rilegge_uguale()
{
var m = new OnlineLogit(3);
m.Update(new[] { 1.0, 0.0, 1.0 }, true);
m.Update(new[] { 0.0, 1.0, 0.0 }, false);
var back = OnlineLogit.FromJson(m.ToJson(), 3);
Assert.Equal(m.Weights, back.Weights);
Assert.Equal(m.Updates, back.Updates);
Assert.Equal(m.CalibrationFactor, back.CalibrationFactor, 9);
}
[Fact]
public void I_parametri_vengono_dal_codice_non_dal_file()
{
// Il passo e' una scelta tarata sui dossier: cambiarla nel codice deve valere dal
// riavvio successivo, non restare inchiodata al valore di quando il file nacque.
var m = new OnlineLogit(2) { LearningRate = 0.5, L2 = 0.1, UseAveraging = true };
m.Update(new[] { 1.0, 0.0 }, true);
var back = OnlineLogit.FromJson(m.ToJson(), 2);
var fresco = new OnlineLogit(2);
Assert.Equal(fresco.LearningRate, back.LearningRate);
Assert.Equal(fresco.L2, back.L2);
Assert.Equal(fresco.UseAveraging, back.UseAveraging);
}
[Fact]
public void Con_un_numero_diverso_di_variabili_si_riparte_da_zero()
{
// Pesi riferiti a variabili diverse non sono un modello, sono rumore.
var m = new OnlineLogit(3);
m.Update(new[] { 1.0, 1.0, 1.0 }, true);
var back = OnlineLogit.FromJson(m.ToJson(), 5);
Assert.Equal(5, back.FeatureCount);
Assert.Equal(0, back.Updates);
}
}
/// <summary>
/// Le variabili sono a fasce e ogni gruppo accende esattamente una casella: un vettore
/// mal formato farebbe imparare al modello relazioni che non esistono.
/// </summary>
public class BidFeaturesTests
{
private static BidContext Ctx(
double gap = 7, double auto = 0.8, int bidders = 3, double? ratio = 0.05,
int hour = 12, DayOfWeek day = DayOfWeek.Tuesday, int open = 11, bool isAuto = false,
int sinceManual = 2, int depth = 120, bool same = false, string key = "50 bidoo shop") =>
new(gap, auto, bidders, ratio, hour, day, open, isAuto, sinceManual, depth, same, key);
[Fact]
public void Dimensione_e_nomi_coincidono()
{
Assert.Equal(BidFeatures.Size, BidFeatures.Names.Length);
Assert.Equal(BidFeatures.Size, BidFeatures.Build(Ctx()).Length);
}
[Fact]
public void Ogni_gruppo_a_fasce_accende_una_sola_casella()
{
var x = BidFeatures.Build(Ctx());
var n = BidFeatures.Names;
int Accese(string prefisso) => n.Select((name, i) => (name, i))
.Count(t => t.name.StartsWith(prefisso, StringComparison.Ordinal) && x[t.i] == 1);
Assert.Equal(1, Accese("ciclo"));
Assert.Equal(1, Accese("puntatori"));
Assert.Equal(1, Accese("prezzo") + Accese("valoreIgnoto"));
Assert.Equal(1, Accese("ora"));
Assert.Equal(1, Accese("feriale") + Accese("festivo"));
Assert.Equal(1, Accese("manuale") + Accese("automatica"));
Assert.Equal(1, Accese("auto di fila"));
Assert.Equal(1, Accese("profondità"));
Assert.Equal(1, Accese("aggancio"));
Assert.Equal(1, Accese("prodotto#"));
}
[Theory]
[InlineData(0, 0)] [InlineData(4, 0)] [InlineData(8, 0)]
[InlineData(9, 1)] [InlineData(10, 2)] [InlineData(12, 2)]
[InlineData(13, 3)] [InlineData(17, 3)] [InlineData(18, 4)]
[InlineData(20, 4)] [InlineData(21, 5)] [InlineData(23, 5)]
public void Le_fasce_orarie_ricalcano_la_curva_misurata(int ora, int fascia)
{
Assert.Equal(fascia, BidFeatures.HourBand(ora));
}
[Fact]
public void Il_cassetto_del_prodotto_e_stabile_e_in_scala()
{
var a = BidFeatures.ProductBucket("50 Bidoo Shop");
var b = BidFeatures.ProductBucket("50 bidoo shop"); // maiuscole indifferenti
Assert.Equal(a, b);
Assert.InRange(a, 0, 31);
// Un hash stabile deve dare sempre lo stesso numero, avvio dopo avvio: qui si
// fissa il valore atteso per accorgersi se qualcuno cambia la funzione.
Assert.Equal(BidFeatures.ProductBucket("prova"), BidFeatures.ProductBucket("prova"));
}
[Fact]
public void Il_valore_ignoto_e_il_ciclo_ignoto_hanno_una_casella_propria()
{
var x = BidFeatures.Build(Ctx(ratio: null, gap: -1, auto: -1));
var n = BidFeatures.Names;
Assert.Equal(1, x[Array.IndexOf(n, "valoreIgnoto")]);
Assert.Equal(1, x[Array.IndexOf(n, "quotaAutoIgnota")]);
Assert.Equal(0, n.Select((name, i) => (name, i)).Where(t => t.name.StartsWith("ciclo")).Sum(t => x[t.i]));
}
}
/// <summary>
/// Il profilo per prodotto e ora deve restringersi verso il livello sopra quando i dati
/// sono pochi: una media su tre aste non e' una stima, e' un aneddoto.
/// </summary>
public class AuctionProfileStatsTests
{
private static readonly DateTime Lun12 = new(2026, 9, 7, 12, 0, 0); // lunedi', fascia 10-12
private static readonly DateTime Sab21 = new(2026, 9, 12, 21, 0, 0); // sabato, fascia 21-23
[Fact]
public void Con_pochi_esempi_la_stima_resta_vicina_al_globale()
{
var s = new AuctionProfileStats();
for (var i = 0; i < 200; i++) s.Observe("altro", Lun12, 25, 0.05); // globale: 25 puntate
s.Observe("raro", Sab21, 100, 0.30); // un solo esempio, estremo
var e = s.Expect("raro", Sab21)!.Value;
// Un esempio contro k=10: peso 1/11 verso il 100, il resto verso il 25.
Assert.InRange(e.WinnerBids, 25, 40);
Assert.Equal(1, e.ExactSupport);
}
[Fact]
public void Con_molti_esempi_la_combinazione_esatta_domina()
{
var s = new AuctionProfileStats();
for (var i = 0; i < 200; i++) s.Observe("altro", Lun12, 25, 0.05);
for (var i = 0; i < 200; i++) s.Observe("caro", Sab21, 100, 0.30);
var e = s.Expect("caro", Sab21)!.Value;
Assert.InRange(e.WinnerBids, 90, 100);
Assert.InRange(e.CloseRatio, 0.27, 0.30);
}
[Fact]
public void Il_prodotto_pesa_anche_in_una_fascia_mai_vista()
{
var s = new AuctionProfileStats();
for (var i = 0; i < 200; i++) s.Observe("altro", Lun12, 25, 0.05);
for (var i = 0; i < 100; i++) s.Observe("caro", Lun12, 100, 0.30);
// Stesso prodotto, fascia diversa: niente esempi esatti, ma il prodotto parla.
var e = s.Expect("caro", Sab21)!.Value;
Assert.True(e.WinnerBids > 60, $"attese >60, ottenute {e.WinnerBids:F1}");
Assert.Equal(0, e.ExactSupport);
Assert.Equal(100, e.ProductSupport);
}
[Fact]
public void Senza_niente_di_appreso_non_si_inventa()
{
Assert.Null(new AuctionProfileStats().Expect("x", Lun12));
}
[Fact]
public void Si_salva_e_si_rilegge()
{
var s = new AuctionProfileStats();
for (var i = 0; i < 30; i++) s.Observe("p", Lun12, 40, 0.10);
var back = AuctionProfileStats.FromJson(s.ToJson());
Assert.Equal(s.AuctionsObserved, back.AuctionsObserved);
Assert.Equal(s.Expect("p", Lun12)!.Value.WinnerBids, back.Expect("p", Lun12)!.Value.WinnerBids, 6);
}
}
/// <summary>
/// Il lettore per l'addestramento deve etichettare bene e rifiutare i dossier con un buco
/// in fondo: un'etichetta sbagliata proprio sull'ultima puntata insegnerebbe il contrario.
/// </summary>
public class DossierTrainingReaderTests
{
private static string Bid(double t, string user, string at, string type, double price, bool mine = false) =>
$"{{\"t\":{t.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"type\":\"bid\",\"at\":\"x\",\"user\":\"{user}\",\"bidAt\":\"{at}\",\"bidType\":\"{type}\",\"mine\":{(mine ? "true" : "false")},\"price\":{price.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}";
private static IEnumerable<string> Dossier(string winner, bool conPoll = true)
{
yield return "{\"type\":\"header\",\"auctionId\":\"1\",\"name\":\"Prova\",\"productKey\":\"prova\",\"addedAt\":\"2026-09-07T11:00:00+02:00\",\"product\":{\"buyNowPrice\":10}}";
if (conPoll) for (var i = 0; i < 50; i++) yield return "{\"t\":1,\"type\":\"poll\",\"price\":0.01,\"timer\":5}";
yield return Bid(1, "anna", "2026-09-07T11:00:00+02:00", "Auto", 0.01);
yield return Bid(2, "bruno", "2026-09-07T11:00:06+02:00", "Auto", 0.02);
yield return Bid(3, "anna", "2026-09-07T11:00:12+02:00", "Manuale", 0.03);
yield return Bid(4, "carla", "2026-09-07T11:00:21+02:00", "Manuale", 0.04, mine: true);
yield return "{\"type\":\"log\",\"msg\":\"[RESET #1] anna\"}";
yield return $"{{\"type\":\"summary\",\"winner\":\"{winner}\",\"finalPrice\":0.04,\"wonByMe\":false}}";
}
[Fact]
public void Salta_i_poll_e_legge_le_puntate_col_tipo()
{
var s = DossierTrainingReader.Read(Dossier("carla"));
Assert.Equal(4, s.Bids.Count);
Assert.Equal("Manuale", s.Bids[2].BidType);
Assert.True(s.Bids[3].Mine);
Assert.Equal(10, s.BuyNowPrice);
Assert.True(s.IsUsable);
}
[Fact]
public void Un_dossier_con_un_buco_in_fondo_non_si_usa()
{
// L'ultima puntata registrata non e' del vincitore: manca qualcosa alla fine, e
// l'etichetta "senza risposta" andrebbe sulla puntata sbagliata.
var s = DossierTrainingReader.Read(Dossier("qualcunaltro"));
Assert.False(s.IsUsable);
}
[Fact]
public void Gli_esempi_sono_uno_per_puntata_tranne_la_prima_e_solo_l_ultimo_e_positivo()
{
var s = DossierTrainingReader.Read(Dossier("carla"));
var ex = DossierTrainingReader.Examples(s).ToList();
Assert.Equal(3, ex.Count);
Assert.Equal(new[] { false, false, true }, ex.Select(e => e.Unanswered).ToArray());
Assert.True(ex[2].Mine);
}
[Fact]
public void Il_ciclo_si_misura_dal_tempo_dichiarato_dal_server()
{
var s = DossierTrainingReader.Read(Dossier("carla"));
var ex = DossierTrainingReader.Examples(s).ToList();
Assert.Equal(6, ex[0].Context.CycleSeconds); // 11:00:06 - 11:00:00
Assert.Equal(9, ex[2].Context.CycleSeconds); // 11:00:21 - 11:00:12
Assert.Equal(11, ex[0].Context.Hour);
Assert.Equal(DayOfWeek.Monday, ex[0].Context.Day);
}
[Fact]
public void Le_finestre_guardano_solo_le_puntate_precedenti()
{
var s = DossierTrainingReader.Read(Dossier("carla"));
var ex = DossierTrainingReader.Examples(s).ToList();
// Terza puntata (anna, manuale): prima di lei due automatiche -> quota 1.0.
Assert.Equal(1.0, ex[1].Context.AutoShareRecent, 6);
Assert.False(ex[1].Context.IsAutoBid);
// Quarta puntata: prima di lei anna (manuale) -> zero automatiche di fila.
Assert.Equal(0, ex[2].Context.CyclesSinceManual);
Assert.Equal(2, ex[2].Context.DistinctBiddersRecent); // anna, bruno visti prima di carla
}
}
+110
View File
@@ -0,0 +1,110 @@
using System;
using System.IO;
using System.Linq;
using AutoBidder.Data;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il database d'esercizio: documenti per chiave, importazione una tantum dei vecchi
/// file JSON, registri che si scrivono, si rileggono, si esportano e scadono.
/// </summary>
[Collection("Database")]
public class OperationalDatabaseTests
{
private static OperationalDatabase Db => OperationalDatabase.Instance;
[Fact]
public void Un_documento_si_scrive_e_si_rilegge()
{
var key = "test/" + Guid.NewGuid().ToString("N");
Db.SetDocument(key, "{\"a\":1}");
Assert.Equal("{\"a\":1}", Db.GetDocument(key));
Db.SetDocument(key, "{\"a\":2}");
Assert.Equal("{\"a\":2}", Db.GetDocument(key));
Db.DeleteDocument(key);
Db.Flush();
Assert.Null(Db.GetDocument(key));
}
[Fact]
public void Il_vecchio_file_si_importa_una_volta_sola()
{
var key = "test/import-" + Guid.NewGuid().ToString("N");
var legacy = Path.Combine(Path.GetTempPath(), $"legacy-{Guid.NewGuid():N}.json");
File.WriteAllText(legacy, "{\"vecchio\":true}");
try
{
Assert.Equal("{\"vecchio\":true}", Db.GetDocumentOrImport(key, legacy));
// Il file cambia, ma il documento è già nel database: comanda il database.
File.WriteAllText(legacy, "{\"vecchio\":false}");
Assert.Equal("{\"vecchio\":true}", Db.GetDocumentOrImport(key, legacy));
Assert.True(File.Exists(legacy)); // resta dov'è
}
finally { File.Delete(legacy); }
}
[Fact]
public void Senza_documento_ne_file_si_ha_null()
{
Assert.Null(Db.GetDocumentOrImport("test/niente-" + Guid.NewGuid().ToString("N"), Path.Combine(Path.GetTempPath(), "non-esiste.json")));
}
[Fact]
public void Il_registro_si_scrive_si_rilegge_e_si_esporta()
{
var marker = "riga-" + Guid.NewGuid().ToString("N");
Db.WriteLog(OperationalDatabase.AppLog, "INFO", marker);
Db.WriteLog(OperationalDatabase.AppLog, "WARN", marker + "-due");
var rows = Db.ReadLog(OperationalDatabase.AppLog, 10);
Assert.Contains(rows, r => r.Message == marker && r.Level == "INFO");
Assert.Equal(marker + "-due", rows[0].Message); // dalla più recente
var file = Path.Combine(Path.GetTempPath(), $"log-{Guid.NewGuid():N}.txt");
try
{
var n = Db.ExportLog(OperationalDatabase.AppLog, file);
Assert.True(n >= 2);
var text = File.ReadAllText(file);
Assert.Contains(marker, text);
Assert.Contains("[WARN]", text);
}
finally { File.Delete(file); }
}
[Fact]
public void Le_righe_vecchie_scadono()
{
var old = SqliteDatabase.Iso(DateTime.Now.AddDays(-200));
Db.ExecuteNow($"INSERT INTO {OperationalDatabase.FreeBidsLog}(ts, level, message) VALUES(?1, 'INFO', 'antica')", old);
Db.WriteLog(OperationalDatabase.FreeBidsLog, "INFO", "recente");
var removed = Db.PurgeLogs(90);
Assert.True(removed >= 1);
var rows = Db.ReadLog(OperationalDatabase.FreeBidsLog, 100);
Assert.DoesNotContain(rows, r => r.Message == "antica");
Assert.Contains(rows, r => r.Message == "recente");
}
[Fact]
public void Le_aste_nel_monitor_vivono_nel_documento()
{
PersistenceManager.SaveAuctions(new[]
{
new Models.AuctionInfo { AuctionId = "doc-1", Name = "Uno" },
new Models.AuctionInfo { AuctionId = "doc-2", Name = "Due" }
});
var back = PersistenceManager.LoadAuctions();
Assert.Equal(2, back.Count);
Assert.Contains(back, a => a.AuctionId == "doc-2" && a.Name == "Due");
}
}
+15 -31
View File
@@ -18,7 +18,7 @@ namespace AutoBidder.Tests;
///
/// <para>Si lancia con <c>backtest.ps1</c>, che imposta le variabili qui sotto:</para>
/// <list type="bullet">
/// <item><c>AUTOBIDDER_BACKTEST_DIR</c> — cartella dei dossier;</item>
/// <item><c>AUTOBIDDER_BACKTEST_DB</c> — file del database SQLite;</item>
/// <item><c>AUTOBIDDER_BACKTEST_MAX</c> — quanti file al massimo (0 = tutti);</item>
/// <item><c>AUTOBIDDER_BACKTEST_OUT</c> — dove scrivere il riepilogo.</item>
/// </list>
@@ -39,11 +39,11 @@ public class RealDossierBacktest
[Fact]
public void Rigioca_i_dossier_reali()
{
var folder = Environment.GetEnvironmentVariable("AUTOBIDDER_BACKTEST_DIR");
var dbPath = Environment.GetEnvironmentVariable("AUTOBIDDER_BACKTEST_DB");
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
if (string.IsNullOrWhiteSpace(dbPath) || !File.Exists(dbPath))
{
_output.WriteLine("AUTOBIDDER_BACKTEST_DIR non impostata o inesistente: rigiocata saltata.");
_output.WriteLine("AUTOBIDDER_BACKTEST_DB non impostata o inesistente: rigiocata saltata.");
return;
}
@@ -53,20 +53,8 @@ public class RealDossierBacktest
// vuole mettere alla prova, non una configurazione inventata per l'occasione.
var settings = new AppSettings();
// Con AUTOBIDDER_BACKTEST_LEGACY=1 si rimettono i vecchi predefiniti, per vedere
// quanto costavano davvero. Serve a rendere visibile la regressione da cui questa
// prova difende: senza un confronto, "0 puntate bloccate" non dice se il controllo
// funziona o se semplicemente non c'era nulla da bloccare.
var legacy = Environment.GetEnvironmentVariable("AUTOBIDDER_BACKTEST_LEGACY") == "1";
if (legacy)
{
settings.AntiBotDetectionEnabled = true;
settings.SoftRetreatDurationSeconds = 30;
settings.PriceVelocityBlockPerSecond = 0.10;
_output.WriteLine("*** vecchi predefiniti (anti-bot acceso, ritiro 30 s, velocita' 0,10 EUR/s) ***");
}
var aggregates = BacktestReport.Run(folder, Leads, settings, username: "", maxFiles: max);
using var db = AutoBidder.Data.AuctionDatabase.Open(dbPath);
var aggregates = BacktestReport.Run(db, Leads, settings, username: "", maxAuctions: max);
var text = BacktestReport.Format(aggregates);
_output.WriteLine(text);
@@ -85,25 +73,21 @@ public class RealDossierBacktest
return;
}
// Le due condizioni che la rigiocata deve garantire sui dati veri:
// il motore non si inceppa, e coi predefiniti nessuna strategia rifiuta puntate.
// Le due condizioni che la rigiocata deve garantire sui dati veri: il motore non
// si inceppa, e ogni blocco ha un motivo riconosciuto. I motivi leciti sono i
// paletti (budget, pareggio, tetti) e le tre cose apprese o misurate sul momento
// (duello con l'autopuntata, valore atteso, regime). Quello che NON deve esserci
// e' un motivo che nessuno riconosce: sarebbe una regola entrata di nascosto.
foreach (var a in aggregates)
{
Assert.True(a.Reached >= 0 && a.Bids <= a.Reached,
$"conteggi incoerenti con anticipo {a.LeadMs} ms");
if (legacy) continue; // coi vecchi predefiniti i blocchi sono il punto
var ignoti = a.Blocks.Where(kv => kv.Key == "altro").ToList();
// I blocchi di budget e pareggio sono il comportamento voluto: fermano le
// puntate che farebbero perdere soldi, e su un archivio vero devono esserci.
// Quello che NON deve esserci e' un blocco da strategia "difensiva".
var difensivi = a.Blocks
.Where(kv => kv.Key is not ("pareggio" or "tetto-spesa" or "tetto-puntate"))
.ToList();
Assert.True(difensivi.Count == 0,
$"con i predefiniti, anticipo {a.LeadMs} ms: puntate bloccate da strategie " +
$"difensive ({string.Join(", ", difensivi.Select(kv => $"{kv.Key}={kv.Value}"))})");
Assert.True(ignoti.Count == 0,
$"anticipo {a.LeadMs} ms: puntate bloccate per un motivo non riconosciuto " +
$"({string.Join(", ", ignoti.Select(kv => $"{kv.Key}={kv.Value}"))})");
}
}
}
+120
View File
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Services;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il gestore del rischio: il kill-switch ferma tutto, lo stop-loss porta in HALT e
/// l'HALT resta finché non lo si toglie a mano, il tetto del giorno conta le puntate
/// del giorno, le aste in gioco insieme si contano davvero.
/// </summary>
[Collection("Database")]
public class RiskManagerTests
{
private static AuctionInfo Auction(string id, RunState state = RunState.Active, int bids = 0) => new()
{
AuctionId = id, Name = "Prova", State = state, SessionBidCount = bids, BidCost = 0.20
};
private static void Pulito()
{
AuctionDatabase.Instance.ExecuteNow("DELETE FROM risk_ledger");
AuctionDatabase.Instance.ExecuteNow("DELETE FROM meta WHERE key = 'risk_halt'");
RiskManager.ResetForTests();
}
[Fact]
public void Lo_stop_loss_porta_in_halt_e_l_halt_resta()
{
Pulito();
var settings = new AppSettings { DailyStopLossEuro = 1.00 };
for (var i = 0; i < 4; i++) RiskManager.NoteBidSpent("a", 0.20);
Assert.True(RiskManager.Authorize(Auction("a"), settings).Allowed); // 0,80 sotto la soglia
// Con 1,00 € spesi la perdita netta eguaglia la soglia: scatta.
RiskManager.NoteBidSpent("a", 0.20);
var v = RiskManager.Authorize(Auction("a"), settings);
Assert.False(v.Allowed);
Assert.True(RiskManager.Halted);
// Anche senza limite impostato, l'HALT resta.
Assert.False(RiskManager.Authorize(Auction("a"), new AppSettings()).Allowed);
// Sopravvive a un «riavvio» della memoria: è nel database.
AuctionDatabase.Instance.Flush();
RiskManager.ResetForTests();
Assert.True(RiskManager.Halted);
RiskManager.Resume();
Assert.False(RiskManager.Halted);
Assert.True(RiskManager.Authorize(Auction("a"), new AppSettings()).Allowed);
}
[Fact]
public void Una_vincita_riduce_la_perdita_netta_del_giorno()
{
Pulito();
var settings = new AppSettings { DailyStopLossEuro = 1.00 };
for (var i = 0; i < 5; i++) RiskManager.NoteBidSpent("a", 0.20);
RiskManager.NoteWin("a", 3.00, "vinta");
Assert.True(RiskManager.Authorize(Auction("a"), settings).Allowed);
var s = RiskManager.Snapshot();
Assert.Equal(1.00, s.TodaySpent, 6);
Assert.Equal(3.00, s.TodayGained, 6);
Assert.Equal(2.00, s.Cumulative, 6);
}
[Fact]
public void Il_drawdown_si_misura_dal_picco()
{
Pulito();
var settings = new AppSettings { MaxDrawdownEuro = 0.50 };
RiskManager.NoteWin("a", 5.00, "vinta"); // picco 5.00
for (var i = 0; i < 2; i++) RiskManager.NoteBidSpent("b", 0.20); // 4.60: drawdown 0.40
Assert.True(RiskManager.Authorize(Auction("b"), settings).Allowed);
RiskManager.NoteBidSpent("b", 0.20); // 4.40: drawdown 0.60
Assert.False(RiskManager.Authorize(Auction("b"), settings).Allowed);
Assert.True(RiskManager.Halted);
RiskManager.Resume();
}
[Fact]
public void Il_tetto_del_giorno_conta_la_puntata_che_sta_per_partire()
{
Pulito();
var settings = new AppSettings { DailyBudgetEuro = 0.50 };
RiskManager.NoteBidSpent("a", 0.20);
RiskManager.NoteBidSpent("a", 0.20);
Assert.False(RiskManager.Authorize(Auction("a"), settings).Allowed); // 0.40 + 0.20 > 0.50
Assert.False(RiskManager.Halted); // il tetto non e' un HALT: domani si riparte
}
[Fact]
public void Le_aste_in_gioco_insieme_hanno_un_tetto()
{
Pulito();
var settings = new AppSettings { MaxConcurrentActiveAuctions = 2 };
var tutte = new List<AuctionInfo>
{
Auction("a", bids: 3), Auction("b", bids: 1), Auction("c"), Auction("d", RunState.Watch, bids: 9)
};
// c non ha ancora puntato e ce ne sono già due in gioco: no.
Assert.False(RiskManager.Authorize(tutte[2], settings, tutte).Allowed);
// a è già in gioco: continua.
Assert.True(RiskManager.Authorize(tutte[0], settings, tutte).Allowed);
}
}
+90
View File
@@ -0,0 +1,90 @@
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Migrazione delle impostazioni salvate da una versione precedente.
///
/// <para>La proprieta' da difendere: un predefinito nuovo <b>non arriva</b> a chi ha gia' un
/// file salvato, perche' il valore su disco vince. Va bene per le scelte dell'utente, non
/// per due valori che erano conseguenze di misure sbagliate — il tetto di 40 richieste al
/// secondo e l'anticipo di 1000 ms erano stati tarati quando il tetto stesso accodava le
/// chiamate e gonfiava il ping. Senza migrazione l'aggiornamento sarebbe arrivato con le
/// correzioni gia' annullate dal file dell'utente.</para>
///
/// <para>L'altra proprieta', altrettanto importante: chi quei valori li aveva gia' cambiati
/// di suo se li tiene. Quella e' una scelta, e non spetta a un aggiornamento ribaltarla.</para>
/// </summary>
public class SettingsMigrationTests
{
private static AppSettings Vecchie() => new()
{
SettingsSchemaVersion = 0,
MaxRequestsPerSecond = 40,
DefaultBidBeforeDeadlineMs = 1000
};
[Fact]
public void I_valori_vecchi_vengono_portati_avanti()
{
var s = Vecchie();
Assert.True(SettingsManager.Migra(s));
Assert.Equal(0, s.MaxRequestsPerSecond); // nessun tetto
Assert.Equal(500, s.DefaultBidBeforeDeadlineMs);
Assert.Equal(SettingsManager.SchemaCorrente, s.SettingsSchemaVersion);
}
[Fact]
public void Una_scelta_dell_utente_non_viene_ribaltata()
{
var s = new AppSettings
{
SettingsSchemaVersion = 0,
MaxRequestsPerSecond = 15, // scelto apposta, non il vecchio predefinito
DefaultBidBeforeDeadlineMs = 2000 // idem
};
SettingsManager.Migra(s);
Assert.Equal(15, s.MaxRequestsPerSecond);
Assert.Equal(2000, s.DefaultBidBeforeDeadlineMs);
}
[Fact]
public void Gira_una_volta_sola()
{
var s = Vecchie();
Assert.True(SettingsManager.Migra(s));
Assert.False(SettingsManager.Migra(s));
// E se poi l'utente rimette 40 di suo, non glielo si toglie al giro dopo.
s.MaxRequestsPerSecond = 40;
Assert.False(SettingsManager.Migra(s));
Assert.Equal(40, s.MaxRequestsPerSecond);
}
[Fact]
public void Impostazioni_nuove_di_zecca_sono_gia_allo_schema_corrente()
{
// Un file che non esiste ancora non e' un file vecchio: nasce gia' aggiornato,
// e non deve far scattare nessuna migrazione.
var s = new AppSettings { SettingsSchemaVersion = SettingsManager.SchemaCorrente };
Assert.False(SettingsManager.Migra(s));
}
[Fact]
public void I_predefiniti_di_fabbrica_sono_quelli_misurati_oggi()
{
var s = new AppSettings();
Assert.Equal(0, s.MaxRequestsPerSecond);
Assert.Equal(500, s.DefaultBidBeforeDeadlineMs);
Assert.True(s.QuietHoursEnabled);
Assert.True(s.AutoBidDuelWithdrawEnabled);
}
}
+159
View File
@@ -0,0 +1,159 @@
using System;
using System.IO;
using System.Linq;
using AutoBidder.Data;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il wrapper su winsqlite3 e il database: si apre, si scrive in coda, si rilegge dopo
/// Flush, i blocchi sono atomici, le date tornano in UTC, e la copia con VACUUM INTO
/// produce un archivio leggibile.
/// </summary>
public class SqliteTests
{
private static string TempDb() =>
Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", $"db-{Guid.NewGuid():N}.sqlite");
[Fact]
public void La_libreria_di_sistema_risponde()
{
var v = SqliteConnection.LibraryVersion;
Assert.Matches(@"^\d+\.\d+", v);
}
[Fact]
public void Scrive_in_coda_e_rilegge_dopo_flush()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "io");
db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "tu");
db.Flush();
Assert.Equal(2, db.Count("sessions"));
var rows = db.Query("SELECT username FROM sessions ORDER BY session_id");
Assert.Equal(new[] { "io", "tu" }, rows.Select(r => r.Str("username")).ToArray());
Assert.Equal(0, db.WritesFailed);
}
[Fact]
public void Un_blocco_e_tutto_o_niente()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
db.EnqueueBatch(new (string, object?[])[]
{
("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", new object?[] { AuctionDatabase.Now(), "a" }),
("INSERT INTO tabella_che_non_esiste(x) VALUES(1)", Array.Empty<object?>()),
("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", new object?[] { AuctionDatabase.Now(), "b" })
});
db.Flush();
Assert.Equal(0, db.Count("sessions"));
Assert.True(db.WritesFailed > 0);
}
[Fact]
public void Le_date_tornano_in_utc()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
var local = new DateTime(2026, 9, 8, 10, 30, 0, DateTimeKind.Local);
db.ExecuteNow("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", local, "x");
var row = db.QueryOne("SELECT started_at FROM sessions")!;
var back = row.Date("started_at")!.Value;
Assert.Equal(DateTimeKind.Utc, back.Kind);
Assert.Equal(local.ToUniversalTime(), back);
}
[Fact]
public void I_tipi_si_leggono_con_tolleranza()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
db.ExecuteNow("INSERT INTO items(product_key, title, retail_price_bidoo, first_seen_at, updated_at) VALUES('k', 'T', 11.5, ?1, ?1)", AuctionDatabase.Now());
var row = db.QueryOne("SELECT * FROM items")!;
Assert.Equal("k", row.Str("product_key"));
Assert.Equal(11.5, row.Dbl("retail_price_bidoo"));
Assert.Null(row.DblOrNull("market_price_est"));
Assert.Equal(1, row.Long("item_id"));
Assert.True(row.Has("shipping"));
Assert.False(row.Has("colonna_inventata"));
}
[Fact]
public void La_cancellazione_di_unasta_trascina_le_sue_righe()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
var now = AuctionDatabase.Now();
db.Enqueue("INSERT INTO auctions(auction_id, added_at, first_seen_at, opened_at) VALUES('a1', ?1, ?1, ?1)", now);
db.Enqueue("INSERT INTO bids(auction_id, username, price_after) VALUES('a1', 'tizio', 0.01)");
db.Enqueue("INSERT INTO polls(auction_id, t, price) VALUES('a1', 0, 0.01)");
db.ExecuteNow("DELETE FROM auctions WHERE auction_id = 'a1'");
Assert.Equal(0, db.Count("bids"));
Assert.Equal(0, db.Count("polls"));
}
[Fact]
public void Una_scrittura_sbagliata_non_ferma_le_altre()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "prima");
db.Enqueue("INSERT INTO nessuna_tabella(x) VALUES(1)");
db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "dopo");
db.Flush();
Assert.Equal(2, db.Count("sessions"));
Assert.Equal(1, db.WritesFailed);
}
[Fact]
public void La_copia_di_sicurezza_e_un_archivio_leggibile()
{
var path = TempDb();
var backup = path + ".copia.sqlite";
using (var db = AuctionDatabase.Open(path))
{
db.ExecuteNow("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "io");
db.BackupTo(backup);
}
Assert.True(File.Exists(backup));
using var copy = AuctionDatabase.Open(backup);
Assert.Equal(1, copy.Count("sessions"));
}
[Fact]
public void Lo_svuotamento_lascia_prodotti_e_sessioni()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
var now = AuctionDatabase.Now();
db.Enqueue("INSERT INTO items(product_key, title, first_seen_at, updated_at) VALUES('k', 'T', ?1, ?1)", now);
db.Enqueue("INSERT INTO auctions(auction_id, added_at, first_seen_at, opened_at) VALUES('a1', ?1, ?1, ?1)", now);
db.Enqueue("INSERT INTO sessions(started_at) VALUES(?1)", now);
db.Flush();
db.WipeObservations();
Assert.Equal(0, db.Count("auctions"));
Assert.Equal(1, db.Count("items"));
Assert.Equal(1, db.Count("sessions"));
}
}
+59
View File
@@ -0,0 +1,59 @@
using System;
using System.IO;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// L'azzeramento tocca solo le voci scelte: gli archivi delle versioni precedenti sono
/// la vecchia cartella Dati, e senza voci scelte non succede nulla.
/// </summary>
[Collection("Database")]
public class StatsWipeTests
{
[Fact]
public void Toglie_la_vecchia_cartella_dei_dati_e_lascia_il_resto()
{
var legacy = AppPaths.LegacyDataFolder;
Directory.CreateDirectory(Path.Combine(legacy, "Statistiche"));
var vecchio = Path.Combine(legacy, "Statistiche", "aste-2020-01.jsonl");
File.WriteAllText(vecchio, "{}\n");
var altro = Path.Combine(AppPaths.ConfigRoot, $"altro-{Guid.NewGuid():N}.json");
File.WriteAllText(altro, "[]");
try
{
var prima = StatsWipe.Measure();
Assert.True(prima.LegacyFiles >= 1);
var report = StatsWipe.Run(new StatsWipe.Options
{
History = false, ProductStats = false, LegacyArchives = true, Learning = false
});
Assert.False(File.Exists(vecchio));
Assert.False(Directory.Exists(legacy));
Assert.True(File.Exists(altro));
Assert.Null(report.BackupPath);
Assert.Empty(report.Errors);
Assert.Contains(report.Steps, s => s.StartsWith("archivi delle versioni precedenti"));
}
finally
{
if (File.Exists(altro)) File.Delete(altro);
}
}
[Fact]
public void Senza_voci_scelte_non_fa_nulla()
{
var o = new StatsWipe.Options { History = false, ProductStats = false, LegacyArchives = false, Learning = false };
Assert.True(o.Nothing);
var report = StatsWipe.Run(o);
Assert.Empty(report.Steps);
Assert.Null(report.BackupPath);
}
}
+5 -3
View File
@@ -20,9 +20,11 @@ internal static class TestEnvironment
{
var sandbox = Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", Guid.NewGuid().ToString("N"));
AppPaths.Configure(
dataFolder: Path.Combine(sandbox, "Dati"),
statsFolder: Path.Combine(sandbox, "Statistiche"));
// Anche la radice della configurazione: senza, un test che tocca le impostazioni
// leggerebbe e scriverebbe quelle vere di chi lancia la suite.
AppPaths.OverrideConfigRootForTests(sandbox);
AppPaths.Configure(databaseFolder: Path.Combine(sandbox, "Database"));
AppPaths.EnsureFolders();
}
+127
View File
@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Ml;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Le formule del modello matematico devono dare i numeri che si calcolano a mano,
/// e la copertura «Compralo Ora» deve azzerare il costo della sconfitta solo quando
/// le puntate spese superano la perdita coperta.
/// </summary>
public class TheoryTests
{
private static Theory.BidSituation S(double p, double value = 11, double price = 0.50, double c = 0.20,
double fee = 1.0, double ship = 0, double? list = null, double spent = 0, bool hedge = false) =>
new(p, value, price, c, fee, ship, list, spent, hedge);
[Fact]
public void Senza_copertura_la_puntata_si_paga_in_entrambi_i_rami()
{
// P=0.5: vinco 11 0.51 1 0.20 = 9.29; perdo 0.20.
var ev = Theory.BidExpectedValue(S(0.5));
Assert.Equal(0.5 * 9.29 - 0.5 * 0.20, ev, 6);
}
[Fact]
public void Con_p_zero_si_perde_esattamente_il_costo_della_puntata()
{
Assert.Equal(-0.20, Theory.BidExpectedValue(S(0)), 6);
}
[Fact]
public void Il_pareggio_e_dove_il_valore_atteso_e_zero()
{
var s = S(0.123);
var pStar = Theory.BreakEvenPWin(s);
var atBreakEven = s with { PWin = pStar };
Assert.Equal(0, Theory.BidExpectedValue(atBreakEven), 6);
}
[Fact]
public void Se_non_conviene_neanche_vincendo_il_pareggio_e_uno()
{
var s = S(0.9, value: 1.0, price: 0.90, fee: 1.0);
Assert.Equal(1, Theory.BreakEvenPWin(s));
Assert.False(Theory.ShouldBid(s));
}
[Fact]
public void La_copertura_azzera_il_costo_di_sconfitta_solo_oltre_la_perdita_coperta()
{
// Listino 11, valore reale 8.80: perdita coperta 2.20 = 11 puntate da 0.20.
var prima = S(0.1, value: 8.80, list: 11, spent: 1.00, hedge: true);
Assert.Equal(0.20, Theory.LossBranchCost(prima));
var dopo = S(0.1, value: 8.80, list: 11, spent: 2.20, hedge: true);
Assert.Equal(0, Theory.LossBranchCost(dopo));
}
[Fact]
public void Per_un_buono_al_nominale_la_copertura_e_totale_dal_primo_centesimo()
{
var s = S(0.01, value: 11, list: 11, spent: 0, hedge: true);
Assert.Equal(0, Theory.LossBranchCost(s));
Assert.True(Theory.BidExpectedValue(s) > 0);
}
[Fact]
public void Con_la_copertura_spenta_il_listino_non_conta()
{
var s = S(0.1, value: 8.80, list: 11, spent: 5, hedge: false);
Assert.Equal(0.20, Theory.LossBranchCost(s));
}
[Fact]
public void Il_numero_critico_di_puntatori_e_valore_su_costo()
{
Assert.Equal(55, Theory.CriticalBidders(11, 0.20), 6);
}
[Fact]
public void La_probabilita_di_vittoria_a_un_anticipo_e_la_frequenza_dei_cicli_sopravvissuti()
{
var cycles = new List<Theory.CycleObservation>();
// 30 cicli fermati a 5 s (puntate presto), 15 scesi fino a 1 s, 5 che hanno chiuso l'asta.
for (var i = 0; i < 30; i++) cycles.Add(new(5.0, false));
for (var i = 0; i < 15; i++) cycles.Add(new(1.0, false));
for (var i = 0; i < 5; i++) cycles.Add(new(0, true));
// A 2 s di anticipo: raggiunto da 15 + 5 = 20 cicli, sopravvissuti 5.
Assert.Equal(0.25, Theory.WinProbabilityAtLead(cycles, 2.0)!.Value, 6);
// A 6 s: raggiunto da tutti e 50, sopravvissuti 5.
Assert.Equal(0.10, Theory.WinProbabilityAtLead(cycles, 6.0)!.Value, 6);
// Con pochi cicli non si dice nulla.
Assert.Null(Theory.WinProbabilityAtLead(cycles.Take(10), 2.0));
}
[Fact]
public void Kaplan_meier_scende_a_ogni_puntata_e_non_alle_censure()
{
var cycles = new[]
{
new Theory.CycleObservation(8, false),
new Theory.CycleObservation(8, false),
new Theory.CycleObservation(3, false),
new Theory.CycleObservation(0, true)
};
var km = Theory.KaplanMeier(cycles);
Assert.Equal(2, km.Count);
Assert.Equal(8, km[0].TimerSeconds);
Assert.Equal(0.5, km[0].Survival, 6); // 2 su 4 puntano a 8 s
Assert.Equal(0.25, km[1].Survival, 6); // poi 1 su 2 a 3 s
}
[Fact]
public void Poisson_da_la_probabilita_che_nessuno_punti()
{
Assert.Equal(Math.Exp(-0.5 * 2), Theory.NoBidProbability(0.5, 2), 9);
Assert.Equal(1, Theory.NoBidProbability(0, 2));
Assert.Equal(0.25, Theory.ArrivalRate(new[] { 4.0, 4.0, 4.0 })!.Value, 6);
Assert.Null(Theory.ArrivalRate(Array.Empty<double>()));
}
}
+47 -3
View File
@@ -449,14 +449,24 @@
<Setter Property="AlternatingRowBackground" Value="{DynamicResource Brush.Surface}"/>
<Setter Property="GridLinesVisibility" Value="None"/>
<Setter Property="HeadersVisibility" Value="Column"/>
<Setter Property="RowHeight" Value="28"/>
<Setter Property="ColumnHeaderHeight" Value="30"/>
<!-- Densità: 22 px invece di 28 fanno entrare circa un quarto di righe in più a
parità di altezza. Su un cruscotto che serve a tenere d'occhio molte aste
insieme, l'altezza è la risorsa scarsa, e sei pixel di aria per riga costano
una decina di aste sullo schermo. -->
<Setter Property="RowHeight" Value="22"/>
<Setter Property="ColumnHeaderHeight" Value="26"/>
<Setter Property="FontFamily" Value="{StaticResource Font.UI}"/>
<Setter Property="FontSize" Value="{StaticResource Font.Size.Base}"/>
<Setter Property="FontSize" Value="{StaticResource Font.Size.Sm}"/>
<Setter Property="CanUserAddRows" Value="False"/>
<Setter Property="AutoGenerateColumns" Value="False"/>
<Setter Property="SelectionMode" Value="Single"/>
<Setter Property="EnableRowVirtualization" Value="True"/>
<!-- Il riciclo riusa i contenitori di riga invece di ricrearli a ogni giro di
scorrimento. Con la griglia del monitor, che si aggiorna piu' volte al
secondo mentre le aste corrono, e' la differenza fra allocare righe nuove
di continuo e non allocarne quasi nessuna. Il valore predefinito di WPF e'
"Standard", cioe' senza riciclo. -->
<Setter Property="VirtualizingPanel.VirtualizationMode" Value="Recycling"/>
<Setter Property="VerticalGridLinesBrush" Value="Transparent"/>
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource Brush.Border}"/>
</Style>
@@ -688,6 +698,40 @@
</Setter>
</Style>
<!-- Pulsante icona a due stati: stesso aspetto di IconButton, e quando è acceso
si riempie del colore d'accento. -->
<Style x:Key="IconToggle" TargetType="ToggleButton">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource Brush.TextMuted}"/>
<Setter Property="FontFamily" Value="{StaticResource Font.UI}"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="Width" Value="30"/>
<Setter Property="Height" Value="28"/>
<Setter Property="Margin" Value="1,0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="7">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Brush.Accent}"/>
<Setter Property="Foreground" Value="{DynamicResource Brush.TextOnAccent}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Brush.SurfaceHover}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.35"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Variante minuta, per le azioni dentro una riga di griglia. -->
<Style x:Key="MiniIconButton" TargetType="Button" BasedOn="{StaticResource IconButton}">
<Setter Property="Width" Value="24"/>
+106 -131
View File
@@ -6,127 +6,134 @@ namespace AutoBidder.Utilities
/// <summary>
/// Posizione di tutti i file dell'applicazione, in un solo posto.
///
/// <para>Prima ogni archivio sceglieva per conto proprio fra <c>Roaming</c> e
/// <c>Local</c>: le aste finivano in una cartella e le impostazioni in un'altra, cosa
/// che rendeva difficile perfino sapere dove guardare per un salvataggio.</para>
///
/// <para>La struttura è deliberatamente su due livelli:</para>
/// <para>Tre cose, tre posti:</para>
/// <list type="bullet">
/// <item><b>Radice fissa</b> (<c>%LocalAppData%\AutoBidder</c>): configurazione e
/// sessione. Non si sposta, perché è lì che si legge <i>dove</i> stanno gli altri
/// dati — spostarla creerebbe un problema dell'uovo e della gallina. La sessione
/// resta qui anche perché è protetta con DPAPI e legata a questo utente Windows:
/// su un disco condiviso non sarebbe comunque leggibile altrove.</item>
/// <item><b>Cartella dati</b> (configurabile): aste monitorate, storico, prodotti
/// seguiti e statistiche. È questa che si può portare su un altro disco, in una
/// cartella sincronizzata o accanto ai propri fogli di calcolo.</item>
/// <item><b>Radice dell'autenticazione</b> (<c>%LocalAppData%\AutoBidder</c>): sessione,
/// riferimenti del sito e profilo del browser. Non si sposta mai: la sessione è
/// protetta con DPAPI e legata a questo utente Windows.</item>
/// <item><b>Radice della configurazione</b> (<c>Documenti\AutoBidder</c> per le
/// installazioni nuove; <c>%LocalAppData%</c> per chi ci aveva già le impostazioni):
/// il file delle impostazioni e le copie di sicurezza. È una cartella nota a Windows,
/// quindi si trova senza dover leggere niente prima.</item>
/// <item><b>Cartella dei database</b> (configurabile, predefinita
/// <c>%LocalAppData%\AutoBidder\Database</c>): i due file SQLite — osservazioni e
/// esercizio — con tutto il resto: aste, puntate, decisioni, prodotti, modelli,
/// registri. Mai in una cartella sincronizzata (OneDrive, Dropbox): un database
/// aperto sotto sincronizzazione si corrompe.</item>
/// </list>
///
/// <para>I file JSON delle versioni precedenti (<c>Dati\…</c>) non si scrivono più;
/// alla prima apertura vengono importati nel database d'esercizio e restano sul disco
/// finché non li si toglie dalla scheda Storico.</para>
/// </summary>
public static class AppPaths
{
/// <summary>Radice fissa: configurazione dell'applicazione e sessione.</summary>
/// <summary>Radice dell'autenticazione. Resta in <c>%LocalAppData%</c> qualunque cosa succeda.</summary>
public static string Root { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AutoBidder");
/// <summary>Impostazioni. Sempre nella radice fissa: contiene il percorso dei dati.</summary>
public static string SettingsFile => Path.Combine(Root, "settings.json");
private static string DocumentsRoot
{
get
{
try
{
var docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
if (!string.IsNullOrWhiteSpace(docs)) return Path.Combine(docs, "AutoBidder");
}
catch { }
/// <summary>Cookie di sessione protetto con DPAPI.</summary>
public static string SessionFile => Path.Combine(Root, "session.dat");
return Root;
}
}
private static string? _configRootOverride;
private static string? _configRootResolved;
/// <summary>
/// Riferimenti del sito per il riscatto puntate: indirizzi, intestazioni, parametri.
/// Sta nella radice fissa insieme alle impostazioni perché è configurazione, non un
/// dato d'esercizio: chi porta la cartella dati su un altro disco non se lo porta
/// dietro, e chi corregge un indirizzo lo trova sempre nello stesso posto.
/// Radice della configurazione: impostazioni e copie di sicurezza. Si decide una
/// volta sola: Documenti se le impostazioni ci sono già; altrimenti
/// <c>%LocalAppData%</c> se ci sono lì (installazione precedente, che resta dov'è);
/// altrimenti Documenti, perché è un'installazione nuova.
/// </summary>
public static string SiteConfigFile => Path.Combine(Root, "site-config.json");
public static string ConfigRoot
{
get
{
if (_configRootOverride != null) return _configRootOverride;
return _configRootResolved ??= ResolveConfigRoot();
}
}
/// <summary>Profilo del browser integrato (cache WebView2).</summary>
private static string ResolveConfigRoot()
{
var documenti = DocumentsRoot;
if (File.Exists(Path.Combine(documenti, "settings.json"))) return documenti;
if (File.Exists(Path.Combine(Root, "settings.json"))) return Root;
try
{
Directory.CreateDirectory(documenti);
return documenti;
}
catch
{
return Root;
}
}
/// <summary>Solo per i test: fissa la radice della configurazione senza guardare il disco.</summary>
public static void OverrideConfigRootForTests(string? path) => _configRootOverride = path;
public static string SettingsFile => Path.Combine(ConfigRoot, "settings.json");
/// <summary>Copie di sicurezza dei database, fatte prima di ogni operazione che cancella.</summary>
public static string BackupsFolder => Path.Combine(ConfigRoot, "Backups");
// ── Autenticazione ───────────────────────────────────────────────
public static string SessionFile => Path.Combine(Root, "session.dat");
public static string SiteConfigFile => Path.Combine(Root, "site-config.json");
public static string BrowserProfile => Path.Combine(Root, "WebView2");
/// <summary>Percorso dati scelto dall'utente; vuoto = predefinito.</summary>
private static string _dataFolderOverride = "";
// ── Database ─────────────────────────────────────────────────────
/// <summary>Percorso statistiche scelto dall'utente; vuoto = sotto la cartella dati.</summary>
private static string _statsFolderOverride = "";
private static string _databaseFolderOverride = "";
/// <summary>
/// Cartella dei dati d'esercizio: aste monitorate, storico, prodotti seguiti.
/// </summary>
public static string DataFolder =>
_dataFolderOverride.Length > 0 ? _dataFolderOverride : Path.Combine(Root, "Dati");
/// <summary>La cartella dei due database. Vuota nelle impostazioni = predefinita.</summary>
public static string DatabaseFolder =>
_databaseFolderOverride.Length > 0 ? _databaseFolderOverride : Path.Combine(Root, "Database");
/// <summary>
/// Cartella delle statistiche e delle esportazioni. Separata dalla precedente
/// perché è quella che si tiene volentieri altrove: cresce nel tempo e serve
/// soprattutto per le analisi fatte fuori dall'applicazione.
/// </summary>
public static string StatsFolder =>
_statsFolderOverride.Length > 0 ? _statsFolderOverride : Path.Combine(DataFolder, "Statistiche");
public static string DefaultDatabaseFolder => Path.Combine(Root, "Database");
// ── File dei singoli archivi ─────────────────────────────────────
/// <summary>Le osservazioni: aste, puntate, interrogazioni, decisioni, misure.</summary>
public static string DatabaseFile => Path.Combine(DatabaseFolder, "autobidder.sqlite");
public static string AuctionsFile => Path.Combine(DataFolder, "auctions.json");
public static string WatchedProductsFile => Path.Combine(DataFolder, "watched-products.json");
/// <summary>L'esercizio: documenti (aste nel monitor, prodotti, modelli) e registri.</summary>
public static string OperationalDatabaseFile => Path.Combine(DatabaseFolder, "esercizio.sqlite");
/// <summary>
/// Collegamenti promozionali già aperti. Dato d'esercizio, non statistica: serve a
/// non riaprire ogni mezz'ora gli stessi premi, che sulla pagina di origine restano
/// pubblicati per giorni.
/// </summary>
public static string ClaimedPromosFile => Path.Combine(DataFolder, "claimed-promos.json");
/// <summary>I tre file (principale, WAL, SHM) di un database, quelli che esistono.</summary>
public static string[] DatabaseFiles(string dbPath) =>
Array.FindAll(new[] { dbPath, dbPath + "-wal", dbPath + "-shm" }, File.Exists);
/// <summary>Storico delle aste concluse: è un dato statistico, non d'esercizio.</summary>
public static string CompletedAuctionsFile => Path.Combine(StatsFolder, "completed-auctions.json");
// ── Archivi delle versioni precedenti (solo lettura, per l'importazione) ──
/// <summary>Misure dell'anticipo effettivo delle puntate.</summary>
public static string BidLeadStatsFile => Path.Combine(StatsFolder, "bid-lead-stats.json");
/// <summary>La vecchia cartella dei dati d'esercizio: <c>ConfigRoot\Dati</c>.</summary>
public static string LegacyDataFolder => Path.Combine(ConfigRoot, "Dati");
/// <summary>Cartella dove finiscono le esportazioni richieste dall'utente.</summary>
public static string ExportFolder => Path.Combine(StatsFolder, "Esportazioni");
public static string LegacyAuctionsFile => Path.Combine(LegacyDataFolder, "auctions.json");
public static string LegacyWatchedProductsFile => Path.Combine(LegacyDataFolder, "watched-products.json");
public static string LegacyProductsFile => Path.Combine(LegacyDataFolder, "products.json");
public static string LegacyClaimedPromosFile => Path.Combine(LegacyDataFolder, "claimed-promos.json");
public static string LegacyStatsFolder => Path.Combine(LegacyDataFolder, "Statistiche");
public static string LegacyLearningFolder => Path.Combine(LegacyStatsFolder, "Apprendimento");
public static string LegacyFreeBidsStatsFile => Path.Combine(LegacyStatsFolder, "free-bids-stats.json");
/// <summary>
/// Statistiche e opzioni per prodotto. Sta fra i dati d'esercizio perché è ciò che
/// decide come entrano le aste nuove, non un archivio da consultare.
/// </summary>
public static string ProductsFile => Path.Combine(DataFolder, "products.json");
// ── Registri di testo ────────────────────────────────────────────
/// <summary>Percorso registri scelto dall'utente; vuoto = sotto la cartella dati.</summary>
private static string _logFolderOverride = "";
/// <summary>
/// Registri leggibili: log applicativo, riscatto puntate e dossier delle singole
/// aste. Separata dal resto perché è la cartella che si apre a mano più spesso — e
/// la sola che convenga poter mettere su un disco capiente: con il registro completo
/// dei poll un'asta lunga vale qualche megabyte.
/// </summary>
public static string LogFolder =>
_logFolderOverride.Length > 0 ? _logFolderOverride : Path.Combine(DataFolder, "Registri");
/// <summary>Dossier di una singola asta, uno per file.</summary>
public static string AuctionLogFolder => Path.Combine(LogFolder, "Aste");
/// <summary>Registro applicativo del giorno indicato.</summary>
public static string AppLogFile(DateTime day) =>
Path.Combine(LogFolder, $"app-{day:yyyy-MM-dd}.log");
/// <summary>Registro del riscatto puntate del giorno indicato.</summary>
public static string FreeBidsLogFile(DateTime day) =>
Path.Combine(LogFolder, $"puntate-{day:yyyy-MM-dd}.log");
/// <summary>
/// Applica i percorsi scelti dall'utente. Un percorso non utilizzabile viene
/// ignorato in favore del predefinito: è preferibile scrivere nel posto sbagliato
/// che non scrivere affatto e perdere lo storico.
/// </summary>
public static void Configure(string? dataFolder, string? statsFolder, string? logFolder = null)
/// <summary>Applica la cartella dei database scelta dall'utente. Non utilizzabile = predefinita.</summary>
public static void Configure(string? databaseFolder)
{
_dataFolderOverride = Sanitize(dataFolder);
_statsFolderOverride = Sanitize(statsFolder);
_logFolderOverride = Sanitize(logFolder);
_databaseFolderOverride = Sanitize(databaseFolder);
}
private static string Sanitize(string? candidate)
@@ -136,7 +143,7 @@ namespace AutoBidder.Utilities
try
{
var full = Path.GetFullPath(candidate.Trim());
Directory.CreateDirectory(full); // verifica che sia davvero scrivibile
Directory.CreateDirectory(full);
return full;
}
catch
@@ -149,10 +156,8 @@ namespace AutoBidder.Utilities
public static void EnsureFolders()
{
TryCreate(Root);
TryCreate(DataFolder);
TryCreate(StatsFolder);
TryCreate(LogFolder);
TryCreate(AuctionLogFolder);
TryCreate(ConfigRoot);
TryCreate(DatabaseFolder);
}
private static void TryCreate(string path)
@@ -161,37 +166,7 @@ namespace AutoBidder.Utilities
{
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
}
catch { /* la creazione è best-effort: l'errore emerge alla scrittura */ }
}
/// <summary>
/// Sposta i file rimasti nelle vecchie posizioni. Le versioni precedenti
/// scrivevano parte dei dati in <c>Roaming</c> e parte in <c>Local</c>, senza
/// sottocartelle: senza questo passaggio storico e aste sparirebbero alla vista.
/// </summary>
public static void MigrateLegacyFiles()
{
var roaming = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AutoBidder");
EnsureFolders();
MoveIfPresent(Path.Combine(roaming, "auctions.json"), AuctionsFile);
MoveIfPresent(Path.Combine(roaming, "completed-auctions.json"), CompletedAuctionsFile);
MoveIfPresent(Path.Combine(roaming, "session.dat"), SessionFile);
MoveIfPresent(Path.Combine(Root, "watched-products.json"), WatchedProductsFile);
}
private static void MoveIfPresent(string from, string to)
{
try
{
if (!File.Exists(from) || File.Exists(to)) return;
Directory.CreateDirectory(Path.GetDirectoryName(to)!);
File.Move(from, to);
}
catch { /* un file bloccato non deve impedire l'avvio */ }
catch { }
}
}
}
+94 -74
View File
@@ -1,102 +1,122 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoBidder.Data;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
/// <summary>
/// Schede dettagliate delle aste concluse, una riga per asta.
/// Le schede dettagliate delle aste concluse, lette dalla tabella <c>auctions</c>.
///
/// <para>Formato <b>JSON Lines</b>: un oggetto per riga, file separato per mese. La
/// scelta è deliberata — un unico documento JSON andrebbe riletto e riscritto per
/// intero a ogni asta conclusa, mentre qui si accoda e basta; e un file che cresce per
/// mesi diventerebbe scomodo da aprire e impossibile da leggere a pezzi. Ogni riga è
/// indipendente: strumenti di analisi e fogli di calcolo li leggono in streaming, e un
/// file troncato perde al più l'ultima riga invece di diventare illeggibile.</para>
/// <para>La scheda è la stessa riga dello storico compatto, vista per intero: serie
/// dei prezzi, puntate per utente, copertura dell'osservazione, rete, motore. Non
/// c'è più nulla da ricomporre da file diversi.</para>
/// </summary>
public static class AuctionDetailStore
{
private static readonly object Sync = new();
private static AuctionDatabase Db => AuctionDatabase.Instance;
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = false // una riga per record: il formato lo richiede
};
private const string Select =
"SELECT * FROM auctions WHERE outcome IS NOT NULL ORDER BY end_time DESC";
/// <summary>File del mese indicato (predefinito: quello corrente).</summary>
public static string FileFor(DateTime when) =>
Path.Combine(AppPaths.StatsFolder, $"aste-{when:yyyy-MM}.jsonl");
public static void Append(AuctionDetailRecord record)
{
if (record == null) return;
try
{
AppPaths.EnsureFolders();
var line = JsonSerializer.Serialize(record, Options);
lock (Sync)
{
File.AppendAllText(FileFor(record.EndedAt), line + Environment.NewLine);
}
}
catch { /* la raccolta dati non deve mai interrompere il monitoraggio */ }
}
/// <summary>Tutte le schede registrate, dalla più recente.</summary>
/// <summary>Tutte le schede, dalla più recente.</summary>
public static List<AuctionDetailRecord> LoadAll()
{
var result = new List<AuctionDetailRecord>();
try
{
if (!Directory.Exists(AppPaths.StatsFolder)) return result;
lock (Sync)
{
foreach (var file in Directory.GetFiles(AppPaths.StatsFolder, "aste-*.jsonl"))
{
foreach (var line in File.ReadLines(file))
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
var record = JsonSerializer.Deserialize<AuctionDetailRecord>(line);
if (record != null) result.Add(record);
}
catch
{
// Una riga corrotta non deve far perdere tutte le altre:
// è il vantaggio principale di un record per riga.
}
}
}
}
}
catch { }
return result.OrderByDescending(r => r.EndedAt).ToList();
Db.Flush();
return Db.Query(Select).Select(FromRow).ToList();
}
public static int Count()
/// <summary>La scheda di una sola asta, o null.</summary>
public static AuctionDetailRecord? Load(string auctionId)
{
Db.Flush();
var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1 AND outcome IS NOT NULL", auctionId);
return row == null ? null : FromRow(row);
}
public static int Count() => (int)Db.ScalarLong("SELECT COUNT(*) AS n FROM auctions WHERE outcome IS NOT NULL");
/// <summary>Le aste di cui esistono eventi registrati (interrogazioni o puntate).</summary>
public static HashSet<string> WithEvents()
{
Db.Flush();
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var r in Db.Query("SELECT DISTINCT auction_id FROM polls UNION SELECT DISTINCT auction_id FROM bids"))
set.Add(r.Str("auction_id"));
return set;
}
internal static AuctionDetailRecord FromRow(SqliteRow row)
{
var r = new AuctionDetailRecord
{
AuctionId = row.Str("auction_id"),
Name = row.Str("name"),
ProductKey = row.Str("product_key"),
Url = row.Str("url"),
Outcome = row.Str("outcome"),
WonByMe = row.Bool("won_by_me"),
Winner = row.Str("winner_user"),
FinalPrice = row.Dbl("final_price"),
BuyNowPrice = row.DblOrNull("buy_now_price"),
ShippingCost = row.DblOrNull("shipping"),
FirstSeenAt = row.Date("first_seen_at") ?? row.Date("added_at") ?? DateTime.MinValue,
EndedAt = row.Date("end_time") ?? row.Date("closed_at") ?? DateTime.MinValue,
ObservedMinutes = row.Dbl("observed_minutes"),
ObservedFromStart = row.Bool("observed_from_start"),
ObservedToEnd = row.Bool("observed_to_end"),
MyBids = row.Int("my_bids"),
TotalObservedBids = row.Int("total_observed_bids"),
DistinctBidders = row.Int("distinct_bidders"),
Resets = row.Int("resets"),
TopBidderShare = row.Dbl("top_bidder_share"),
PriceVelocityPerMinute = row.Dbl("price_velocity_per_minute"),
AveragePingMs = row.Dbl("avg_ping_ms"),
PollCount = row.Long("polls"),
PollErrors = row.Long("poll_errors"),
ConfiguredLeadMs = row.Int("configured_lead_ms"),
TimerExpiredCount = row.Int("timer_expired"),
SuccessfulBids = row.Int("successful_bids"),
FailedBids = row.Int("failed_bids"),
BidCostEuro = row.DblOrNull("bid_fee") is { } fee && fee > 0 ? fee : 0.20
};
if (r.ObservedMinutes <= 0 && r.EndedAt > r.FirstSeenAt)
r.ObservedMinutes = Math.Max(0, (r.EndedAt - r.FirstSeenAt).TotalMinutes);
try
{
if (!Directory.Exists(AppPaths.StatsFolder)) return 0;
lock (Sync)
var series = row.StrOrNull("price_series_json");
if (!string.IsNullOrEmpty(series))
{
return Directory.GetFiles(AppPaths.StatsFolder, "aste-*.jsonl")
.Sum(f => File.ReadLines(f).Count(l => !string.IsNullOrWhiteSpace(l)));
using var doc = JsonDocument.Parse(series);
foreach (var p in doc.RootElement.EnumerateArray())
{
r.PriceSeries.Add(new PricePoint
{
T = p.TryGetProperty("t", out var t) ? t.GetDouble() : 0,
Price = p.TryGetProperty("price", out var pr) ? pr.GetDouble() : 0
});
}
}
var byUser = row.StrOrNull("bids_by_user_json");
if (!string.IsNullOrEmpty(byUser))
{
var dict = JsonSerializer.Deserialize<Dictionary<string, int>>(byUser);
if (dict != null)
foreach (var kv in dict) r.BidsByUser[kv.Key] = kv.Value;
}
}
catch { return 0; }
catch { /* una colonna JSON rotta non deve togliere la scheda */ }
return r;
}
}
}
-493
View File
@@ -1,493 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
/// <summary>
/// Il dossier di una singola asta: un file per asta, scritto mentre l'asta è in corso.
///
/// <para><b>Formato JSON Lines.</b> La prima riga è l'intestazione (che asta è, con che
/// impostazioni la si sta seguendo, quanto vale il prodotto), poi una riga per ogni cosa
/// che succede — ogni interrogazione a Bidoo, ogni puntata di ogni utente con l'ora al
/// millisecondo, ogni mia puntata con anticipo pianificato ed effettivo, ogni riga di
/// registro — e l'ultima riga è il riepilogo. Si accoda senza rileggere, un'interruzione
/// perde al massimo l'ultima riga, e un'AI lo legge senza doverlo convertire.</para>
///
/// <para>Perché tanto dettaglio: questi dati <b>non esistono a posteriori</b>. Bidoo non
/// espone lo storico dei prezzi né i tempi delle puntate di un'asta conclusa. O li si
/// raccoglie mentre l'asta va avanti, o sono persi — e sono esattamente quelli su cui si
/// capisce se l'anticipo impostato è giusto e come si comportano gli avversari.</para>
///
/// <para>La scrittura passa da <see cref="FileLogWriter"/>, quindi non tocca mai il
/// disco sul thread che segue l'asta: si accoda una riga e si prosegue.</para>
/// </summary>
public sealed class AuctionDossier
{
/// <summary>Campioni di ping tenuti in memoria per il riepilogo finale.</summary>
private const int MaxPingSamples = 50_000;
private static readonly ConcurrentDictionary<string, AuctionDossier> Open =
new(StringComparer.Ordinal);
private static readonly JsonSerializerOptions Json = new()
{
WriteIndented = false,
// Senza, accenti e apostrofi dei nomi prodotto finiscono in \uXXXX e il file
// diventa illeggibile proprio per chi (o cosa) deve analizzarlo.
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly FileLogWriter _writer;
private readonly DateTime _startedAt;
private readonly List<int> _pings = new();
private readonly object _sync = new();
private long _polls;
private long _events;
private bool _closed;
private AuctionDossier(string path, DateTime startedAt)
{
_writer = FileLogWriter.For(path);
_startedAt = startedAt;
}
public string Path => _writer.Path;
/// <summary>Righe scritte finora, intestazione esclusa.</summary>
public long EventCount => _events;
// ── Apertura e chiusura ──────────────────────────────────────────
/// <summary>
/// Apre (o riapre) il dossier di quest'asta. Riaprendo l'applicazione mentre l'asta
/// è ancora viva si <b>riprende lo stesso file</b>: un'asta seguita a cavallo di un
/// riavvio deve restare una storia sola.
/// </summary>
public static AuctionDossier? OpenFor(AuctionInfo auction, AppSettings settings)
{
if (auction == null || !settings.WriteAuctionDossiers) return null;
return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings));
}
/// <summary>Il dossier già aperto per quest'asta, se c'è.</summary>
public static AuctionDossier? For(string auctionId) =>
auctionId != null && Open.TryGetValue(auctionId, out var dossier) ? dossier : null;
/// <summary>
/// Fa confluire nel dossier tutte le righe di registro delle aste. Da chiamare una
/// volta all'avvio: le righe nascono in decine di punti del motore, e agganciarsi
/// alla sorgente è l'unico modo per non doverle inseguire una per una.
/// </summary>
public static void CaptureAuctionLogs()
{
AuctionInfo.LogSink = (auction, entry) =>
For(auction.AuctionId)?.Log(
entry.Level.ToString().ToLowerInvariant(),
entry.Category.ToString(),
entry.Message);
}
/// <summary>
/// Chiude il dossier di un'asta tolta dal monitor prima della fine.
///
/// <para>Senza riepilogo, e detto esplicitamente: quel file racconta una storia
/// interrotta, e chi lo analizzerà deve saperlo — una serie di prezzi che si ferma a
/// metà sembra un'asta finita a quel prezzo.</para>
/// </summary>
public static void Abandon(string auctionId, string reason)
{
if (auctionId == null) return;
if (!Open.TryRemove(auctionId, out var dossier)) return;
dossier.Write(new
{
t = dossier.Elapsed(),
type = "abandoned",
at = Now(),
reason,
note = "asta tolta dal monitor prima della conclusione: dati incompleti"
});
FileLogWriter.FlushAll();
}
/// <summary>Svuota sul disco i dossier aperti. Alla chiusura, o prima di esportare.</summary>
public static void FlushAll() => FileLogWriter.FlushAll();
/// <summary>Quanti dossier sono aperti adesso.</summary>
public static int OpenCount => Open.Count;
private static AuctionDossier Create(AuctionInfo auction, AppSettings settings)
{
AppPaths.EnsureFolders();
var path = ResolvePath(auction);
var isNew = !File.Exists(path);
var dossier = new AuctionDossier(path, DateTime.Now);
if (isNew) dossier.WriteHeader(auction, settings);
else dossier.Write(new { type = "resumed", at = Now(), note = "applicazione riavviata, asta ancora in corso" });
return dossier;
}
/// <summary>
/// Percorso del file. Se ne esiste già uno per quest'asta si riusa quello, comunque
/// si chiami: il nome porta la data, e un'asta cominciata ieri non deve spezzarsi in
/// due file solo perché è mezzanotte.
/// </summary>
private static string ResolvePath(AuctionInfo auction)
{
try
{
var existing = Directory
.GetFiles(AppPaths.AuctionLogFolder, $"*_{auction.AuctionId}.jsonl")
.FirstOrDefault();
if (existing != null) return existing;
}
catch { /* cartella non ancora disponibile: si crea il file nuovo */ }
var slug = Slug(auction.Name);
var name = $"{DateTime.Now:yyyy-MM-dd_HHmm}_{slug}_{auction.AuctionId}.jsonl";
return System.IO.Path.Combine(AppPaths.AuctionLogFolder, name);
}
private void WriteHeader(AuctionInfo auction, AppSettings settings)
{
Write(new
{
type = "header",
schema = "autobidder.auction.v1",
app = AppInfo.Version,
auctionId = auction.AuctionId,
name = auction.Name,
productKey = ProductKeyHelper.GenerateProductKey(auction.Name),
url = auction.OriginalUrl,
openedAt = Now(),
addedAt = auction.AddedAt.ToLocalTime().ToString("o"),
// Valore del prodotto: senza questo, prezzo finale e convenienza non
// significano nulla quando si rileggeranno i dati.
product = new
{
buyNowPrice = auction.BuyNowPrice,
shippingCost = auction.ShippingCost,
bidCostEuro = auction.BidCost,
hasWinLimit = auction.HasWinLimit,
winLimit = auction.WinLimitDescription
},
// Con che regole la si sta seguendo: due aste identiche con anticipi
// diversi non sono confrontabili, e senza questo non si saprebbe.
config = new
{
state = auction.State.ToString(),
bidBeforeDeadlineMs = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs,
minPrice = auction.MinPrice,
maxPrice = auction.MaxPrice,
maxBids = auction.MaxClicks,
minResets = auction.MinResets,
maxResets = auction.MaxResets,
pollCriticalMs = settings.PollIntervalCriticalMs,
criticalWindowMs = settings.CriticalWindowMs,
valueCheckEnabled = settings.ValueCheckEnabled,
minSavingsPercentage = settings.MinSavingsPercentage,
antiBotEnabled = settings.AntiBotDetectionEnabled,
competitionEnabled = settings.CompetitionDetectionEnabled,
rawPollsIncluded = settings.DossierIncludeRawPolls
}
});
}
/// <summary>
/// Chiude il dossier con il riepilogo. Da qui in avanti il file non cresce più:
/// è la riga che dice a chi analizza che la storia è completa.
/// </summary>
public static void Close(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
if (auction == null) return;
if (!Open.TryRemove(auction.AuctionId, out var dossier)) return;
dossier.WriteSummary(auction, detail, finalState);
}
private void WriteSummary(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
lock (_sync)
{
if (_closed) return;
_closed = true;
}
var pings = PingStats();
Write(new
{
type = "summary",
closedAt = Now(),
endedAt = detail.EndedAt.ToLocalTime().ToString("o"),
outcome = detail.Outcome,
wonByMe = detail.WonByMe,
winner = detail.Winner,
finalPrice = detail.FinalPrice,
// Le due bandiere che dicono quanto vale questo dossier per l'analisi.
coverage = new
{
observedFromStart = auction.ObservedFromStart,
observedToEnd = auction.ObservedToEnd,
complete = auction.ObservedFromStart && auction.ObservedToEnd,
firstSeenAt = detail.FirstSeenAt.ToLocalTime().ToString("o"),
observedMinutes = Math.Round(detail.ObservedMinutes, 2)
},
participation = new
{
myBids = detail.MyBids,
totalObservedBids = detail.TotalObservedBids,
distinctBidders = detail.DistinctBidders,
resets = detail.Resets,
topBidderShare = Math.Round(detail.TopBidderShare, 2),
bidsByUser = detail.BidsByUser
},
value = new
{
buyNowPrice = detail.BuyNowPrice,
shippingCost = detail.ShippingCost,
bidCostEuro = auction.BidCost,
totalCostIfWon = Math.Round(detail.TotalCostIfWon(auction.BidCost), 2),
finalPriceRatio = detail.FinalPriceRatio.HasValue
? Math.Round(detail.FinalPriceRatio.Value, 4)
: (double?)null
},
network = new
{
avgPingMs = pings.Avg,
minPingMs = pings.Min,
maxPingMs = pings.Max,
medianPingMs = pings.Median,
p95PingMs = pings.P95,
samples = pings.Count,
polls = _polls,
pollErrors = detail.PollErrors
},
engine = new
{
configuredLeadMs = detail.ConfiguredLeadMs,
timerExpiredCount = auction.TimerExpiredCount,
successfulBids = auction.SuccessfulBidCount,
failedBids = auction.FailedBidCount,
collisions = auction.CollisionCount
},
priceSeries = detail.PriceSeries.Select(p => new { t = Math.Round(p.T, 2), price = p.Price }),
priceVelocityPerMinute = Math.Round(detail.PriceVelocityPerMinute, 4),
finalStatus = finalState?.Status.ToString(),
eventsWritten = _events
}, isSummary: true);
FileLogWriter.FlushAll();
}
// ── Eventi ───────────────────────────────────────────────────────
/// <summary>
/// Una singola interrogazione a Bidoo. È l'evento più frequente — nella finestra
/// critica quattro al secondo — ed è quello che permette di ricostruire a posteriori
/// dove stava davvero il timer nell'istante in cui si è deciso di puntare.
/// </summary>
public void Poll(AuctionState state, bool includeRaw)
{
_polls++;
lock (_sync)
{
if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs);
}
if (!includeRaw) return;
Write(new
{
t = Elapsed(),
type = "poll",
at = Now(),
price = state.Price,
timer = Math.Round(state.Timer, 2),
status = state.Status.ToString(),
lastBidder = string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder,
mine = state.IsMyBid,
pingMs = state.PollingLatencyMs,
expiryUnix = state.ExpiryUnixSeconds,
serverUnix = state.ServerUnixSeconds
});
}
/// <summary>Una puntata altrui, come l'ha riportata Bidoo.</summary>
public void ForeignBid(BidHistoryEntry entry, double price)
{
Write(new
{
t = Elapsed(),
type = "bid",
at = Now(),
user = entry.Username,
// L'ora dichiarata da Bidoo, distinta da quella in cui l'abbiamo saputo:
// fra le due può passare un poll intero, e la differenza conta quando si
// ricostruisce chi ha puntato per primo.
bidAt = entry.Timestamp > 0
? DateTimeOffset.FromUnixTimeSeconds(entry.Timestamp).ToLocalTime().ToString("o")
: null,
bidType = entry.BidType,
mine = entry.IsMyBid,
price
});
}
/// <summary>
/// Una mia puntata, con tutto ciò che serve a giudicarla: quanto prima della
/// scadenza volevo essere, quanto prima ci sono arrivato davvero, e con che ping.
/// È la coppia pianificato/effettivo il dato che permette di tarare l'anticipo.
/// </summary>
public void MyBid(
double price,
int plannedLeadMs,
double actualLeadMs,
int pingMs,
bool success,
string? error,
int? bidsUsed,
int? remainingBids)
{
Write(new
{
t = Elapsed(),
type = "my_bid",
at = Now(),
price,
plannedLeadMs,
actualLeadMs = Math.Round(actualLeadMs, 1),
leadErrorMs = Math.Round(actualLeadMs - plannedLeadMs, 1),
pingMs,
avgPingMs = PingStats().Avg,
result = success ? "ok" : "fail",
error,
bidsUsed,
remainingBids
});
}
/// <summary>Il timer è stato azzerato da una puntata: l'asta continua.</summary>
public void Reset(int resetCount, double price, string? bidder)
{
Write(new
{
t = Elapsed(),
type = "reset",
at = Now(),
resetCount,
price,
bidder
});
}
/// <summary>Una riga del registro dell'asta (strategie, avvisi, errori).</summary>
public void Log(string level, string category, string message)
{
Write(new
{
t = Elapsed(),
type = "log",
at = Now(),
level,
category,
msg = message
});
}
/// <summary>Cambio di modo: Ferma, Osserva, Attiva.</summary>
public void StateChanged(string from, string to)
{
Write(new
{
t = Elapsed(),
type = "state",
at = Now(),
from,
to
});
}
// ── Interno ──────────────────────────────────────────────────────
private void Write(object payload, bool isSummary = false)
{
// Dopo il riepilogo il file è una storia chiusa: una riga in coda al riepilogo
// farebbe dubitare di tutto ciò che c'è sopra.
if (_closed && !isSummary) return;
try
{
_writer.Write(JsonSerializer.Serialize(payload, Json));
_events++;
}
catch { /* una riga persa non vale l'interruzione di un'asta */ }
}
private double Elapsed() => Math.Round((DateTime.Now - _startedAt).TotalSeconds, 3);
private static string Now() => DateTime.Now.ToString("HH:mm:ss.fff");
private (int Count, double Avg, int Min, int Max, int Median, int P95) PingStats()
{
int[] copy;
lock (_sync) copy = _pings.Where(p => p > 0).ToArray();
if (copy.Length == 0) return (0, 0, 0, 0, 0, 0);
Array.Sort(copy);
return (
copy.Length,
Math.Round(copy.Average(), 1),
copy[0],
copy[^1],
copy[copy.Length / 2],
copy[Math.Min(copy.Length - 1, (int)(copy.Length * 0.95))]);
}
/// <summary>Nome file leggibile: niente caratteri vietati, niente nomi chilometrici.</summary>
private static string Slug(string? name)
{
if (string.IsNullOrWhiteSpace(name)) return "asta";
var clean = Regex.Replace(name.Trim(), @"[^\p{L}\p{Nd}]+", "-").Trim('-');
foreach (var invalid in System.IO.Path.GetInvalidFileNameChars())
clean = clean.Replace(invalid, '-');
if (clean.Length == 0) return "asta";
return clean.Length <= 48 ? clean : clean[..48].TrimEnd('-');
}
}
}
-356
View File
@@ -1,356 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
/// <summary>
/// Criteri con cui scegliere le aste da esportare. Tutti facoltativi: quello che resta
/// vuoto non filtra.
/// </summary>
public sealed class AuctionExportFilter
{
public DateTime? From { get; set; }
public DateTime? To { get; set; }
/// <summary>Testo cercato nel nome del prodotto.</summary>
public string? NameContains { get; set; }
/// <summary>Chiave prodotto esatta: seleziona tutte le aste dello stesso articolo.</summary>
public string? ProductKey { get; set; }
public bool IncludeWon { get; set; } = true;
public bool IncludeLost { get; set; } = true;
/// <summary>Aste finite senza che ci puntassi: utili come campione di mercato.</summary>
public bool IncludeClosed { get; set; } = true;
/// <summary>Solo aste su cui ho davvero puntato.</summary>
public bool OnlyWithMyBids { get; set; }
/// <summary>Solo aste seguite dall'inizio alla fine: le più preziose per l'analisi.</summary>
public bool OnlyComplete { get; set; }
/// <summary>Solo aste di cui esiste il dossier con tutti gli eventi.</summary>
public bool OnlyWithDossier { get; set; }
public double? MinFinalPrice { get; set; }
public double? MaxFinalPrice { get; set; }
/// <summary>Includi nel file anche gli eventi del dossier, non solo il riepilogo.</summary>
public bool IncludeEvents { get; set; } = true;
/// <summary>Tetto di aste esportate. 0 = nessuno.</summary>
public int MaxAuctions { get; set; }
}
/// <summary>Esito di un'esportazione.</summary>
public sealed record AuctionExportResult(
bool Success,
string Path,
int AuctionCount,
int WithEvents,
long Bytes,
string Message);
/// <summary>
/// Mette in un unico file le aste che rispondono ai criteri scelti, riepilogo e — dove
/// c'è — tutti gli eventi registrati nel dossier.
///
/// <para>Il formato è JSON pensato per essere <b>letto da un'AI</b>: un blocco
/// <c>meta</c> che spiega cosa sono i campi e con che filtri è stato prodotto, e poi le
/// aste. La spiegazione dentro il file non è ridondanza: un'esportazione viene letta
/// mesi dopo, da qualcuno (o qualcosa) che non ha davanti né questo codice né la memoria
/// di com'era impostata l'applicazione quel giorno.</para>
/// </summary>
public static class AuctionExporter
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
/// <summary>Le aste che passerebbero il filtro, per mostrarne il conto prima di esportare.</summary>
public static List<AuctionDetailRecord> Select(AuctionExportFilter filter)
{
filter ??= new AuctionExportFilter();
var details = MergedRecords();
var dossiers = DossierIndex();
var selected = details.Where(r => Matches(r, filter, dossiers)).ToList();
return filter.MaxAuctions > 0 ? selected.Take(filter.MaxAuctions).ToList() : selected;
}
/// <summary>Scrive il file. Il percorso viene deciso qui se non lo si impone.</summary>
public static AuctionExportResult Export(AuctionExportFilter filter, string? targetPath = null)
{
try
{
// Ciò che è ancora in coda deve finire sul disco prima di rileggerlo:
// esportare un'asta appena conclusa senza le sue ultime righe sarebbe il
// modo più sottile per ritrovarsi con dati incompleti.
FileLogWriter.FlushAll();
filter ??= new AuctionExportFilter();
var selected = Select(filter);
if (selected.Count == 0)
return new AuctionExportResult(false, "", 0, 0, 0, "nessuna asta corrisponde ai filtri");
var dossiers = DossierIndex();
var withEvents = 0;
var auctions = new JsonArray();
foreach (var record in selected)
{
var node = JsonSerializer.SerializeToNode(record, Options)!.AsObject();
node["totalCostIfWon"] = Math.Round(record.TotalCostIfWon(record.BidCostEuro), 2);
if (filter.IncludeEvents &&
dossiers.TryGetValue(record.AuctionId, out var dossierPath))
{
var events = ReadEvents(dossierPath);
if (events != null)
{
node["events"] = events;
withEvents++;
}
}
auctions.Add(node);
}
var document = new JsonObject
{
["meta"] = BuildMeta(filter, selected, withEvents),
["auctions"] = auctions
};
var path = targetPath ?? DefaultPath();
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
File.WriteAllText(path, document.ToJsonString(Options));
var bytes = new FileInfo(path).Length;
return new AuctionExportResult(true, path, selected.Count, withEvents, bytes,
$"{selected.Count} aste esportate ({withEvents} con eventi)");
}
catch (Exception ex)
{
return new AuctionExportResult(false, "", 0, 0, 0, $"esportazione non riuscita: {ex.Message}");
}
}
/// <summary>Percorso predefinito: cartella esportazioni, nome con data e ora.</summary>
public static string DefaultPath() =>
System.IO.Path.Combine(AppPaths.ExportFolder, $"aste-{DateTime.Now:yyyy-MM-dd_HHmm}.json");
/// <summary>Elenco dei prodotti presenti nello storico, per il menu dei filtri.</summary>
public static List<string> KnownProducts() =>
MergedRecords()
.Select(r => r.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(n => n, StringComparer.CurrentCultureIgnoreCase)
.ToList();
// ── Interno ──────────────────────────────────────────────────────
/// <summary>
/// Spiega dentro il file cosa contiene e con che filtri è stato prodotto. È la
/// differenza fra un'esportazione riutilizzabile e un mucchio di numeri.
/// </summary>
private static JsonObject BuildMeta(
AuctionExportFilter filter,
List<AuctionDetailRecord> selected,
int withEvents)
{
var complete = selected.Count(r => r.IsComplete);
return new JsonObject
{
["schema"] = "autobidder.export.v1",
["app"] = AppInfo.Version,
["generatedAt"] = DateTime.Now.ToString("o"),
["auctionCount"] = selected.Count,
["withEvents"] = withEvents,
["completeObservations"] = complete,
["filters"] = new JsonObject
{
["from"] = filter.From?.ToString("yyyy-MM-dd"),
["to"] = filter.To?.ToString("yyyy-MM-dd"),
["nameContains"] = filter.NameContains,
["productKey"] = filter.ProductKey,
["includeWon"] = filter.IncludeWon,
["includeLost"] = filter.IncludeLost,
["includeClosed"] = filter.IncludeClosed,
["onlyWithMyBids"] = filter.OnlyWithMyBids,
["onlyComplete"] = filter.OnlyComplete,
["onlyWithDossier"] = filter.OnlyWithDossier,
["minFinalPrice"] = filter.MinFinalPrice,
["maxFinalPrice"] = filter.MaxFinalPrice
},
["fieldGuide"] = new JsonObject
{
["observedFromStart"] = "il monitor c'era prima che l'asta partisse",
["observedToEnd"] = "la conclusione è stata vista dal vivo",
["isComplete"] = "storia completa: sono le aste su cui l'analisi vale davvero",
["priceSeries"] = "prezzo campionato: t = secondi dal primo avvistamento",
["bidsByUser"] = "puntate osservate per utente",
["topBidderShare"] = "quota percentuale del puntatore più attivo",
["configuredLeadMs"] = "anticipo impostato sulla scadenza",
["averagePingMs"] = "latenza media verso Bidoo su questa asta",
["buyNowPrice"] = "valore del prodotto (Compra Subito) al momento dell'asta",
["totalCostIfWon"] = "prezzo finale + puntate spese + spedizione",
["events[].type"] = "poll = interrogazione, bid = puntata altrui, " +
"my_bid = mia puntata (plannedLeadMs vs actualLeadMs), " +
"reset = timer azzerato, log = registro, summary = riepilogo"
}
};
}
/// <summary>
/// Schede dettagliate, completate con i riepiloghi delle aste che non ne hanno una
/// (quelle concluse prima che il dossier esistesse). Una vista sola su tutto lo
/// storico: filtrare su due archivi diversi darebbe risultati diversi a seconda
/// del filtro, che è il modo più sicuro per non fidarsi più dei propri dati.
/// </summary>
private static List<AuctionDetailRecord> MergedRecords()
{
var byId = new Dictionary<string, AuctionDetailRecord>(StringComparer.OrdinalIgnoreCase);
foreach (var detail in AuctionDetailStore.LoadAll())
byId[detail.AuctionId] = detail;
foreach (var summary in CompletedAuctionsStore.LoadAll())
{
if (byId.ContainsKey(summary.AuctionId)) continue;
byId[summary.AuctionId] = new AuctionDetailRecord
{
AuctionId = summary.AuctionId,
Name = summary.Name,
ProductKey = summary.ProductKey,
Url = summary.OriginalUrl,
Outcome = summary.Outcome,
WonByMe = summary.WonByMe,
Winner = summary.Winner,
FinalPrice = summary.FinalPrice,
BuyNowPrice = summary.BuyNowPrice,
MyBids = summary.MyBidsUsed,
Resets = summary.TotalResets,
FirstSeenAt = summary.AddedAt,
EndedAt = summary.EndedAt,
ObservedMinutes = Math.Max(0, (summary.EndedAt - summary.AddedAt).TotalMinutes)
};
}
return byId.Values.OrderByDescending(r => r.EndedAt).ToList();
}
/// <summary>Dossier presenti sul disco, per id asta.</summary>
private static Dictionary<string, string> DossierIndex()
{
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
if (!Directory.Exists(AppPaths.AuctionLogFolder)) return index;
foreach (var file in Directory.GetFiles(AppPaths.AuctionLogFolder, "*.jsonl"))
{
// Il nome finisce con _<idasta>.jsonl: è il legame con lo storico.
var name = System.IO.Path.GetFileNameWithoutExtension(file);
var separator = name.LastIndexOf('_');
if (separator < 0 || separator == name.Length - 1) continue;
index[name[(separator + 1)..]] = file;
}
}
catch { }
return index;
}
private static JsonArray? ReadEvents(string path)
{
try
{
var events = new JsonArray();
foreach (var line in File.ReadLines(path))
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
var node = JsonNode.Parse(line);
if (node != null) events.Add(node);
}
catch
{
// Una riga illeggibile (interruzione a metà scrittura) non deve far
// perdere l'intero dossier: è il motivo per cui è un record per riga.
}
}
return events.Count > 0 ? events : null;
}
catch
{
return null;
}
}
private static bool Matches(
AuctionDetailRecord record,
AuctionExportFilter filter,
Dictionary<string, string> dossiers)
{
if (filter.From.HasValue && record.EndedAt < filter.From.Value.Date) return false;
if (filter.To.HasValue && record.EndedAt > filter.To.Value.Date.AddDays(1)) return false;
if (!string.IsNullOrWhiteSpace(filter.NameContains) &&
!record.Name.Contains(filter.NameContains.Trim(), StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrWhiteSpace(filter.ProductKey) &&
!string.Equals(record.ProductKey, filter.ProductKey.Trim(), StringComparison.OrdinalIgnoreCase))
return false;
var won = record.WonByMe;
var lost = !record.WonByMe && record.MyBids > 0;
var closed = !record.WonByMe && record.MyBids == 0;
if (won && !filter.IncludeWon) return false;
if (lost && !filter.IncludeLost) return false;
if (closed && !filter.IncludeClosed) return false;
if (filter.OnlyWithMyBids && record.MyBids == 0) return false;
if (filter.OnlyComplete && !record.IsComplete) return false;
if (filter.OnlyWithDossier && !dossiers.ContainsKey(record.AuctionId)) return false;
if (filter.MinFinalPrice.HasValue && record.FinalPrice < filter.MinFinalPrice.Value) return false;
if (filter.MaxFinalPrice.HasValue && record.FinalPrice > filter.MaxFinalPrice.Value) return false;
return true;
}
}
}
+28
View File
@@ -63,5 +63,33 @@ namespace AutoBidder.Utilities
/// </summary>
public static int? TrustedWinnerBids(CompletedAuctionRecord record) =>
CheckWinnerBids(record).IsTrustworthy ? record.WinnerBidsUsed : null;
/// <summary>
/// Quanta parte dell'asta è stata davvero osservata, da 0 a 1. <c>null</c> se non
/// si può dire (prezzo finale mancante).
///
/// <para>Sfrutta la stessa identità del controllo qui sopra: il prezzo finale in
/// centesimi è il numero totale di puntate dell'asta. Confrontarlo con i reset
/// visti dice, senza bisogno di registrare nient'altro, se l'asta è stata seguita
/// dall'inizio o presa per strada. Un'asta agganciata a metà ha numeri veri ma
/// parziali — è il caso peggiore, perché sembra buona.</para>
///
/// <para>Il valore è tagliato a 1: sui dati reali (5876 aste) nessun record supera
/// 1,02, e il poco che sfora è arrotondamento del prezzo, non un'asta osservata
/// più del possibile.</para>
/// </summary>
public static double? ObservedCoverage(double finalPrice, int totalResets)
{
if (finalPrice <= 0) return null;
var totalBids = Math.Round(finalPrice * 100);
if (totalBids <= 0) return null;
return Math.Min(1.0, Math.Max(0, totalResets) / totalBids);
}
/// <summary>Comodità: la copertura di un'asta già registrata.</summary>
public static double? ObservedCoverage(CompletedAuctionRecord record) =>
record == null ? null : ObservedCoverage(record.FinalPrice, record.TotalResets);
}
}
+80
View File
@@ -0,0 +1,80 @@
using System;
namespace AutoBidder.Utilities
{
/// <summary>
/// Riconosce l'avversario che ha l'autopuntata di Bidoo armata, e dice quando smettere.
///
/// <para><b>Il meccanismo.</b> L'autopuntata del sito scatta a 2 secondi dalla chiusura,
/// e si riarma ogni volta che il suo padrone non è più in testa. Il timer si azzera
/// intorno agli 8 secondi, quindi la risposta arriva circa 6 secondi dopo la puntata che
/// l'ha svegliata. Misurato su 2.799.681 intervalli fra puntate consecutive in 5037 aste:
/// mediana <b>6,0 s</b>, primo quartile 5,3 s, terzo quartile 7,1 s.</para>
///
/// <para><b>Perché serve ritirarsi.</b> Contro un'autopuntata armata non esiste attrito
/// che si possa vincere: risponde sempre, risponde in sei secondi, non dorme e non si
/// distrae. Puntare di nuovo significa comprare un altro giro di sei secondi al prezzo
/// di una puntata a testa, all'infinito, finché uno dei due esaurisce il credito. Nelle
/// prove reali sono state 191 puntate su tre aste per zero vittorie, con un ciclo
/// perfettamente regolare da 13,0 secondi: 7 di attesa nostra più 6 della loro risposta.</para>
///
/// <para><b>Perché non basta puntare tardi.</b> Puntare a un secondo dalla chiusura non
/// evita l'autopuntata avversaria: la sveglia. Lei scatta quando il suo padrone perde la
/// testa della classifica, non a un orario fisso, quindi ogni nostra puntata tardiva è
/// esattamente ciò che la fa ripartire.</para>
///
/// <para>Classe pura, senza stato e senza orologio: prende i numeri e restituisce un
/// giudizio. Così la stessa identica logica serve il motore dal vivo e la rigiocata sui
/// dossier, senza due versioni che divergono.</para>
/// </summary>
public static class AutoBidDuel
{
/// <summary>Ritardo tipico della risposta automatica, in secondi.</summary>
public const double RispostaTipicaSecondi = 6.0;
/// <summary>
/// Quanto ci si allontana dai 6 secondi continuando a chiamarla automatica.
/// Coincide grosso modo con l'intervallo fra primo e terzo quartile misurati
/// (5,37,1 s), allargato per la latenza di rete e per l'imprecisione del polling.
/// </summary>
public const double TolleranzaSecondi = 2.5;
/// <summary>
/// Risposte automatiche di fila oltre le quali si smette. Cinque perché è il punto in
/// cui la coincidenza smette di essere credibile: cinque intervalli indipendenti
/// dentro la finestra dei 6±2,5 s non capitano per caso, e cinque puntate sono un
/// prezzo che si può pagare per avere la certezza.
/// </summary>
public const int RispostePerConcludere = 5;
/// <summary>
/// Questo intervallo ha la firma della risposta automatica?
/// </summary>
public static bool ERispostaAutomatica(double secondiDopoLaMiaPuntata) =>
secondiDopoLaMiaPuntata > 0 &&
Math.Abs(secondiDopoLaMiaPuntata - RispostaTipicaSecondi) <= TolleranzaSecondi;
/// <summary>
/// Il conteggio aggiornato dopo aver visto una risposta avversaria.
///
/// <para>Una risposta che <b>non</b> ha la firma azzera il conteggio invece di
/// scalarlo: serve una sequenza pulita per concludere che c'è una macchina
/// dall'altra parte, e un solo intervallo umano in mezzo basta a rimettere tutto in
/// discussione. È la scelta prudente, perché l'errore costoso è ritirarsi da
/// un'asta che si poteva vincere.</para>
/// </summary>
public static int Aggiorna(int consecutiveFinora, double secondiDopoLaMiaPuntata) =>
ERispostaAutomatica(secondiDopoLaMiaPuntata) ? consecutiveFinora + 1 : 0;
/// <summary>
/// Si è capito che dall'altra parte c'è un'autopuntata armata?
/// </summary>
public static bool DuelloRiconosciuto(int consecutive, int soglia = RispostePerConcludere) =>
consecutive >= Math.Max(1, soglia);
/// <summary>Come spiegarlo nel registro, in una riga.</summary>
public static string Spiegazione(int consecutive) =>
$"autopuntata avversaria riconosciuta: {consecutive} risposte di fila a ~{RispostaTipicaSecondi:N0}s. " +
"Continuare significa scambiare puntate una a una con una macchina che non si stanca.";
}
}
-247
View File
@@ -1,247 +0,0 @@
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.");
}
}
}

Some files were not shown because too many files have changed in this diff Show More