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>
This commit is contained in:
Vendored
+18
@@ -78,6 +78,24 @@
|
|||||||
},
|
},
|
||||||
"problemMatcher": []
|
"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": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "crea installatore",
|
"label": "crea installatore",
|
||||||
"detail": "Verifica, pubblica ed esegue Inno Setup: bin/installer/AutoBidder-<versione>-setup.exe. Crea il tag a pacchetto pronto. Non tocca Gitea.",
|
"detail": "Verifica, pubblica ed esegue Inno Setup: bin/installer/AutoBidder-<versione>-setup.exe. Crea il tag a pacchetto pronto. Non tocca Gitea.",
|
||||||
|
|||||||
+5
-6
@@ -26,11 +26,6 @@ namespace AutoBidder
|
|||||||
AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder);
|
AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder);
|
||||||
AppPaths.EnsureFolders();
|
AppPaths.EnsureFolders();
|
||||||
|
|
||||||
// La modalità della sessione si fissa qui e non cambia più fino alla chiusura:
|
|
||||||
// chi salva le impostazioni a metà sessione vede l'avviso di riavvio, non un
|
|
||||||
// motore che cambia comportamento sotto i piedi.
|
|
||||||
RuntimeProfile.Initialize(settings.SessionMode);
|
|
||||||
|
|
||||||
// Le versioni precedenti scrivevano parte dei dati in Roaming e parte in Local,
|
// Le versioni precedenti scrivevano parte dei dati in Roaming e parte in Local,
|
||||||
// senza sottocartelle: senza questo passaggio storico e aste sparirebbero.
|
// senza sottocartelle: senza questo passaggio storico e aste sparirebbero.
|
||||||
AppPaths.MigrateLegacyFiles();
|
AppPaths.MigrateLegacyFiles();
|
||||||
@@ -39,7 +34,6 @@ namespace AutoBidder
|
|||||||
// proprio ciò che non si riesce a leggere a video.
|
// proprio ciò che non si riesce a leggere a video.
|
||||||
TextLogService.SessionStarted(AppInfo.Version);
|
TextLogService.SessionStarted(AppInfo.Version);
|
||||||
TextLogService.PurgeOldLogs();
|
TextLogService.PurgeOldLogs();
|
||||||
TextLogService.App("INFO", $"[MODALITÀ] {RuntimeProfile.Describe()}");
|
|
||||||
TextLogService.App("INFO", $"[PERCORSI] configurazione in {AppPaths.ConfigRoot}, dati in {AppPaths.DataFolder}");
|
TextLogService.App("INFO", $"[PERCORSI] configurazione in {AppPaths.ConfigRoot}, dati in {AppPaths.DataFolder}");
|
||||||
|
|
||||||
// Le righe di registro delle aste confluiscono nei rispettivi dossier.
|
// Le righe di registro delle aste confluiscono nei rispettivi dossier.
|
||||||
@@ -64,6 +58,11 @@ namespace AutoBidder
|
|||||||
TextLogService.App("WARN", $"[STORICO] Riparazione non riuscita: {ex.Message}");
|
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
|
// Applica il tema salvato (chiaro/scuro) prima di mostrare la finestra
|
||||||
ThemeManager.ApplyFromSettings();
|
ThemeManager.ApplyFromSettings();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,32 +44,6 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- SEZIONE 0-ter: Modalità della sessione -->
|
|
||||||
<Border Style="{StaticResource CardBlock}">
|
|
||||||
<StackPanel>
|
|
||||||
<ctl:HelpHeader Title="Modalità della sessione"
|
|
||||||
Margin="0,0,0,14"
|
|
||||||
Help="Addestramento registra tutto: ogni interrogazione nel dossier, ogni decisione della strategia, il dettaglio esteso delle aste concluse. È la modalità con cui si raccolgono i dati per tarare le regole. Gara fa solo il lavoro che serve a puntare: niente poll grezzi nel dossier, niente righe informative nel registro dell'asta, niente dettaglio esteso. Restano puntate, esiti, errori e lo storico delle aste concluse. Cosa aspettarsi, misurato: il lavoro che Gara evita costa 4,9 microsecondi per interrogazione — con 57 aste a 17 poll al secondo è mezzo punto percentuale di un core. Il guadagno vero è sul disco e sulla leggibilità dei dossier, non sulla velocità. La modalità vale per tutta la sessione: cambiarla richiede il riavvio."/>
|
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<RadioButton x:Name="ModeTrainingRadio" Content="Addestramento"
|
|
||||||
GroupName="SessionMode" IsChecked="True"
|
|
||||||
Checked="SessionModeRadio_Checked"
|
|
||||||
ToolTip="Registrazione completa: dossier con ogni poll, registro della strategia, dettaglio esteso."/>
|
|
||||||
<RadioButton x:Name="ModeRaceRadio" Content="Gara" Margin="16,0,0,0"
|
|
||||||
GroupName="SessionMode"
|
|
||||||
Checked="SessionModeRadio_Checked"
|
|
||||||
ToolTip="Solo ciò che serve a puntare. Restano puntate, esiti, errori e lo storico delle aste concluse."/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<TextBlock x:Name="ModeCurrentText" Margin="0,8,0,0"
|
|
||||||
Foreground="{DynamicResource Brush.TextMuted}" FontSize="11.5"/>
|
|
||||||
<TextBlock x:Name="ModeRestartNotice" Margin="0,6,0,0" Visibility="Collapsed"
|
|
||||||
Foreground="{DynamicResource Brush.Warning}" TextWrapping="Wrap"
|
|
||||||
Text="La modalità scelta si applica al prossimo avvio: salva e riavvia l'applicazione."/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- SEZIONE 0-quater: Fascia oraria e pulizia dell'elenco -->
|
<!-- SEZIONE 0-quater: Fascia oraria e pulizia dell'elenco -->
|
||||||
<Border Style="{StaticResource CardBlock}">
|
<Border Style="{StaticResource CardBlock}">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|||||||
@@ -48,30 +48,6 @@ namespace AutoBidder.Controls
|
|||||||
Utilities.ThemeManager.SetAndSave(false);
|
Utilities.ThemeManager.SetAndSave(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Modalità della sessione ──────────────────────────────────────
|
|
||||||
|
|
||||||
private void SessionModeRadio_Checked(object sender, RoutedEventArgs e) =>
|
|
||||||
RefreshModeNotice(Utilities.RuntimeProfile.Mode);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mostra che modalità è in vigore adesso, e l'avviso di riavvio solo se la scelta
|
|
||||||
/// nella scheda differisce da quella. Un avviso sempre acceso non lo legge nessuno.
|
|
||||||
/// </summary>
|
|
||||||
public void RefreshModeNotice(string modalitaInVigore)
|
|
||||||
{
|
|
||||||
if (ModeRestartNotice == null || ModeCurrentText == null) return;
|
|
||||||
|
|
||||||
var scelta = ModeRaceRadio?.IsChecked == true
|
|
||||||
? Utilities.RuntimeProfile.Gara
|
|
||||||
: Utilities.RuntimeProfile.Addestramento;
|
|
||||||
|
|
||||||
ModeCurrentText.Text = $"In vigore adesso: {modalitaInVigore}";
|
|
||||||
ModeRestartNotice.Visibility =
|
|
||||||
string.Equals(scelta, modalitaInVigore, System.StringComparison.OrdinalIgnoreCase)
|
|
||||||
? Visibility.Collapsed
|
|
||||||
: Visibility.Visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non servono propriet� wrapper - MainWindow.xaml.cs accede direttamente ai controlli tramite:
|
// Non servono propriet� wrapper - MainWindow.xaml.cs accede direttamente ai controlli tramite:
|
||||||
// Settings.DefaultBidBeforeDeadlineMsTextBox (definito nel XAML con x:Name)
|
// Settings.DefaultBidBeforeDeadlineMsTextBox (definito nel XAML con x:Name)
|
||||||
// Settings.MaxLogLinesPerAuctionTextBox (definito nel XAML con x:Name)
|
// Settings.MaxLogLinesPerAuctionTextBox (definito nel XAML con x:Name)
|
||||||
|
|||||||
@@ -107,14 +107,6 @@ namespace AutoBidder
|
|||||||
Settings.DetailedStatsCheckBox.IsChecked = settings.DetailedStatsEnabled;
|
Settings.DetailedStatsCheckBox.IsChecked = settings.DetailedStatsEnabled;
|
||||||
Settings.CatalogCacheSecondsTextBox.Text = settings.CatalogCacheSeconds.ToString();
|
Settings.CatalogCacheSecondsTextBox.Text = settings.CatalogCacheSeconds.ToString();
|
||||||
|
|
||||||
// Modalità: si mostra quella salvata; l'avviso compare se differisce da
|
|
||||||
// quella in vigore, che è stata fissata all'avvio e non cambia fino al riavvio.
|
|
||||||
if (string.Equals(settings.SessionMode, RuntimeProfile.Gara, StringComparison.OrdinalIgnoreCase))
|
|
||||||
Settings.ModeRaceRadio.IsChecked = true;
|
|
||||||
else
|
|
||||||
Settings.ModeTrainingRadio.IsChecked = true;
|
|
||||||
Settings.RefreshModeNotice(RuntimeProfile.Mode);
|
|
||||||
|
|
||||||
Settings.QuietHoursCheckBox.IsChecked = settings.QuietHoursEnabled;
|
Settings.QuietHoursCheckBox.IsChecked = settings.QuietHoursEnabled;
|
||||||
Settings.QuietHoursStartTextBox.Text = settings.QuietHoursStart.ToString();
|
Settings.QuietHoursStartTextBox.Text = settings.QuietHoursStart.ToString();
|
||||||
Settings.QuietHoursEndTextBox.Text = settings.QuietHoursEnd.ToString();
|
Settings.QuietHoursEndTextBox.Text = settings.QuietHoursEnd.ToString();
|
||||||
@@ -430,11 +422,6 @@ namespace AutoBidder
|
|||||||
|
|
||||||
settings.DetailedStatsEnabled = Settings.DetailedStatsCheckBox.IsChecked ?? true;
|
settings.DetailedStatsEnabled = Settings.DetailedStatsCheckBox.IsChecked ?? true;
|
||||||
|
|
||||||
settings.SessionMode = Settings.ModeRaceRadio.IsChecked == true
|
|
||||||
? RuntimeProfile.Gara
|
|
||||||
: RuntimeProfile.Addestramento;
|
|
||||||
Settings.RefreshModeNotice(RuntimeProfile.Mode);
|
|
||||||
|
|
||||||
settings.QuietHoursEnabled = Settings.QuietHoursCheckBox.IsChecked ?? true;
|
settings.QuietHoursEnabled = Settings.QuietHoursCheckBox.IsChecked ?? true;
|
||||||
if (int.TryParse(Settings.QuietHoursStartTextBox.Text?.Trim(), out var qStart) && qStart is >= 0 and <= 23)
|
if (int.TryParse(Settings.QuietHoursStartTextBox.Text?.Trim(), out var qStart) && qStart is >= 0 and <= 23)
|
||||||
settings.QuietHoursStart = qStart;
|
settings.QuietHoursStart = qStart;
|
||||||
|
|||||||
@@ -70,18 +70,24 @@ namespace AutoBidder
|
|||||||
|
|
||||||
var detail = BuildDetail(auction, state, record);
|
var detail = BuildDetail(auction, state, record);
|
||||||
|
|
||||||
// In Gara il dettaglio esteso non si scrive: vedi RuntimeProfile.
|
if (settings.DetailedStatsEnabled)
|
||||||
if (settings.DetailedStatsEnabled && RuntimeProfile.DetailedStats)
|
|
||||||
{
|
{
|
||||||
AuctionDetailStore.Append(detail);
|
AuctionDetailStore.Append(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Il dossier si chiude con il riepilogo: da lì in poi quel file è una
|
// 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.
|
// storia completa, ed è così che l'analisi sa di poterlo usare. Il percorso
|
||||||
|
// si prende prima, perché Close lo toglie dal registro dei dossier aperti.
|
||||||
|
var dossierPath = AuctionDossier.For(auction.AuctionId)?.Path;
|
||||||
AuctionDossier.Close(auction, detail, state);
|
AuctionDossier.Close(auction, detail, state);
|
||||||
|
|
||||||
ProductStatsStore.RecordCompleted(record, detail);
|
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, dossierPath));
|
||||||
|
|
||||||
NotifyOutcome(record, settings);
|
NotifyOutcome(record, settings);
|
||||||
|
|
||||||
Dispatcher.BeginInvoke(() =>
|
Dispatcher.BeginInvoke(() =>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AutoBidder.Ml
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Legge da un dossier solo ciò che serve ad addestrare: intestazione, puntate,
|
||||||
|
/// riepilogo. Le interrogazioni — il 90% delle righe e quasi tutti i byte — si saltano
|
||||||
|
/// con un confronto di testo prima ancora di interpretare il JSON.
|
||||||
|
///
|
||||||
|
/// <para>È il motivo per cui il primo addestramento su 8,8 GB di dossier è fattibile in
|
||||||
|
/// sottofondo: per ogni file si interpreta l'uno per cento delle righe.</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'intestazione (dossier recenti) 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 è, il
|
||||||
|
/// dossier 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="me">
|
||||||
|
/// Il nostro nome utente, per i dossier scritti prima che l'intestazione lo portasse.
|
||||||
|
/// Le puntate di quell'utente valgono come nostre anche se il file non le marca.
|
||||||
|
/// </param>
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// Scarto veloce: la stragrande maggioranza delle righe è un poll.
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// In ordine di tempo dichiarato dal server, poi di prezzo: è l'ordine vero
|
||||||
|
// delle puntate, non quello in cui le abbiamo sapute.
|
||||||
|
bids.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
var c = a.UnixSeconds.CompareTo(b.UnixSeconds);
|
||||||
|
return c != 0 ? c : a.Price.CompareTo(b.Price);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le puntate nostre: quelle marcate, oppure quelle del nostro nome utente. I
|
||||||
|
// dossier scritti finora non marcavano niente — la bandiera veniva copiata da
|
||||||
|
// un campo che nessuno impostava — quindi il nome è l'unica via per il passato.
|
||||||
|
if (chiSonoIo.Length > 0)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < bids.Count; i++)
|
||||||
|
{
|
||||||
|
if (!bids[i].Mine && string.Equals(bids[i].User, chiSonoIo, StringComparison.OrdinalIgnoreCase))
|
||||||
|
bids[i] = bids[i] with { Mine = true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Session
|
||||||
|
{
|
||||||
|
AuctionId = auctionId,
|
||||||
|
ProductKey = productKey,
|
||||||
|
BuyNowPrice = buyNow,
|
||||||
|
AddedAtLocal = addedAt,
|
||||||
|
Bids = bids,
|
||||||
|
Winner = winner,
|
||||||
|
FinalPrice = finalPrice,
|
||||||
|
HasSummary = hasSummary,
|
||||||
|
Me = chiSonoIo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gli esempi di addestramento di un dossier: 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. Includere questa stessa puntata sarebbe dare al
|
||||||
|
// modello un pezzo di futuro.
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
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> Dai dossier. Ogni puntata di ogni asta chiusa è un
|
||||||
|
/// esempio: rimasta senza risposta oppure no. Non dalle nostre vittorie, che sono
|
||||||
|
/// diciannove in duemila aste: un modello addestrato su quelle non sarebbe un modello.
|
||||||
|
/// Il comportamento avversario non dipende da chi ha puntato, e questo rende
|
||||||
|
/// utilizzabili i milioni di puntate altrui.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Il primo addestramento</b> è incrementale e in sottofondo: i dossier sono
|
||||||
|
/// migliaia e pesano gigabyte, e si leggono un po' per avvio, dal più recente, con un
|
||||||
|
/// tempo massimo per sessione. Quello che è già stato letto non si rilegge: un elenco
|
||||||
|
/// dei file appresi lo tiene a mente. Il profilo delle aste invece parte subito dallo
|
||||||
|
/// storico compatto, che è piccolo e ha le puntate del vincitore già verificate.</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. Il modello dà la stima migliore possibile di
|
||||||
|
/// quando gli altri sono esausti, ed è quanto si può chiedere ai dati.</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, e senza questo
|
||||||
|
// scrivevano lo stesso file nello stesso istante — "file in uso da un altro
|
||||||
|
// processo", che in realtà eravamo noi.
|
||||||
|
private static readonly object SaveSync = new();
|
||||||
|
|
||||||
|
private static OnlineLogit _model = new(BidFeatures.Size);
|
||||||
|
private static AuctionProfileStats _profile = new();
|
||||||
|
private static readonly HashSet<string> _ingested = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private static int _auctionsLearned;
|
||||||
|
private static bool _loaded;
|
||||||
|
private static volatile bool _bootstrapRunning;
|
||||||
|
|
||||||
|
/// <summary>Righe di registro sull'apprendimento: chi si aggancia le scrive dove vuole.</summary>
|
||||||
|
public static event Action<string>? OnLog;
|
||||||
|
|
||||||
|
// ── Percorsi ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string Folder => Path.Combine(AppPaths.StatsFolder, "Apprendimento");
|
||||||
|
private static string ModelFile => Path.Combine(Folder, "modello-puntate.json");
|
||||||
|
private static string ProfileFile => Path.Combine(Folder, "profilo-aste.json");
|
||||||
|
private static string ManifestFile => Path.Combine(Folder, "dossier-appresi.txt");
|
||||||
|
|
||||||
|
// ── 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>
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (File.Exists(ModelFile))
|
||||||
|
_model = OnlineLogit.FromJson(File.ReadAllText(ModelFile), BidFeatures.Size);
|
||||||
|
if (File.Exists(ProfileFile))
|
||||||
|
_profile = AuctionProfileStats.FromJson(File.ReadAllText(ProfileFile));
|
||||||
|
if (File.Exists(ManifestFile))
|
||||||
|
{
|
||||||
|
foreach (var line in File.ReadLines(ManifestFile))
|
||||||
|
{
|
||||||
|
var l = line.Trim();
|
||||||
|
if (l.Length == 0) continue;
|
||||||
|
if (l.StartsWith("#", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (l.StartsWith("#appresi=", StringComparison.Ordinal) &&
|
||||||
|
int.TryParse(l["#appresi=".Length..], out var n)) _auctionsLearned = n;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ingested.Add(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
OnLog?.Invoke($"[APPRENDIMENTO] Archivio illeggibile, si riparte da zero: {ex.Message}");
|
||||||
|
_model = new OnlineLogit(BidFeatures.Size);
|
||||||
|
_profile = new AuctionProfileStats();
|
||||||
|
_ingested.Clear();
|
||||||
|
_auctionsLearned = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OnLog?.Invoke($"[APPRENDIMENTO] Modello: {ModelUpdates:N0} aggiornamenti da {AuctionsLearned:N0} aste; " +
|
||||||
|
$"profilo: {ProfileAuctions:N0} aste; dossier già letti: {_ingested.Count:N0}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Save()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string model, profile;
|
||||||
|
lock (Sync)
|
||||||
|
{
|
||||||
|
model = _model.ToJson();
|
||||||
|
profile = _profile.ToJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (SaveSync)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Folder);
|
||||||
|
File.WriteAllText(ModelFile, model);
|
||||||
|
File.WriteAllText(ProfileFile, profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
OnLog?.Invoke($"[APPRENDIMENTO] Salvataggio non riuscito: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SaveManifest()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string[] lines;
|
||||||
|
lock (Sync)
|
||||||
|
{
|
||||||
|
lines = new[] { "#appresi=" + _auctionsLearned }
|
||||||
|
.Concat(_ingested.OrderBy(x => x, StringComparer.Ordinal))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (SaveSync)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Folder);
|
||||||
|
File.WriteAllLines(ManifestFile, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
OnLog?.Invoke($"[APPRENDIMENTO] Elenco dei dossier letti non salvato: {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,
|
||||||
|
/// perché lo storico è la stessa fonte che poi lo alimenta asta per asta.
|
||||||
|
/// </summary>
|
||||||
|
private static void BootstrapProfileFromHistory()
|
||||||
|
{
|
||||||
|
lock (Sync) { if (_profile.AuctionsObserved > 0) return; }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var records = CompletedAuctionsStore.LoadAll();
|
||||||
|
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>
|
||||||
|
/// Legge i dossier non ancora appresi, dal più recente, finché c'è tempo. Il tempo è
|
||||||
|
/// per avvio: la prima volta ne servono diversi, poi resta solo l'asta appena chiusa.
|
||||||
|
/// </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
|
||||||
|
|
||||||
|
var folder = AppPaths.AuctionLogFolder;
|
||||||
|
if (!Directory.Exists(folder)) return;
|
||||||
|
|
||||||
|
string[] pending;
|
||||||
|
lock (Sync)
|
||||||
|
{
|
||||||
|
pending = Directory.GetFiles(folder, "*.jsonl")
|
||||||
|
.Where(f => !_ingested.Contains(Path.GetFileName(f)))
|
||||||
|
.OrderByDescending(Path.GetFileName, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.Length == 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.Length:N0} dossier da leggere, al massimo {budget.TotalSeconds:N0} s per questo avvio");
|
||||||
|
|
||||||
|
foreach (var file in pending)
|
||||||
|
{
|
||||||
|
if (sw.Elapsed > budget) break;
|
||||||
|
|
||||||
|
if (IngestFile(file)) usable++;
|
||||||
|
done++;
|
||||||
|
|
||||||
|
if (done % 100 == 0)
|
||||||
|
{
|
||||||
|
Save();
|
||||||
|
SaveManifest();
|
||||||
|
OnLog?.Invoke($"[APPRENDIMENTO] {done:N0} dossier letti, {usable:N0} utilizzabili, {ModelUpdates:N0} aggiornamenti");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un respiro ogni file: il motore delle aste ha la precedenza.
|
||||||
|
await Task.Yield();
|
||||||
|
}
|
||||||
|
|
||||||
|
Save();
|
||||||
|
SaveManifest();
|
||||||
|
|
||||||
|
var remaining = pending.Length - done;
|
||||||
|
OnLog?.Invoke($"[APPRENDIMENTO] Letti {done:N0} dossier ({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 dei dossier interrotto: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_bootstrapRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Impara da un dossier. Restituisce true se era utilizzabile. Un file letto si
|
||||||
|
/// segna come letto anche se non era utilizzabile: rileggerlo non lo renderebbe tale.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IngestFile(string path)
|
||||||
|
{
|
||||||
|
var name = Path.GetFileName(path);
|
||||||
|
var session = DossierTrainingReader.ReadFile(path);
|
||||||
|
|
||||||
|
lock (Sync)
|
||||||
|
{
|
||||||
|
_ingested.Add(name);
|
||||||
|
if (session == null || !session.IsUsable) return false;
|
||||||
|
|
||||||
|
foreach (var (ctx, unanswered, _) in DossierTrainingReader.Examples(session))
|
||||||
|
_model.Update(BidFeatures.Build(ctx), unanswered);
|
||||||
|
|
||||||
|
_auctionsLearned++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chiusura di un'asta: si impara subito ────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Da chiamare quando un'asta è chiusa e il suo dossier è stato scritto. Aggiorna
|
||||||
|
/// il profilo con lo storico appena registrato e il modello con le puntate del
|
||||||
|
/// dossier, poi salva: la prossima decisione le vede già.
|
||||||
|
/// </summary>
|
||||||
|
public static void OnAuctionClosed(CompletedAuctionRecord record, string? dossierPath)
|
||||||
|
{
|
||||||
|
if (record == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (Sync)
|
||||||
|
{
|
||||||
|
_profile.Observe(record.ProductKey, record.EndedAt.ToLocalTime(),
|
||||||
|
AuctionIntegrity.TrustedWinnerBids(record), Ratio(record.FinalPrice, record.BuyNowPrice));
|
||||||
|
}
|
||||||
|
|
||||||
|
var learned = false;
|
||||||
|
if (!string.IsNullOrWhiteSpace(dossierPath) && File.Exists(dossierPath))
|
||||||
|
{
|
||||||
|
// Il dossier è appena stato svuotato su disco da Close: si può leggere.
|
||||||
|
learned = IngestFile(dossierPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
Save();
|
||||||
|
SaveManifest();
|
||||||
|
|
||||||
|
OnLog?.Invoke(learned
|
||||||
|
? $"[APPRENDIMENTO] '{record.Name}': appresa ({ModelUpdates:N0} aggiornamenti, {AuctionsLearned:N0} aste)"
|
||||||
|
: $"[APPRENDIMENTO] '{record.Name}': profilo aggiornato, dossier non utilizzabile 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)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Quota di autopuntate: solo sui tipi dichiarati dal server.
|
||||||
|
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; // tipo ignoto: non si conta
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctx = 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));
|
||||||
|
|
||||||
|
var x = BidFeatures.Build(ctx);
|
||||||
|
lock (Sync) return _model.PredictCalibrated(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
_profile = new AuctionProfileStats();
|
||||||
|
_ingested.Clear();
|
||||||
|
_auctionsLearned = 0;
|
||||||
|
_loaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -584,6 +584,18 @@ namespace AutoBidder.Models
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public bool AutoBidDuelDetected { get; set; }
|
public bool AutoBidDuelDetected { get; set; }
|
||||||
|
|
||||||
|
// ── 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>Probabilità che una puntata fatta adesso resti senza risposta.</summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public double? LearnedUnansweredProbability { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Valore atteso in euro di una puntata fatta adesso: sotto zero si perde.</summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public double? LearnedExpectedValue { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registra la risposta avversaria arrivata adesso e aggiorna il conteggio.
|
/// Registra la risposta avversaria arrivata adesso e aggiorna il conteggio.
|
||||||
/// Restituisce true se proprio con questa risposta il duello viene riconosciuto,
|
/// Restituisce true se proprio con questa risposta il duello viene riconosciuto,
|
||||||
|
|||||||
@@ -17,3 +17,5 @@ Nota:
|
|||||||
- In alternativa al metodo descritto prima possiamo definire delle sessioni "di addestramento" e delle sessioni "reali" in cui la prima si concentra su log e raccolta dati mentre la seconda di concentra su velocità ed esecuzione portando al minimo raccolta dati ed esportazione
|
- In alternativa al metodo descritto prima possiamo definire delle sessioni "di addestramento" e delle sessioni "reali" in cui la prima si concentra su log e raccolta dati mentre la seconda di concentra su velocità ed esecuzione portando al minimo raccolta dati ed esportazione
|
||||||
|
|
||||||
Storico aste per analisi: C:\Users\alber\Downloads\AutoBidder\storico-aste-20260907-163509.json
|
Storico aste per analisi: C:\Users\alber\Downloads\AutoBidder\storico-aste-20260907-163509.json
|
||||||
|
|
||||||
|
Fai domande se non sei sicuro di come procedere oppure se non sono stato abbastanza chiaro.
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ namespace AutoBidder.Services
|
|||||||
// Il dossier registra tutto ciò che passa di qui: è materiale che esiste
|
// Il dossier registra tutto ciò che passa di qui: è materiale che esiste
|
||||||
// solo adesso.
|
// solo adesso.
|
||||||
var pollSettings = Settings;
|
var pollSettings = Settings;
|
||||||
AuctionDossier.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls && RuntimeProfile.RawPolls);
|
AuctionDossier.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls);
|
||||||
|
|
||||||
if (state.RecentBidsHistory != null && state.RecentBidsHistory.Count > 0)
|
if (state.RecentBidsHistory != null && state.RecentBidsHistory.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -870,7 +870,7 @@ namespace AutoBidder.Services
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Convenienza OK (check={settings.ValueCheckEnabled}, buyNow={auction.BuyNowPrice?.ToString("F2") ?? "N/D"})",
|
auction.AddLog($"✓ Convenienza OK (check={settings.ValueCheckEnabled}, buyNow={auction.BuyNowPrice?.ToString("F2") ?? "N/D"})",
|
||||||
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Value);
|
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -925,7 +925,7 @@ namespace AutoBidder.Services
|
|||||||
}
|
}
|
||||||
else if (session != null)
|
else if (session != null)
|
||||||
{
|
{
|
||||||
if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Puntate residue OK ({session.RemainingBids} > {settings.MinimumRemainingBids})",
|
auction.AddLog($"✓ Puntate residue OK ({session.RemainingBids} > {settings.MinimumRemainingBids})",
|
||||||
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Limit);
|
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -933,7 +933,7 @@ namespace AutoBidder.Services
|
|||||||
// CONTROLLO 2: Non puntare se sono già il vincitore corrente
|
// CONTROLLO 2: Non puntare se sono già il vincitore corrente
|
||||||
if (state.IsMyBid)
|
if (state.IsMyBid)
|
||||||
{
|
{
|
||||||
if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Sono già vincitore corrente - skip",
|
auction.AddLog($"✓ Sono già vincitore corrente - skip",
|
||||||
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.BidAttempt);
|
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.BidAttempt);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -954,7 +954,7 @@ namespace AutoBidder.Services
|
|||||||
|
|
||||||
if (maxBids > 0)
|
if (maxBids > 0)
|
||||||
{
|
{
|
||||||
if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Puntate OK ({usedBids}/{maxBids})",
|
auction.AddLog($"✓ Puntate OK ({usedBids}/{maxBids})",
|
||||||
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Limit);
|
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -976,7 +976,7 @@ namespace AutoBidder.Services
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Prezzo OK (€{state.Price:F2} in [{auction.MinPrice:F2}, {(auction.MaxPrice > 0 ? auction.MaxPrice.ToString("F2") : "∞")}])",
|
auction.AddLog($"✓ Prezzo OK (€{state.Price:F2} in [{auction.MinPrice:F2}, {(auction.MaxPrice > 0 ? auction.MaxPrice.ToString("F2") : "∞")}])",
|
||||||
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Price);
|
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Price);
|
||||||
|
|
||||||
|
|
||||||
@@ -1020,7 +1020,7 @@ namespace AutoBidder.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RuntimeProfile.VerboseAuctionLog) auction.AddLog($"✓ Tutti i controlli ShouldBid superati",
|
auction.AddLog($"✓ Tutti i controlli ShouldBid superati",
|
||||||
Models.AuctionLogLevel.Info, Models.AuctionLogCategory.BidAttempt);
|
Models.AuctionLogLevel.Info, Models.AuctionLogCategory.BidAttempt);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -1131,9 +1131,21 @@ namespace AutoBidder.Services
|
|||||||
var added = 0;
|
var added = 0;
|
||||||
var dossier = AuctionDossier.For(auction.AuctionId);
|
var dossier = AuctionDossier.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)) AuctionDossier.CurrentUsername = me;
|
||||||
|
|
||||||
foreach (var bid in newBids)
|
foreach (var bid in newBids)
|
||||||
{
|
{
|
||||||
if (!existing.Add($"{bid.Timestamp}_{bid.Username}_{bid.Price:F2}")) continue;
|
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);
|
bids.Add(bid);
|
||||||
added++;
|
added++;
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,40 @@ namespace AutoBidder.Services
|
|||||||
return decision;
|
return decision;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 0-ter. VALORE ATTESO APPRESO.
|
||||||
|
//
|
||||||
|
// Il modello dice con che probabilità una puntata fatta adesso resterebbe
|
||||||
|
// senza risposta; il valore atteso è quella probabilità per il margine che
|
||||||
|
// resta (valore meno prezzo meno il centesimo), meno il costo della puntata.
|
||||||
|
// Sotto zero, in media, puntare perde soldi.
|
||||||
|
//
|
||||||
|
// Il modello parla sempre, ma decide solo quando ha appreso abbastanza aste:
|
||||||
|
// un modello appena nato direbbe cose a caso, e a caso fermerebbe le puntate.
|
||||||
|
// La probabilità e il valore atteso finiscono comunque sull'asta, per il
|
||||||
|
// registro e per l'interfaccia.
|
||||||
|
if (settings.LearningGateEnabled && auction.BuyNowPrice is > 0)
|
||||||
|
{
|
||||||
|
var prob = Ml.LearningService.PredictUnanswered(auction, state, DateTime.Now, currentUsername);
|
||||||
|
if (prob is { } p)
|
||||||
|
{
|
||||||
|
var costo = settings.AverageBidCostEuro * Math.Max(0.1, settings.LearningEvMultiplier);
|
||||||
|
var margine = auction.BuyNowPrice.Value - state.Price - 0.01;
|
||||||
|
var ev = p * margine - costo;
|
||||||
|
|
||||||
|
auction.LearnedUnansweredProbability = p;
|
||||||
|
auction.LearnedExpectedValue = ev;
|
||||||
|
|
||||||
|
if (Ml.LearningService.IsReady(settings) && ev < 0)
|
||||||
|
{
|
||||||
|
decision.ShouldBid = false;
|
||||||
|
decision.Reason =
|
||||||
|
$"valore atteso negativo: P(senza risposta) {p:P2} × margine {margine:F2} € " +
|
||||||
|
$"− puntata {costo:F2} € = {ev:+0.000;-0.000} €";
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ? RIMOSSO: Entry Point - Era sbagliato!
|
// ? RIMOSSO: Entry Point - Era sbagliato!
|
||||||
// I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
|
// I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
|
||||||
// Se l'utente imposta MaxPrice=2�, vuole puntare FINO A 2�, non fino al 70%!
|
// Se l'utente imposta MaxPrice=2�, vuole puntare FINO A 2�, non fino al 70%!
|
||||||
|
|||||||
@@ -85,6 +85,76 @@ public class AuctionDossierTests
|
|||||||
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Coalescenza dei poll ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Con ~17 interrogazioni al secondo per asta, su 40 dossier veri l'88,8% dei poll
|
||||||
|
// aveva la stessa faccia del precedente. Si scrive solo quando cambia qualcosa di
|
||||||
|
// visibile: la profondita' di ogni ciclo resta ricostruibile, il file pesa un decimo.
|
||||||
|
|
||||||
|
private static int Conta(AuctionDossier dossier, string tipo) =>
|
||||||
|
LinesOf(dossier).Count(l => l.Contains($"\"type\":\"{tipo}\"", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void I_poll_identici_di_fila_si_scrivono_una_volta_sola()
|
||||||
|
{
|
||||||
|
var auction = NewAuction();
|
||||||
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
||||||
|
|
||||||
|
for (var i = 0; i < 10; i++) dossier.Poll(State(1.24, 15.6), includeRaw: true);
|
||||||
|
Assert.Equal(1, Conta(dossier, "poll"));
|
||||||
|
|
||||||
|
// Cambia il secondo sul cronometro: si scrive, perche' e' da qui che si legge
|
||||||
|
// fin dove e' sceso il timer.
|
||||||
|
dossier.Poll(State(1.24, 14.9), includeRaw: true);
|
||||||
|
Assert.Equal(2, Conta(dossier, "poll"));
|
||||||
|
|
||||||
|
// Cambia il prezzo: si scrive.
|
||||||
|
dossier.Poll(State(1.25, 15.0), includeRaw: true);
|
||||||
|
Assert.Equal(3, Conta(dossier, "poll"));
|
||||||
|
|
||||||
|
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_reset_porta_il_minimo_del_ciclo_chiuso_e_la_sua_durata()
|
||||||
|
{
|
||||||
|
var auction = NewAuction();
|
||||||
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
||||||
|
|
||||||
|
dossier.Poll(State(1.24, 8.0), includeRaw: true);
|
||||||
|
dossier.Poll(State(1.24, 3.4), includeRaw: true);
|
||||||
|
dossier.Poll(State(1.24, 1.2), includeRaw: true);
|
||||||
|
dossier.Poll(State(1.25, 8.0), includeRaw: true); // il prezzo sale: ciclo chiuso
|
||||||
|
dossier.Reset(1, 1.25, "marco_82");
|
||||||
|
|
||||||
|
var reset = LinesOf(dossier).Single(l => l.Contains("\"type\":\"reset\"", StringComparison.Ordinal));
|
||||||
|
var r = JsonDocument.Parse(reset).RootElement;
|
||||||
|
|
||||||
|
Assert.Equal(1.2, r.GetProperty("minTimer").GetDouble(), 3);
|
||||||
|
Assert.True(r.GetProperty("cycleSeconds").GetDouble() >= 0);
|
||||||
|
|
||||||
|
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Le_righe_RESET_del_registro_non_si_copiano_nel_dossier()
|
||||||
|
{
|
||||||
|
// Ripetevano parola per parola l'evento reset gia' scritto: una riga di log per
|
||||||
|
// ogni reset, e nessuna diceva qualcosa in piu'.
|
||||||
|
var auction = NewAuction();
|
||||||
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
||||||
|
|
||||||
|
dossier.Log("info", "General", "[RESET #3] marco_82 → €1,25 | Timer: 8,0s");
|
||||||
|
dossier.Log("info", "General", "qualcosa che invece conta");
|
||||||
|
|
||||||
|
var logs = LinesOf(dossier).Where(l => l.Contains("\"type\":\"log\"", StringComparison.Ordinal)).ToArray();
|
||||||
|
|
||||||
|
Assert.Single(logs);
|
||||||
|
Assert.Contains("qualcosa che invece conta", logs[0]);
|
||||||
|
|
||||||
|
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Ogni_riga_e_json_valido_per_conto_suo()
|
public void Ogni_riga_e_json_valido_per_conto_suo()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using AutoBidder.Ml;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AutoBidder.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Valuta il modello sui dossier veri, con una divisione onesta nel tempo: si impara dai
|
||||||
|
/// più vecchi e si giudica sui più recenti, che il modello non ha mai visto. Sul futuro,
|
||||||
|
/// non su ciò che ha già letto.
|
||||||
|
///
|
||||||
|
/// <para>Si attiva solo con le variabili d'ambiente, come la rigiocata:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>AUTOBIDDER_ML_DIR</c> — cartella dei dossier;</item>
|
||||||
|
/// <item><c>AUTOBIDDER_ML_MAX</c> — quanti file al massimo (0 = tutti);</item>
|
||||||
|
/// <item><c>AUTOBIDDER_ML_OUT</c> — dove scrivere il rapporto.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para><b>Cosa misura.</b> Quanto il modello separa le puntate rimaste senza risposta
|
||||||
|
/// dalle altre (sollevamento nella cima della classifica), se la probabilità è nella scala
|
||||||
|
/// giusta (calibrazione per fasce), e — sulle nostre puntate vere — cosa avrebbe fatto il
|
||||||
|
/// cancello del valore atteso: quante ne avrebbe fermate, e quante di quelle fermate erano
|
||||||
|
/// invece vincenti.</para>
|
||||||
|
/// </summary>
|
||||||
|
public class MlModelBacktest
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
|
||||||
|
public MlModelBacktest(ITestOutputHelper output) => _output = output;
|
||||||
|
|
||||||
|
private sealed record Row(double P, bool Unanswered, bool Mine, double Ev);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Valuta_il_modello_sui_dossier_reali()
|
||||||
|
{
|
||||||
|
var folder = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_DIR");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
|
||||||
|
{
|
||||||
|
_output.WriteLine("AUTOBIDDER_ML_DIR non impostata o inesistente: valutazione saltata.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var max = int.TryParse(Environment.GetEnvironmentVariable("AUTOBIDDER_ML_MAX"), out var m) ? m : 0;
|
||||||
|
|
||||||
|
// Ordine per nome = ordine di data: i dossier cominciano con la data.
|
||||||
|
var files = Directory.GetFiles(folder, "*.jsonl").OrderBy(Path.GetFileName, StringComparer.Ordinal).ToList();
|
||||||
|
if (max > 0 && files.Count > max) files = files.Skip(files.Count - max).ToList();
|
||||||
|
|
||||||
|
// Il nostro nome utente: i dossier scritti prima di oggi non lo portano
|
||||||
|
// nell'intestazione, e senza non si sa quali puntate erano nostre.
|
||||||
|
var me = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_ME");
|
||||||
|
|
||||||
|
var sessions = new List<DossierTrainingReader.Session>();
|
||||||
|
foreach (var f in files)
|
||||||
|
{
|
||||||
|
var s = DossierTrainingReader.ReadFile(f, me);
|
||||||
|
if (s != null && s.IsUsable) sessions.Add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
var report = new StringBuilder();
|
||||||
|
report.AppendLine($"Dossier letti: {files.Count:N0}, utilizzabili: {sessions.Count:N0}");
|
||||||
|
|
||||||
|
if (sessions.Count < 20)
|
||||||
|
{
|
||||||
|
report.AppendLine("Troppo pochi dossier utilizzabili per una valutazione.");
|
||||||
|
_output.WriteLine(report.ToString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var split = (int)(sessions.Count * 0.7);
|
||||||
|
var train = sessions.Take(split).ToList();
|
||||||
|
var test = sessions.Skip(split).ToList();
|
||||||
|
|
||||||
|
// Le variabili si costruiscono una volta sola: leggere i file è la parte lenta,
|
||||||
|
// addestrare è veloce, e così si possono confrontare più configurazioni.
|
||||||
|
var trainX = train.SelectMany(s => DossierTrainingReader.Examples(s))
|
||||||
|
.Select(e => (X: BidFeatures.Build(e.Context), Y: e.Unanswered)).ToList();
|
||||||
|
var testX = test.SelectMany(s => DossierTrainingReader.Examples(s).Select(e => (s, e)))
|
||||||
|
.Select(t => (
|
||||||
|
X: BidFeatures.Build(t.e.Context), Y: t.e.Unanswered, Mine: t.e.Mine,
|
||||||
|
Margine: t.s.BuyNowPrice is > 0 ? t.s.BuyNowPrice.Value - t.e.Context.AuctionDepthCents / 100.0 - 0.01 : double.NaN))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var n = testX.Count;
|
||||||
|
var positives = testX.Count(r => r.Y);
|
||||||
|
var baseRate = (double)positives / 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 ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// Il modello deve separare (sollevamento) E stare nella scala giusta
|
||||||
|
// (calibrazione): la probabilità entra in un valore atteso in euro. Si prova una
|
||||||
|
// griglia piccola e si sceglie la configurazione meglio calibrata fra quelle che
|
||||||
|
// separano davvero.
|
||||||
|
const double costo = 0.20;
|
||||||
|
// Prima griglia (passo × passate × media): il passo 0,01 in una passata era il
|
||||||
|
// migliore, ma sovrastimava ancora di 1,6-1,8 volte e la media non aiutava. Il
|
||||||
|
// sospetto è la regolarizzazione: con centinaia di migliaia di passi il
|
||||||
|
// decadimento accumulato riduce i pesi a due terzi, e i pesi sono quasi tutti
|
||||||
|
// negativi, quindi le probabilità salgono. Seconda griglia: passo × regolarizzazione.
|
||||||
|
var configs = new List<(string Nome, double Lr, int Epochs, bool Avg, double L2)>();
|
||||||
|
foreach (var lr in new[] { 0.01, 0.003 })
|
||||||
|
foreach (var l2 in new[] { 1e-4, 1e-6, 0.0 })
|
||||||
|
configs.Add(($"passo {lr:0.000}, L2 {l2:0.######}", lr, 1, false, l2));
|
||||||
|
|
||||||
|
report.AppendLine("Configurazione | sollev. 5% | prevista/osservata (cima 10%) | prevista/osservata (tutte)");
|
||||||
|
report.AppendLine("-------------------------------------+------------+-------------------------------+---------------------------");
|
||||||
|
|
||||||
|
OnlineLogit? best = null; string bestName = ""; double bestScore = double.MaxValue; double bestLift = 0;
|
||||||
|
foreach (var (nome, lr, ep, avg, l2) in configs)
|
||||||
|
{
|
||||||
|
var modello = new OnlineLogit(BidFeatures.Size) { LearningRate = lr, UseAveraging = avg, L2 = l2 };
|
||||||
|
for (var e = 0; e < ep; e++) 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 / 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() / baseRate;
|
||||||
|
|
||||||
|
report.AppendLine($"{nome,-37} | {lift5,9:N2}x | {ratioTop,29:N2} | {ratioAll,25:N2}");
|
||||||
|
|
||||||
|
// Punteggio: distanza della scala da 1 (in logaritmo), solo se separa.
|
||||||
|
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; bestLift = lift5; }
|
||||||
|
}
|
||||||
|
report.AppendLine();
|
||||||
|
|
||||||
|
if (best == null)
|
||||||
|
{
|
||||||
|
report.AppendLine("Nessuna configurazione separa abbastanza (sollevamento al 5% > 2x).");
|
||||||
|
_output.WriteLine(report.ToString());
|
||||||
|
Assert.Fail("nessuna configurazione separa");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
report.AppendLine($"Scelta: {bestName}");
|
||||||
|
report.AppendLine();
|
||||||
|
|
||||||
|
// ── Valutazione prequenziale: come nell'uso vero ─────────────────
|
||||||
|
//
|
||||||
|
// Il modello in produzione è online: quando decide su un'asta ha già imparato da
|
||||||
|
// tutte quelle chiuse prima. Giudicarlo "congelato" sui vecchi e applicato ai nuovi
|
||||||
|
// gli attribuisce una deriva che nell'uso non subisce. Qui ogni asta di valutazione
|
||||||
|
// viene prima prevista, poi appresa, in ordine di tempo: è esattamente la sequenza
|
||||||
|
// del motore. Le previsioni sono quelle calibrate, come le vede il motore.
|
||||||
|
var model = best;
|
||||||
|
var rows = new List<Row>();
|
||||||
|
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();
|
||||||
|
|
||||||
|
foreach (var esempi in perAsta)
|
||||||
|
{
|
||||||
|
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 (prevista poi appresa, asta per asta), fattore di calibrazione finale {model.CalibrationFactor:N2}:");
|
||||||
|
|
||||||
|
var meanPos = rows.Where(r => r.Unanswered).Average(r => r.P);
|
||||||
|
var meanNeg = rows.Where(r => !r.Unanswered).Average(r => r.P);
|
||||||
|
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");
|
||||||
|
report.AppendLine();
|
||||||
|
|
||||||
|
// Sollevamento: fra le puntate a probabilità più alta, quante erano davvero finali.
|
||||||
|
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 top = sorted.Take(k);
|
||||||
|
var prec = top.Count(r => r.Unanswered) / (double)k;
|
||||||
|
var lift = prec / 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();
|
||||||
|
|
||||||
|
// Calibrazione: la probabilità prevista deve somigliare a quella osservata.
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Le nostre puntate vere: cosa avrebbe fatto il cancello.
|
||||||
|
var mie = rows.Where(r => r.Mine && !double.IsNaN(r.Ev)).ToList();
|
||||||
|
if (mie.Count > 0)
|
||||||
|
{
|
||||||
|
var passate = mie.Where(r => r.Ev >= 0).ToList();
|
||||||
|
var fermate = 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.");
|
||||||
|
}
|
||||||
|
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}");
|
||||||
|
|
||||||
|
var text = report.ToString();
|
||||||
|
_output.WriteLine(text);
|
||||||
|
|
||||||
|
var outFile = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_OUT");
|
||||||
|
if (!string.IsNullOrWhiteSpace(outFile))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outFile))!);
|
||||||
|
File.WriteAllText(outFile, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// La condizione minima perché il modello serva a qualcosa: nella cima della
|
||||||
|
// classifica le puntate finali devono essere più frequenti che a caso.
|
||||||
|
Assert.True(liftTop5 > 1.0, $"il modello non separa: sollevamento al 5% = {liftTop5:N2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
using AutoBidder.Utilities;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace AutoBidder.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Modalita' della sessione. Due proprieta' da difendere: che Gara tolga esattamente il
|
|
||||||
/// lavoro dichiarato e nient'altro, e che la scelta sia immutabile per tutta la vita del
|
|
||||||
/// processo — un salvataggio a meta' sessione non deve cambiare il motore sotto i piedi.
|
|
||||||
/// </summary>
|
|
||||||
public class RuntimeProfileTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Addestramento_registra_tutto()
|
|
||||||
{
|
|
||||||
RuntimeProfile.ResetForTests();
|
|
||||||
RuntimeProfile.Initialize(RuntimeProfile.Addestramento);
|
|
||||||
|
|
||||||
Assert.False(RuntimeProfile.IsRace);
|
|
||||||
Assert.True(RuntimeProfile.RawPolls);
|
|
||||||
Assert.True(RuntimeProfile.VerboseAuctionLog);
|
|
||||||
Assert.True(RuntimeProfile.DetailedStats);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Gara_toglie_solo_il_lavoro_dichiarato()
|
|
||||||
{
|
|
||||||
RuntimeProfile.ResetForTests();
|
|
||||||
RuntimeProfile.Initialize(RuntimeProfile.Gara);
|
|
||||||
|
|
||||||
Assert.True(RuntimeProfile.IsRace);
|
|
||||||
Assert.False(RuntimeProfile.RawPolls);
|
|
||||||
Assert.False(RuntimeProfile.VerboseAuctionLog);
|
|
||||||
Assert.False(RuntimeProfile.DetailedStats);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void La_prima_scelta_vince_per_tutta_la_sessione()
|
|
||||||
{
|
|
||||||
RuntimeProfile.ResetForTests();
|
|
||||||
RuntimeProfile.Initialize(RuntimeProfile.Gara);
|
|
||||||
|
|
||||||
// Un salvataggio delle impostazioni a meta' sessione passa di qui: non deve
|
|
||||||
// cambiare niente fino al riavvio.
|
|
||||||
RuntimeProfile.Initialize(RuntimeProfile.Addestramento);
|
|
||||||
|
|
||||||
Assert.True(RuntimeProfile.IsRace);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(null)]
|
|
||||||
[InlineData("")]
|
|
||||||
[InlineData("qualcosa")]
|
|
||||||
[InlineData("gara")]
|
|
||||||
public void Un_valore_ignoto_vale_Addestramento_e_le_maiuscole_non_contano(string? valore)
|
|
||||||
{
|
|
||||||
RuntimeProfile.ResetForTests();
|
|
||||||
RuntimeProfile.Initialize(valore);
|
|
||||||
|
|
||||||
// "gara" minuscolo e' Gara: un file modificato a mano non deve spegnere la scelta.
|
|
||||||
var atteso = string.Equals(valore, "gara", System.StringComparison.OrdinalIgnoreCase);
|
|
||||||
Assert.Equal(atteso, RuntimeProfile.IsRace);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Il_predefinito_di_fabbrica_e_Addestramento()
|
|
||||||
{
|
|
||||||
RuntimeProfile.ResetForTests();
|
|
||||||
Assert.Equal(RuntimeProfile.Addestramento, new AppSettings().SessionMode);
|
|
||||||
Assert.False(RuntimeProfile.IsRace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -54,6 +54,27 @@ namespace AutoBidder.Utilities
|
|||||||
private long _events;
|
private long _events;
|
||||||
private bool _closed;
|
private bool _closed;
|
||||||
|
|
||||||
|
// ── Coalescenza dei poll ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Con il tetto delle richieste tolto arrivano ~17 interrogazioni al secondo per
|
||||||
|
// asta, e su 40 dossier veri l'88,8% ha la stessa faccia della precedente: stesso
|
||||||
|
// prezzo, stesso puntatore, stesso stato, stesso secondo sul cronometro. Scriverle
|
||||||
|
// tutte faceva dossier da 30 MB per una sola asta, e 8,8 GB in tutto — di cui nove
|
||||||
|
// decimi ripetizioni. Si scrive quando cambia qualcosa, e comunque una volta al
|
||||||
|
// secondo perché il ping resti misurabile. Ogni cambio di secondo e ogni reset
|
||||||
|
// vengono scritti: la profondità di ogni ciclo resta ricostruibile esattamente.
|
||||||
|
private string? _lastPollSignature;
|
||||||
|
private DateTime _lastPollWrittenAt = DateTime.MinValue;
|
||||||
|
|
||||||
|
// Quanto è sceso il timer nel ciclo appena chiuso e quanto è durato: sono i due
|
||||||
|
// numeri che l'apprendimento vuole da un reset, e si ricavano solo qui, dove
|
||||||
|
// passano tutti i poll.
|
||||||
|
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 AuctionDossier(string path, DateTime startedAt)
|
private AuctionDossier(string path, DateTime startedAt)
|
||||||
{
|
{
|
||||||
_writer = FileLogWriter.For(path);
|
_writer = FileLogWriter.For(path);
|
||||||
@@ -62,6 +83,14 @@ namespace AutoBidder.Utilities
|
|||||||
|
|
||||||
public string Path => _writer.Path;
|
public string Path => _writer.Path;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Il nostro nome utente su Bidoo, scritto nell'intestazione di ogni dossier aperto
|
||||||
|
/// da qui in poi. Lo imposta il motore appena conosce la sessione. Serve a chi
|
||||||
|
/// rilegge il file per sapere quali puntate erano nostre senza dipendere da niente
|
||||||
|
/// di esterno al file.
|
||||||
|
/// </summary>
|
||||||
|
public static volatile string CurrentUsername = "";
|
||||||
|
|
||||||
/// <summary>Righe scritte finora, intestazione esclusa.</summary>
|
/// <summary>Righe scritte finora, intestazione esclusa.</summary>
|
||||||
public long EventCount => _events;
|
public long EventCount => _events;
|
||||||
|
|
||||||
@@ -173,6 +202,7 @@ namespace AutoBidder.Utilities
|
|||||||
schema = "autobidder.auction.v1",
|
schema = "autobidder.auction.v1",
|
||||||
app = AppInfo.Version,
|
app = AppInfo.Version,
|
||||||
auctionId = auction.AuctionId,
|
auctionId = auction.AuctionId,
|
||||||
|
me = string.IsNullOrEmpty(CurrentUsername) ? null : CurrentUsername,
|
||||||
name = auction.Name,
|
name = auction.Name,
|
||||||
productKey = ProductKeyHelper.GenerateProductKey(auction.Name),
|
productKey = ProductKeyHelper.GenerateProductKey(auction.Name),
|
||||||
url = auction.OriginalUrl,
|
url = auction.OriginalUrl,
|
||||||
@@ -321,10 +351,39 @@ namespace AutoBidder.Utilities
|
|||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs);
|
if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs);
|
||||||
|
|
||||||
|
// Un cambio di prezzo è un reset visto da qui: il ciclo che finisce va
|
||||||
|
// consegnato a Reset() con il suo minimo e la sua durata.
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!includeRaw) return;
|
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(new
|
Write(new
|
||||||
{
|
{
|
||||||
t = Elapsed(),
|
t = Elapsed(),
|
||||||
@@ -399,6 +458,15 @@ namespace AutoBidder.Utilities
|
|||||||
/// <summary>Il timer è stato azzerato da una puntata: l'asta continua.</summary>
|
/// <summary>Il timer è stato azzerato da una puntata: l'asta continua.</summary>
|
||||||
public void Reset(int resetCount, double price, string? bidder)
|
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(new
|
Write(new
|
||||||
{
|
{
|
||||||
t = Elapsed(),
|
t = Elapsed(),
|
||||||
@@ -406,13 +474,24 @@ namespace AutoBidder.Utilities
|
|||||||
at = Now(),
|
at = Now(),
|
||||||
resetCount,
|
resetCount,
|
||||||
price,
|
price,
|
||||||
bidder
|
bidder,
|
||||||
|
|
||||||
|
// Del ciclo appena chiuso: fin dove è sceso il cronometro prima che
|
||||||
|
// qualcuno puntasse, e quanto è durato. È il dato con cui si ricostruisce
|
||||||
|
// a che punto del conto alla rovescia la gente punta davvero.
|
||||||
|
minTimer,
|
||||||
|
cycleSeconds
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Una riga del registro dell'asta (strategie, avvisi, errori).</summary>
|
/// <summary>Una riga del registro dell'asta (strategie, avvisi, errori).</summary>
|
||||||
public void Log(string level, string category, string message)
|
public void Log(string level, string category, string message)
|
||||||
{
|
{
|
||||||
|
// Le righe "[RESET #n] utente → prezzo" ripetono parola per parola l'evento
|
||||||
|
// reset che sta già nel dossier: nel campione erano una riga di log ogni reset,
|
||||||
|
// e nessuna diceva qualcosa in più. Restano nel registro a video.
|
||||||
|
if (message != null && message.StartsWith("[RESET #", StringComparison.Ordinal)) return;
|
||||||
|
|
||||||
Write(new
|
Write(new
|
||||||
{
|
{
|
||||||
t = Elapsed(),
|
t = Elapsed(),
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace AutoBidder.Utilities
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// La modalità della sessione, letta una volta all'avvio e immutabile fino alla chiusura.
|
|
||||||
///
|
|
||||||
/// <para><b>Addestramento</b> registra tutto: ogni interrogazione nel dossier, ogni
|
|
||||||
/// decisione della strategia nel registro dell'asta, il dettaglio esteso delle aste
|
|
||||||
/// concluse. È la modalità con cui si raccolgono i dati che poi servono a tarare le
|
|
||||||
/// regole — e i dossier sono la materia prima di ogni analisi fatta finora.</para>
|
|
||||||
///
|
|
||||||
/// <para><b>Gara</b> fa solo il lavoro che serve a puntare. Quello che toglie:</para>
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>la riga JSON per ogni interrogazione nel dossier — a 17 poll al secondo per
|
|
||||||
/// asta un'asta lunga vale decine di megabyte;</item>
|
|
||||||
/// <item>le righe di registro informative («✓ prezzo OK» e simili), composte a ogni
|
|
||||||
/// giro prima ancora di sapere se qualcuno le leggerà;</item>
|
|
||||||
/// <item>il dettaglio esteso di ogni asta conclusa.</item>
|
|
||||||
/// </list>
|
|
||||||
/// <para>Restano puntate, esiti, errori, lo storico delle aste concluse e tutto ciò che
|
|
||||||
/// alimenta le decisioni — il riconoscimento del duello con l'autopuntata compreso, che
|
|
||||||
/// vive sulle puntate avversarie e non sui poll.</para>
|
|
||||||
///
|
|
||||||
/// <para><b>Cosa aspettarsi, misurato.</b> Il lavoro che Gara evita costa 4,9 µs per
|
|
||||||
/// interrogazione (3,3 la riga JSON, 1,6 le sei stringhe di registro). A 57 aste per
|
|
||||||
/// 17 poll al secondo sono 4,7 ms di CPU al secondo: mezzo punto percentuale di un
|
|
||||||
/// core. Il guadagno vero di Gara è sul disco e sulla leggibilità dei dossier, non
|
|
||||||
/// sulla velocità: la scrittura era già asincrona, e la CPU spesa a prepararla è
|
|
||||||
/// trascurabile. Se il programma è lento, la causa sta altrove.</para>
|
|
||||||
///
|
|
||||||
/// <para><b>Perché immutabile.</b> Scelta esplicita dell'utente: la modalità vale per
|
|
||||||
/// tutta la sessione e cambiarla richiede il riavvio. Un interruttore a caldo
|
|
||||||
/// funzionerebbe uguale, ma una sessione tutta di un tipo ha un pregio suo — i dossier
|
|
||||||
/// che ne escono sono confrontabili fra loro, senza buchi a metà.</para>
|
|
||||||
/// </summary>
|
|
||||||
public static class RuntimeProfile
|
|
||||||
{
|
|
||||||
public const string Addestramento = "Addestramento";
|
|
||||||
public const string Gara = "Gara";
|
|
||||||
|
|
||||||
private static string? _mode;
|
|
||||||
|
|
||||||
/// <summary>La modalità in vigore. Se nessuno l'ha fissata, Addestramento.</summary>
|
|
||||||
public static string Mode => _mode ?? Addestramento;
|
|
||||||
|
|
||||||
public static bool IsRace => string.Equals(Mode, Gara, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fissa la modalità per tutta la vita del processo. La prima chiamata vince: le
|
|
||||||
/// successive sono ignorate, così un salvataggio delle impostazioni a metà sessione
|
|
||||||
/// non cambia il comportamento sotto i piedi del motore.
|
|
||||||
/// </summary>
|
|
||||||
public static void Initialize(string? mode)
|
|
||||||
{
|
|
||||||
if (_mode != null) return;
|
|
||||||
_mode = Normalize(mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Il nome canonico di una modalità; qualunque altra cosa vale Addestramento.</summary>
|
|
||||||
public static string Normalize(string? mode) =>
|
|
||||||
string.Equals(mode, Gara, StringComparison.OrdinalIgnoreCase) ? Gara : Addestramento;
|
|
||||||
|
|
||||||
/// <summary>Solo per i test: azzera la scelta.</summary>
|
|
||||||
public static void ResetForTests() => _mode = null;
|
|
||||||
|
|
||||||
// ── Cosa fa ciascuna modalità ────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>Scrivere nel dossier una riga per ogni interrogazione.</summary>
|
|
||||||
public static bool RawPolls => !IsRace;
|
|
||||||
|
|
||||||
/// <summary>Comporre le righe di registro informative («✓ prezzo OK» e simili).</summary>
|
|
||||||
public static bool VerboseAuctionLog => !IsRace;
|
|
||||||
|
|
||||||
/// <summary>Scrivere il dettaglio esteso di ogni asta conclusa.</summary>
|
|
||||||
public static bool DetailedStats => !IsRace;
|
|
||||||
|
|
||||||
/// <summary>Una riga per il registro d'avvio.</summary>
|
|
||||||
public static string Describe() => IsRace
|
|
||||||
? "Gara: niente poll grezzi nel dossier, niente registro informativo, niente dettaglio esteso"
|
|
||||||
: "Addestramento: registrazione completa";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -565,12 +565,34 @@ namespace AutoBidder.Utilities
|
|||||||
// Vedi BiddingHours per i numeri: alle 0 e alle 9 la stessa asta costa quasi il
|
// Vedi BiddingHours per i numeri: alle 0 e alle 9 la stessa asta costa quasi il
|
||||||
// doppio che fra le 10 e le 13.
|
// doppio che fra le 10 e le 13.
|
||||||
|
|
||||||
|
// ── Apprendimento ────────────────────────────────────────────────
|
||||||
|
// Vedi Ml/LearningService. Sempre acceso: impara da ogni asta chiusa.
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Modalità della sessione: <c>Addestramento</c> registra tutto, <c>Gara</c> fa solo
|
/// Lasciar fermare una puntata al modello quando il valore atteso è negativo.
|
||||||
/// il lavoro che serve a puntare. Letta una volta all'avvio: cambiarla richiede il
|
/// Spento, il modello parla nel registro ma non decide.
|
||||||
/// riavvio. Vedi <see cref="RuntimeProfile"/> per cosa cambia e per quanto pesa.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string SessionMode { get; set; } = RuntimeProfile.Addestramento;
|
public bool LearningGateEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aste apprese sotto le quali il modello non ferma niente. Un modello appena nato
|
||||||
|
/// direbbe cose a caso, e a caso fermerebbe le puntate. Predefinito 300.
|
||||||
|
/// </summary>
|
||||||
|
public int LearningMinAuctions { get; set; } = 300;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moltiplica il costo della puntata nel confronto col valore atteso: 1 è il
|
||||||
|
/// valore atteso puro, 2 pretende che una puntata renda il doppio del suo costo.
|
||||||
|
/// Più alto = più selettivo = meno puntate.
|
||||||
|
/// </summary>
|
||||||
|
public double LearningEvMultiplier { get; set; } = 1.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Secondi per avvio dedicati a leggere i dossier non ancora appresi, in
|
||||||
|
/// sottofondo. La prima volta ne servono diversi avvii; poi resta solo l'asta
|
||||||
|
/// appena chiusa.
|
||||||
|
/// </summary>
|
||||||
|
public int LearningBootstrapSecondsPerStart { get; set; } = 120;
|
||||||
|
|
||||||
// ── Rimozione automatica delle aste concluse ─────────────────────
|
// ── Rimozione automatica delle aste concluse ─────────────────────
|
||||||
// Vedi FinishedAuctionCleanup: si toglie di serie, si trattiene cio' su cui c'e'
|
// Vedi FinishedAuctionCleanup: si toglie di serie, si trattiene cio' su cui c'e'
|
||||||
|
|||||||
@@ -421,6 +421,37 @@
|
|||||||
<Message Importance="High" Text=" riepilogo in $(Riepilogo)" />
|
<Message Importance="High" Text=" riepilogo in $(Riepilogo)" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
|
<!-- ═════════════════════ Valutazione dell'apprendimento ═════════════════════ -->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Addestra sui dossier più vecchi e giudica sui più recenti, mai visti: dice se il
|
||||||
|
modello separa le puntate rimaste senza risposta, se la sua probabilità è nella
|
||||||
|
scala giusta, e cosa avrebbe fatto il cancello del valore atteso sulle puntate
|
||||||
|
vere dell'utente.
|
||||||
|
-->
|
||||||
|
<Target Name="Apprendimento">
|
||||||
|
<PropertyGroup>
|
||||||
|
<CartellaDossier Condition="'$(CartellaDossier)' == ''">$(LOCALAPPDATA)\AutoBidder\Dati\Registri\Aste</CartellaDossier>
|
||||||
|
<MaxDossier Condition="'$(MaxDossier)' == ''">0</MaxDossier>
|
||||||
|
<RiepilogoMl Condition="'$(RiepilogoMl)' == ''">$(Radice)\bin\apprendimento-report.txt</RiepilogoMl>
|
||||||
|
<!-- Il nostro nome utente su Bidoo: serve a riconoscere le nostre puntate nei
|
||||||
|
dossier scritti prima che l'intestazione lo portasse. -->
|
||||||
|
<UtenteMl Condition="'$(UtenteMl)' == ''"></UtenteMl>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Error Condition="!Exists('$(CartellaDossier)')"
|
||||||
|
Text="Cartella dei dossier non trovata: $(CartellaDossier). Indicala con -p:CartellaDossier=..." />
|
||||||
|
|
||||||
|
<Message Importance="High" Text="== Valutazione del modello sui dossier ==" />
|
||||||
|
<Message Importance="High" Text=" cartella : $(CartellaDossier)" />
|
||||||
|
|
||||||
|
<Exec WorkingDirectory="$(Radice)"
|
||||||
|
Command="dotnet test "$(TestProj)" --nologo -v q --artifacts-path "$(CartellaProve)" --filter "FullyQualifiedName~MlModelBacktest" --logger "console;verbosity=detailed""
|
||||||
|
EnvironmentVariables="$(AmbientePulito);AUTOBIDDER_ML_DIR=$(CartellaDossier);AUTOBIDDER_ML_MAX=$(MaxDossier);AUTOBIDDER_ML_OUT=$(RiepilogoMl);AUTOBIDDER_ML_ME=$(UtenteMl)" />
|
||||||
|
|
||||||
|
<Message Importance="High" Text=" rapporto in $(RiepilogoMl)" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
<!-- ═════════════════════ Eseguibile ═════════════════════ -->
|
<!-- ═════════════════════ Eseguibile ═════════════════════ -->
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
Reference in New Issue
Block a user