Database SQLite al posto dei dossier, gestore del rischio, decisioni in shadow, rete neurale, bandit e simulatore

Le aste non si salvano più in un file JSON per asta più tre archivi da tenere allineati:
un solo database SQLite (winsqlite3.dll di Windows via P/Invoke, nessun pacchetto) con
prodotti, aste, puntate, interrogazioni coalescenti, reset con profondità del ciclo,
nostre puntate, decisioni del motore, misure di rete, puntatori, sessioni e contabilità.
Scrittura in coda su un thread dedicato, letture in WAL. Per scelta dell'utente si parte
da zero: il database si riempie man mano che si osservano le aste; i vecchi dossier
restano leggibili per un'importazione futura ma non si scrivono più. Storico, schede,
apprendimento, rigiocata ed esportazioni leggono tutti da lì.

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 11:00:49 +02:00
co-authored by Claude Fable 5.1
parent 8b7c39c9e3
commit 7993ca44a0
65 changed files with 6645 additions and 2123 deletions
+24 -3
View File
@@ -23,7 +23,7 @@ namespace AutoBidder
// Le impostazioni stanno nella radice fissa, quindi si possono leggere anche
// prima di sapere dove finiranno gli altri dati.
var settings = SettingsManager.Load();
AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder);
AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder, settings.DatabasePath);
AppPaths.EnsureFolders();
// Le versioni precedenti scrivevano parte dei dati in Roaming e parte in Local,
@@ -36,8 +36,21 @@ namespace AutoBidder
TextLogService.PurgeOldLogs();
TextLogService.App("INFO", $"[PERCORSI] configurazione in {AppPaths.ConfigRoot}, dati in {AppPaths.DataFolder}");
// Le righe di registro delle aste confluiscono nei rispettivi dossier.
AuctionDossier.CaptureAuctionLogs();
// Il database si apre per primo: da qui in poi ogni archivio ci scrive.
Data.AuctionDatabase.OnLog += m => TextLogService.App("INFO", m);
try
{
var db = Data.AuctionDatabase.Instance;
TextLogService.App("INFO", $"[DATABASE] {db.Path} (SQLite {Data.AuctionDatabase.LibraryVersion}, {db.SizeBytes / 1024:N0} KB)");
db.Enqueue("INSERT INTO sessions(started_at, app_version) VALUES(?1, ?2)", Data.AuctionDatabase.Now(), AppInfo.Version);
}
catch (Exception ex)
{
TextLogService.App("ERROR", $"[DATABASE] Apertura non riuscita: {ex.Message}");
}
// Le righe di registro delle aste confluiscono nel database.
Data.AuctionRecorder.CaptureAuctionLogs();
// Lo storico e' cresciuto per mesi con versioni diverse dell'applicazione:
// alcuni record hanno campi che allora non esistevano. Quelli deducibili si
@@ -74,6 +87,14 @@ namespace AutoBidder
TextLogService.SessionEnded();
FileLogWriter.DisposeAll();
try
{
var db = Data.AuctionDatabase.Instance;
db.Enqueue("UPDATE sessions SET ended_at = ?1 WHERE session_id = (SELECT MAX(session_id) FROM sessions)", Data.AuctionDatabase.Now());
Data.AuctionDatabase.CloseInstance();
}
catch { /* all'uscita non c'è più nessuno a cui dirlo */ }
// L'icona di notifica sopravvive al processo se non la si rimuove:
// resterebbe nell'area di notifica come icona fantasma.
WindowsNotifier.Dispose();
@@ -66,6 +66,19 @@
<TextBlock Style="{StaticResource Glyph}" Text="&#xE71A;"/>
</Button>
<ToggleButton x:Name="KillSwitchToggle" Margin="6,0,0,0" Padding="7,3"
BorderThickness="1" Cursor="Hand"
Background="{DynamicResource Brush.SurfaceAlt}"
BorderBrush="{DynamicResource Brush.Border}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Kill-switch: acceso, nessuna puntata dal vivo parte, qualunque cosa decidano le strategie. Le aste continuano a essere seguite e registrate. È un file KILL_SWITCH in %LocalAppData%\AutoBidder: resta anche dopo un riavvio."
Checked="KillSwitchToggle_Changed" Unchecked="KillSwitchToggle_Changed">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE7BA;" Margin="0,0,5,0"/>
<TextBlock Text="Kill-switch" VerticalAlignment="Center" FontSize="12"/>
</StackPanel>
</ToggleButton>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="8,4"/>
</StackPanel>
@@ -67,6 +67,20 @@ namespace AutoBidder.Controls
RaiseEvent(new RoutedEventArgs(StopClickedEvent, this));
}
private void KillSwitchToggle_Changed(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(KillSwitchChangedEvent, this));
}
public static readonly RoutedEvent KillSwitchChangedEvent = EventManager.RegisterRoutedEvent(
"KillSwitchChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public event RoutedEventHandler KillSwitchChanged
{
add { AddHandler(KillSwitchChangedEvent, value); }
remove { RemoveHandler(KillSwitchChangedEvent, value); }
}
private void AddUrlButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(AddUrlClickedEvent, this));
+61
View File
@@ -84,6 +84,12 @@
Content="Apri cartella" Margin="0,0,8,0"
ToolTip="La cartella con modello, profilo, elenco dei dossier letti e ultima valutazione"
Click="OpenFolderButton_Click"/>
<Button x:Name="ExportDecisionsButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Esporta decisioni (CSV)" Margin="0,0,8,0"
ToolTip="Tutte le decisioni del motore, dal vivo e in shadow, con esito e motivazione per esteso (reason_detail). CSV con punto e virgola, pronto per un foglio di calcolo."
Click="ExportDecisionsButton_Click"/>
<Button x:Name="RetrainButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Danger}"
@@ -107,6 +113,7 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Stato -->
@@ -279,6 +286,60 @@
</Grid>
</StackPanel>
</Border>
<!-- Shadow: cosa avrebbe reso -->
<Border Grid.Row="4" Grid.Column="0" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Shadow: cosa avrebbero reso le decisioni" Margin="0,0,0,10"
Help="Ogni decisione registrata (in Osserva come in Attiva) riceve un esito alla chiusura dell'asta: se il prezzo non è più salito dopo quell'istante, una puntata lì sarebbe stata l'ultima. Con questo si giudicano sugli stessi istanti la policy in uso (modello + regime), il bandit Thompson che gira in shadow, «punta sempre» e «non punta mai».&#10;&#10;ROI per puntata = netto / puntate. L'intervallo di confidenza dice se il null-model (ROI = 0) è battuto davvero o per caso. Chi risponde al motore fra modello logistico e rete neurale lo decide il punteggio di Brier prequenziale qui sotto."/>
<UniformGrid Columns="3" Rows="1" Margin="0,0,0,8">
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatChampion" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="chi risponde al motore"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatBrier" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="Brier logistico / rete"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatBandit" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="bandit: contesti / osservazioni"/>
</StackPanel>
</UniformGrid>
<TextBox x:Name="ShadowBox" IsReadOnly="True" TextWrapping="NoWrap"
MinHeight="180" MaxHeight="300"
FontFamily="Consolas" FontSize="11.5"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
BorderBrush="{DynamicResource Brush.Border}"
Text="Nessuna decisione con esito ancora."/>
</StackPanel>
</Border>
<!-- Simulatore -->
<Border Grid.Row="4" Grid.Column="2" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Simulatore: le policy a confronto" Margin="0,0,0,10"
Help="Aste sintetiche con avversari tarati sul database (puntatori per asta, profondità dei cicli, quota di autopuntate, chiusura mediana): aggressivi, cecchini, autopuntate a 2 s, principianti, ognuno con il costo affondato degli umani. Le policy giocano le stesse aste (stessi semi): non puntare mai, puntare sempre, la regola del valore atteso, il bandit che impara giocando, e l'agente Q addestrato qui dentro con penalità conservativa.&#10;&#10;Il simulatore non è Bidoo: i numeri assoluti non contano, il confronto sì. Ed è l'unico posto dove si vede come reagirebbero gli altri a una nostra puntata."/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Button x:Name="SimulateButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Content="Simula 500 aste per policy" Margin="0,0,8,0"
Click="SimulateButton_Click"/>
<TextBlock x:Name="SimulateHint" Style="{StaticResource Hint}" VerticalAlignment="Center" Text=""/>
</StackPanel>
<TextBox x:Name="SimulationBox" IsReadOnly="True" TextWrapping="NoWrap"
MinHeight="180" MaxHeight="300"
FontFamily="Consolas" FontSize="11.5"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
BorderBrush="{DynamicResource Brush.Border}"
Text="Premi «Simula» per mettere le policy a confronto."/>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</Grid>
+78
View File
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
@@ -111,6 +112,7 @@ namespace AutoBidder.Controls
StatBootstrap.Text = LearningService.BootstrapRunning ? "in corso" : "fermo";
RefreshAutonomy(settings);
RefreshShadow(settings);
var decisioni = LearningService.RecentDecisions();
var fermate = decisioni.Count(d => d.Blocked);
@@ -213,6 +215,82 @@ namespace AutoBidder.Controls
: $"{righe.Count} aste seguite: {sfogo} in Sfogo, {righe.Count(r => r.Regime == "Sondaggio")} in Sondaggio, {righe.Count - sfogo - righe.Count(r => r.Regime == "Sondaggio")} Calme.";
}
private DateTime _shadowRefreshedAt = DateTime.MinValue;
/// <summary>Campione, Brier, bandit e il rapporto shadow (ogni mezzo minuto: interroga il database).</summary>
private void RefreshShadow(AppSettings settings)
{
var (bl, bn, samples) = LearningService.BrierScores;
StatChampion.Text = LearningService.ChallengerLeads ? "rete neurale" : "logistico";
StatBrier.Text = samples == 0 ? "—" : $"{bl:F5} / {bn:F5}";
StatBandit.Text = $"{LearningService.Bandit.Arms:N0} / {LearningService.Bandit.Observations:N0}";
if ((DateTime.Now - _shadowRefreshedAt).TotalSeconds < 30) return;
_shadowRefreshedAt = DateTime.Now;
try
{
var report = ShadowReport.Compute(Data.AuctionDatabase.Instance, settings, 30);
ShadowBox.Text = report.WithOutcome == 0
? $"Decisioni registrate negli ultimi 30 giorni: {report.TotalDecisions:N0}, nessuna con esito ancora (arriva alla chiusura dell'asta)."
: report.Text;
}
catch (Exception ex)
{
ShadowBox.Text = $"Rapporto non disponibile: {ex.Message}";
}
}
private void ExportDecisionsButton_Click(object sender, RoutedEventArgs e)
{
var result = DecisionExporter.ExportCsv();
if (result.Success)
{
try { Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(result.Path)!, UseShellExecute = true }); } catch { }
MessageBox.Show($"{result.Message}\n{result.Path}", "Decisioni", MessageBoxButton.OK, MessageBoxImage.Information);
}
else
{
MessageBox.Show(result.Message, "Decisioni", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private CancellationTokenSource? _simulation;
private async void SimulateButton_Click(object sender, RoutedEventArgs e)
{
if (_simulation != null) { _simulation.Cancel(); return; }
_simulation = new CancellationTokenSource();
var ct = _simulation.Token;
SimulateButton.Content = "Annulla";
SimulationBox.Text = "Simulazione in corso…";
var settings = SettingsManager.Load();
try
{
var text = await Task.Run(() => SimulationLab.Run(Data.AuctionDatabase.Instance, settings, 500,
msg => Dispatcher.BeginInvoke(() => SimulateHint.Text = msg), ct), ct);
SimulationBox.Text = text;
SimulateHint.Text = $"fatto alle {DateTime.Now:HH:mm}";
}
catch (OperationCanceledException)
{
SimulationBox.Text = "Simulazione annullata.";
SimulateHint.Text = "";
}
catch (Exception ex)
{
SimulationBox.Text = $"Simulazione non riuscita: {ex.Message}";
SimulateHint.Text = "";
}
finally
{
_simulation = null;
SimulateButton.Content = "Simula 500 aste per policy";
}
}
private void RefreshButton_Click(object sender, RoutedEventArgs e) => Refresh();
private void OpenFolderButton_Click(object sender, RoutedEventArgs e)
+5
View File
@@ -298,6 +298,11 @@
CellStyle="{StaticResource EditableCell}"
HeaderTemplate="{StaticResource EditableHeader}"
ElementStyle="{StaticResource EditableText}"/>
<DataGridTextColumn Header="Valore reale €" Binding="{Binding MarketValueText}"
Width="Auto" MinWidth="96"
CellStyle="{StaticResource EditableCell}"
HeaderTemplate="{StaticResource EditableHeader}"
ElementStyle="{StaticResource EditableText}"/>
<DataGridTextColumn Header="Entro min" Binding="{Binding MaxStartMinutesText}"
Width="Auto" MinWidth="86"
CellStyle="{StaticResource EditableCell}"
+100 -3
View File
@@ -541,7 +541,7 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
</Button>
</Grid>
<TextBlock Text="Registri (log applicativo, riscatto puntate, dossier delle aste)"
<TextBlock Text="Registri (log applicativo, riscatto puntate)"
Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
@@ -562,6 +562,29 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button>
</Grid>
<TextBlock Text="Database (aste, puntate, decisioni, misure)" Margin="0,12,0,4"
Foreground="{DynamicResource Brush.TextMuted}"
ToolTip="Un solo file SQLite. Sta in %LocalAppData%\AutoBidder\Database e non va messo in una cartella sincronizzata (OneDrive, Dropbox): è il modo classico di corrompere un database aperto. Cambiarlo richiede un riavvio."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" x:Name="DatabaseFileTextBox"/>
<Button Grid.Column="1" Content="Sfoglia…" Margin="8,0,0,0"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Click="BrowseDatabaseFileButton_Click"/>
<Button Grid.Column="2" Margin="6,0,0,0"
Style="{StaticResource IconButton}"
ToolTip="Apri la cartella del database"
Click="OpenDatabaseFolderButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button>
</Grid>
</StackPanel>
</Border>
@@ -577,9 +600,9 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
<CheckBox x:Name="WriteFreeBidsLogCheckBox" IsChecked="True" Margin="0,4"
Content="Registro del riscatto puntate su file"/>
<CheckBox x:Name="WriteDossiersCheckBox" IsChecked="True" Margin="0,4"
Content="Dossier per ogni asta (eventi, puntate di ogni utente, ping)"/>
Content="Registra ogni asta seguita nel database (puntate di ogni utente, reset, mie puntate, decisioni)"/>
<CheckBox x:Name="RawPollsCheckBox" IsChecked="True" Margin="0,4"
Content="Nel dossier registra anche ogni singola interrogazione a Bidoo"
Content="Registra anche le interrogazioni a Bidoo (una riga quando cambia qualcosa, e almeno una al secondo)"
ToolTip="Risoluzione massima: prezzo, timer e ping a ogni poll. Nella finestra critica sono quattro righe al secondo, quindi i file crescono in fretta."/>
<Grid Margin="0,10,0,0">
@@ -671,6 +694,80 @@ Se il valore è maggiore di zero, il sistema non punta quando le puntate residue
</StackPanel>
</Border>
<!-- SEZIONE: Gestione del rischio -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Gestione del rischio"
Margin="0,0,0,14"
Help="I paletti duri che nessuna strategia può scavalcare. Ogni puntata dal vivo passa da qui prima di partire.&#10;&#10;Il tetto di spesa del giorno ferma le puntate per oggi e domani si riparte. Lo stop-loss (perdita netta del giorno) e il drawdown (discesa dal picco del saldo cumulato) mettono invece il sistema in HALT: resta fermo anche dopo un riavvio, finché non lo togli tu da qui. Le vincite si contano al valore reale del prodotto (colonna «Valore reale» nella scheda Prodotti), meno prezzo, fee e spedizione.&#10;&#10;Il kill-switch è un file KILL_SWITCH in %LocalAppData%\AutoBidder: finché esiste nessuna puntata dal vivo parte. Lo puoi creare anche da fuori, senza aprire l'applicazione.&#10;&#10;La copertura «Compralo Ora» presume che, perdendo, si compri al listino recuperando le puntate come credito: la perdita si limita a listino valore reale. Per i buoni al nominale copre tutto dal primo centesimo, e il modello punterà più spesso. Se non intendi esercitarla, spegnila."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Tetti di puntate attivi" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Applica i due tetti di puntate qui sotto (per asta e per sessione)."/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="BankrollEnabledCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Puntate massime per asta" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="0 = nessun limite. Vale per le aste senza un tetto proprio."/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="MaxBidsPerAuctionTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Puntate massime per sessione" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="0 = nessun limite. Si azzera a ogni avvio."/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="MaxBidsPerSessionTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Tetto di spesa del giorno (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quanto spendere in puntate al massimo in un giorno. 0 = nessun limite. Non è un HALT: domani si riparte."/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="DailyBudgetTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Stop-loss del giorno (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Perdita netta del giorno (puntate spese meno vincite) oltre la quale il sistema entra in HALT. 0 = mai."/>
<TextBox Grid.Row="4" Grid.Column="1" x:Name="DailyStopLossTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="Drawdown massimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Discesa del saldo cumulato dal suo picco oltre la quale il sistema entra in HALT. 0 = mai."/>
<TextBox Grid.Row="5" Grid.Column="1" x:Name="MaxDrawdownTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="Aste in gioco insieme" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Su quante aste al massimo spendere nello stesso momento. Un'asta è «in gioco» quando ha già ricevuto una tua puntata in questa sessione. 0 = nessun limite."/>
<TextBox Grid.Row="6" Grid.Column="1" x:Name="MaxConcurrentTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="7" Grid.Column="0" Text="Fee di transazione (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quanto Bidoo aggiunge alla vincita. Entra nel valore atteso di ogni puntata e nel conto delle vincite."/>
<TextBox Grid.Row="7" Grid.Column="1" x:Name="TransactionFeeTextBox" Text="1,00" Margin="10,10"/>
<TextBlock Grid.Row="8" Grid.Column="0" Text="Copertura «Compralo Ora»" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Vedi la spiegazione della sezione. Accesa: le puntate oltre la perdita coperta non costano nulla nel ramo di sconfitta."/>
<CheckBox Grid.Row="8" Grid.Column="1" x:Name="HedgeCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="9" Grid.Column="0" Text="Kill-switch" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Acceso: nessuna puntata dal vivo. Si applica subito, senza salvare."/>
<CheckBox Grid.Row="9" Grid.Column="1" x:Name="KillSwitchCheckBox" Margin="10,10" VerticalAlignment="Center"
Checked="KillSwitchCheckBox_Changed" Unchecked="KillSwitchCheckBox_Changed"/>
</Grid>
<Border Style="{StaticResource InfoBox}" Margin="0,15,0,0">
<StackPanel>
<TextBlock x:Name="RiskStatusText" TextWrapping="Wrap"
Foreground="{DynamicResource Brush.Text}"
FontSize="12" LineHeight="18"
Text="Nessun HALT in corso."/>
<Button x:Name="ResumeHaltButton" Content="Togli l'HALT e riparti" Margin="0,10,0,0"
HorizontalAlignment="Left" Padding="14,5" Visibility="Collapsed"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Danger}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Click="ResumeHaltButton_Click"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- SEZIONE 4: Limiti Log -->
<Border Background="{DynamicResource Brush.Surface}"
BorderBrush="{DynamicResource Brush.Border}"
+74
View File
@@ -86,6 +86,80 @@ namespace AutoBidder.Controls
private void OpenLogFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenLogFolderClickedEvent, this));
private void BrowseDatabaseFileButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(BrowseDatabaseFileClickedEvent, this));
private void OpenDatabaseFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenDatabaseFolderClickedEvent, this));
public static readonly RoutedEvent BrowseDatabaseFileClickedEvent = EventManager.RegisterRoutedEvent(
"BrowseDatabaseFileClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent OpenDatabaseFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenDatabaseFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler BrowseDatabaseFileClicked
{
add { AddHandler(BrowseDatabaseFileClickedEvent, value); }
remove { RemoveHandler(BrowseDatabaseFileClickedEvent, value); }
}
public event RoutedEventHandler OpenDatabaseFolderClicked
{
add { AddHandler(OpenDatabaseFolderClickedEvent, value); }
remove { RemoveHandler(OpenDatabaseFolderClickedEvent, value); }
}
// ── Gestione del rischio: kill-switch e HALT si applicano subito ──
private bool _riskSyncing;
private void KillSwitchCheckBox_Changed(object sender, RoutedEventArgs e)
{
if (_riskSyncing) return;
Services.RiskManager.SetKillSwitch(KillSwitchCheckBox.IsChecked == true);
RefreshRiskStatus();
RaiseEvent(new RoutedEventArgs(KillSwitchChangedEvent, this));
}
private void ResumeHaltButton_Click(object sender, RoutedEventArgs e)
{
Services.RiskManager.Resume();
RefreshRiskStatus();
}
/// <summary>Riallinea kill-switch e HALT a quello che dicono il file e il database.</summary>
public void RefreshRiskStatus()
{
try
{
_riskSyncing = true;
KillSwitchCheckBox.IsChecked = Services.RiskManager.KillSwitchActive;
var halt = Services.RiskManager.HaltReason;
var s = Services.RiskManager.Snapshot();
var conto = $"Oggi: spesi {s.TodaySpent:F2} €, incassati {s.TodayGained:F2} € (netto {s.TodayNet:+0.00;-0.00} €). " +
$"Saldo cumulato {s.Cumulative:+0.00;-0.00} €, picco {s.Peak:F2} €, drawdown {s.Drawdown:F2} €.";
RiskStatusText.Text = string.IsNullOrEmpty(halt)
? (Services.RiskManager.KillSwitchActive ? "Kill-switch ATTIVO: nessuna puntata dal vivo.\n" : "Nessun HALT in corso.\n") + conto
: $"HALT in corso: {halt}\n{conto}";
ResumeHaltButton.Visibility = string.IsNullOrEmpty(halt) ? Visibility.Collapsed : Visibility.Visible;
}
catch { }
finally { _riskSyncing = false; }
}
public static readonly RoutedEvent KillSwitchChangedEvent = EventManager.RegisterRoutedEvent(
"KillSwitchChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler KillSwitchChanged
{
add { AddHandler(KillSwitchChangedEvent, value); }
remove { RemoveHandler(KillSwitchChangedEvent, value); }
}
/// <summary>Riepilogo delle misure sull'anticipo, mostrato nel riquadro informativo.</summary>
public void SetBidLeadSummary(string text) => BidLeadSummaryText.Text = text;
@@ -117,6 +117,18 @@ namespace AutoBidder
Settings.WriteDossiersCheckBox.IsChecked = settings.WriteAuctionDossiers;
Settings.RawPollsCheckBox.IsChecked = settings.DossierIncludeRawPolls;
Settings.LogRetentionTextBox.Text = settings.LogRetentionDays.ToString();
Settings.DatabaseFileTextBox.Text = AppPaths.DatabaseFile;
Settings.BankrollEnabledCheckBox.IsChecked = settings.BankrollManagerEnabled;
Settings.MaxBidsPerAuctionTextBox.Text = settings.MaxBidsPerAuction.ToString();
Settings.MaxBidsPerSessionTextBox.Text = settings.MaxBidsPerSession.ToString();
Settings.DailyBudgetTextBox.Text = settings.DailyBudgetEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.DailyStopLossTextBox.Text = settings.DailyStopLossEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.MaxDrawdownTextBox.Text = settings.MaxDrawdownEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.MaxConcurrentTextBox.Text = settings.MaxConcurrentActiveAuctions.ToString();
Settings.TransactionFeeTextBox.Text = settings.TransactionFeeEuro.ToString("F2", System.Globalization.CultureInfo.CurrentCulture);
Settings.HedgeCheckBox.IsChecked = settings.BuyNowHedgeEnabled;
Settings.RefreshRiskStatus();
RefreshBidLeadSummary();
@@ -440,6 +452,21 @@ namespace AutoBidder
if (int.TryParse(Settings.LogRetentionTextBox.Text?.Trim(), out var retention) && retention >= 0)
settings.LogRetentionDays = retention;
var previousDb = AppPaths.DatabaseFile;
settings.DatabasePath = NormalizeFolderChoice(Settings.DatabaseFileTextBox.Text, AppPaths.DatabaseFile, settings.DatabasePath);
if (!string.Equals(previousDb, settings.DatabasePath.Length > 0 ? settings.DatabasePath : AppPaths.DatabaseFile, StringComparison.OrdinalIgnoreCase))
Log("[DATI] Percorso del database cambiato: vale dal prossimo avvio", LogLevel.Warning);
settings.BankrollManagerEnabled = Settings.BankrollEnabledCheckBox.IsChecked ?? true;
settings.MaxBidsPerAuction = ReadBounded(Settings.MaxBidsPerAuctionTextBox.Text, settings.MaxBidsPerAuction, 0, 100000, "puntate massime per asta", "");
settings.MaxBidsPerSession = ReadBounded(Settings.MaxBidsPerSessionTextBox.Text, settings.MaxBidsPerSession, 0, 1000000, "puntate massime per sessione", "");
settings.DailyBudgetEuro = ReadEuro(Settings.DailyBudgetTextBox.Text, settings.DailyBudgetEuro, "tetto di spesa del giorno");
settings.DailyStopLossEuro = ReadEuro(Settings.DailyStopLossTextBox.Text, settings.DailyStopLossEuro, "stop-loss del giorno");
settings.MaxDrawdownEuro = ReadEuro(Settings.MaxDrawdownTextBox.Text, settings.MaxDrawdownEuro, "drawdown massimo");
settings.MaxConcurrentActiveAuctions = ReadBounded(Settings.MaxConcurrentTextBox.Text, settings.MaxConcurrentActiveAuctions, 0, 1000, "aste in gioco insieme", "");
settings.TransactionFeeEuro = ReadEuro(Settings.TransactionFeeTextBox.Text, settings.TransactionFeeEuro, "fee di transazione");
settings.BuyNowHedgeEnabled = Settings.HedgeCheckBox.IsChecked ?? true;
Utilities.SettingsManager.Save(settings);
ApplyDataFolderSettings(settings, previousData, previousStats);
@@ -488,6 +515,17 @@ namespace AutoBidder
return current;
}
/// <summary>Un importo in euro, con virgola o punto; se non valido si tiene il valore attuale.</summary>
private double ReadEuro(string? text, double current, string label)
{
var t = (text ?? "").Trim().Replace(',', '.');
if (double.TryParse(t, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v) && v >= 0 && v < 1_000_000)
return Math.Round(v, 2);
Log($"[ERRORE] Valore {label} non valido: mantenuto {current:F2} €", LogLevel.Error);
return current;
}
/// <summary>
/// Mostra lo stato del motore: aggancio all'orologio del server e traffico prodotto.
/// </summary>
@@ -72,18 +72,16 @@ namespace AutoBidder
// mensile che la duplicava non c'è più (vedi AuctionDetailStore).
var detail = BuildDetail(auction, state, record);
// 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. 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);
// La registrazione si chiude con il riepilogo: da lì in poi la riga è una
// storia completa, ed è così che l'analisi sa di poterla usare.
Data.AuctionRecorder.Close(auction, detail, state);
ProductStatsStore.RecordCompleted(record, detail);
// L'apprendimento impara subito da questa asta: il dossier è già su disco
// (Close lo svuota) e lo storico è già scritto. Fuori dal percorso di
// chiusura, che deve restare corto.
_ = System.Threading.Tasks.Task.Run(() => Ml.LearningService.OnAuctionClosed(record, dossierPath));
_ = System.Threading.Tasks.Task.Run(() => Ml.LearningService.OnAuctionClosed(record));
NotifyOutcome(record, settings);
@@ -238,8 +236,7 @@ namespace AutoBidder
BidCostEuro = auction.BidCost,
ObservedFromStart = auction.ObservedFromStart,
ObservedToEnd = auction.ObservedToEnd,
DossierPath = AuctionDossier.For(auction.AuctionId)?.Path
ObservedToEnd = auction.ObservedToEnd
};
}
+1 -1
View File
@@ -205,7 +205,7 @@ namespace AutoBidder
/// </summary>
private void ApplyDataFolderSettings(AppSettings settings, string previousData, string previousStats)
{
AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder);
AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder, settings.DatabasePath);
AppPaths.EnsureFolders();
// I campi tornano a mostrare i percorsi effettivi: chi ne ha svuotato uno deve
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.IO;
using System.Windows;
using AutoBidder.Services;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Kill-switch dalla barra del monitor e percorso del database dalle impostazioni.
/// </summary>
public partial class MainWindow
{
private void AuctionMonitor_KillSwitchChanged(object sender, RoutedEventArgs e)
{
var on = AuctionMonitor.KillSwitchToggle.IsChecked == true;
RiskManager.SetKillSwitch(on);
Log(on
? "[RISCHIO] Kill-switch ATTIVO: nessuna puntata dal vivo finché resta acceso (file KILL_SWITCH)"
: "[RISCHIO] Kill-switch tolto: le puntate dal vivo possono ripartire",
on ? LogLevel.Warning : LogLevel.Success);
try { Settings.RefreshRiskStatus(); } catch { }
}
/// <summary>Il kill-switch cambiato dalle Impostazioni: l'interruttore in barra deve seguirlo.</summary>
private void Settings_KillSwitchChanged(object sender, RoutedEventArgs e) => RefreshKillSwitchToggle();
/// <summary>Allinea l'interruttore in barra allo stato vero del file sentinella.</summary>
private void RefreshKillSwitchToggle()
{
try { AuctionMonitor.KillSwitchToggle.IsChecked = RiskManager.KillSwitchActive; } catch { }
}
private void Settings_BrowseDatabaseFileClicked(object sender, RoutedEventArgs e)
{
try
{
var current = AppPaths.DatabaseFile;
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = "Scegli dove tenere il database",
FileName = Path.GetFileName(current),
InitialDirectory = Path.GetDirectoryName(current),
Filter = "Database SQLite (*.sqlite)|*.sqlite|Tutti i file|*.*",
OverwritePrompt = false,
CheckPathExists = true
};
if (dialog.ShowDialog(this) == true)
Settings.DatabaseFileTextBox.Text = dialog.FileName;
}
catch (Exception ex)
{
Log($"[DATI] Scelta del database non riuscita: {ex.Message}", LogLevel.Error);
}
}
private void Settings_OpenDatabaseFolderClicked(object sender, RoutedEventArgs e)
=> OpenFolder(Path.GetDirectoryName(AppPaths.DatabaseFile) ?? AppPaths.Root);
}
}
+731
View File
@@ -0,0 +1,731 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using AutoBidder.Utilities;
namespace AutoBidder.Data
{
/// <summary>
/// L'archivio dell'applicazione: un solo file SQLite con tutto ciò che si osserva e
/// si decide — aste, puntate, interrogazioni, reset, nostre puntate, decisioni del
/// motore, misure di rete, puntatori, sessioni.
///
/// <para><b>Perché un database.</b> Prima c'erano un file JSON per asta (i dossier),
/// un file JSON con lo storico compatto, uno con le statistiche per prodotto, uno con
/// le misure dell'anticipo: quattro copie parziali degli stessi fatti, da tenere
/// allineate, e nessuna interrogabile. Qui ogni fatto sta in una riga sola, con un
/// indice, e una domanda come «quante puntate manuali fra le 10 e le 13 sui buoni da
/// 10 € sono rimaste senza risposta?» è una riga di SQL invece di una lettura di otto
/// gigabyte.</para>
///
/// <para><b>Come scrive.</b> Mai sul thread che segue le aste. Ogni scrittura finisce
/// in una coda e un thread dedicato la svuota a blocchi, in una transazione ogni
/// qualche centinaio di righe: SQLite fa migliaia di inserimenti al secondo così, e
/// nessuno sul percorso della puntata aspetta il disco. Chi deve leggere ciò che ha
/// appena scritto chiama <see cref="Flush"/>.</para>
///
/// <para><b>Come legge.</b> Su una seconda connessione, sotto un lucchetto. Con il
/// giornale in modalità WAL le letture non bloccano la scrittura e viceversa.</para>
///
/// <para><b>Dove sta.</b> In <c>%LocalAppData%\AutoBidder\Database</c>: non in
/// Documenti, che può essere sincronizzata da OneDrive — un file SQLite sotto
/// sincronizzazione è il modo classico di corromperlo.</para>
/// </summary>
public sealed class AuctionDatabase : IDisposable
{
public const int SchemaVersion = 1;
private static readonly object InstanceSync = new();
private static AuctionDatabase? _instance;
/// <summary>Righe di registro (errori di scrittura, migrazioni): chi si aggancia le scrive dove vuole.</summary>
public static event Action<string>? OnLog;
/// <summary>L'archivio dell'applicazione, aperto al primo uso nel percorso di <see cref="AppPaths.DatabaseFile"/>.</summary>
public static AuctionDatabase Instance
{
get
{
lock (InstanceSync)
{
if (_instance == null || _instance._disposed)
_instance = Open(AppPaths.DatabaseFile);
return _instance;
}
}
}
/// <summary>Chiude l'archivio dell'applicazione svuotando la coda. Da chiamare all'uscita.</summary>
public static void CloseInstance()
{
lock (InstanceSync)
{
_instance?.Dispose();
_instance = null;
}
}
private readonly SqliteConnection _writer;
private readonly SqliteConnection _reader;
private readonly object _readSync = new();
// ── Coda di scrittura ────────────────────────────────────────────
private abstract class Pending
{
public long Seq;
}
private sealed class Statement : Pending
{
public string Sql = "";
public object?[] Args = Array.Empty<object?>();
}
private sealed class Batch : Pending
{
public List<(string Sql, object?[] Args)> Ops = new();
}
private sealed class Custom : Pending
{
public Action<SqliteConnection> Action = _ => { };
public Exception? Error;
public ManualResetEventSlim Done = new(false);
}
private readonly Queue<Pending> _queue = new();
private readonly object _queueSync = new();
private long _lastEnqueued;
private long _lastCommitted;
private readonly Thread _writerThread;
private volatile bool _disposed;
private volatile bool _stop;
/// <summary>Scritture eseguite dal momento dell'apertura.</summary>
public long WritesCommitted => Interlocked.Read(ref _writesCommitted);
private long _writesCommitted;
/// <summary>Scritture rifiutate da SQLite (registrate nel log).</summary>
public long WritesFailed => Interlocked.Read(ref _writesFailed);
private long _writesFailed;
public int QueueLength { get { lock (_queueSync) return _queue.Count; } }
/// <summary>Cambia a ogni scrittura sulle aste: chi tiene una copia in memoria la confronta con questo.</summary>
public long AuctionsVersion => Interlocked.Read(ref _auctionsVersion);
private long _auctionsVersion;
public string Path { get; }
public static string LibraryVersion => SqliteConnection.LibraryVersion;
private long _nextDecisionId;
/// <summary>Il prossimo id per <c>bot_decisions</c>: unico, crescente, senza aspettare la scrittura.</summary>
public long NextDecisionId() => Interlocked.Increment(ref _nextDecisionId);
private AuctionDatabase(string path)
{
Path = path;
var folder = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
_writer = new SqliteConnection(path);
Configure(_writer);
Migrate(_writer);
_reader = new SqliteConnection(path);
Configure(_reader);
// Gli id delle decisioni si assegnano qui, non da SQLite: chi registra una
// decisione deve poterla legare alla puntata senza aspettare il disco.
try { _nextDecisionId = _writer.Query("SELECT COALESCE(MAX(decision_id), 0) AS n FROM bot_decisions")[0].Long("n"); }
catch { _nextDecisionId = 0; }
_writerThread = new Thread(WriterLoop)
{
Name = "AutoBidder.Database",
IsBackground = true,
Priority = ThreadPriority.BelowNormal
};
_writerThread.Start();
}
public static AuctionDatabase Open(string path) => new(path);
private static void Configure(SqliteConnection c)
{
c.Execute("PRAGMA journal_mode=WAL");
c.Execute("PRAGMA synchronous=NORMAL");
c.Execute("PRAGMA foreign_keys=ON");
c.Execute("PRAGMA temp_store=MEMORY");
}
// ── Schema ───────────────────────────────────────────────────────
private static void Migrate(SqliteConnection c)
{
var version = (int)c.Query("PRAGMA user_version")[0].Long("user_version");
if (version >= SchemaVersion) return;
if (version < 1)
{
c.ExecuteScript(SchemaV1);
c.Execute("PRAGMA user_version=1");
OnLog?.Invoke($"[DATABASE] Schema creato (versione 1) in {c.Path} — SQLite {LibraryVersion}");
}
}
private const string SchemaV1 = """
CREATE TABLE IF NOT EXISTS meta(
key TEXT PRIMARY KEY,
value TEXT);
-- Un prodotto: ciò che le aste hanno in comune. market_price_est è il valore
-- reale V scelto dall'utente; retail_price_bidoo è il «Compralo Ora».
CREATE TABLE IF NOT EXISTS items(
item_id INTEGER PRIMARY KEY,
product_key TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
category TEXT,
retail_price_bidoo REAL,
market_price_est REAL,
market_price_source TEXT,
shipping REAL,
first_seen_at TEXT NOT NULL,
updated_at TEXT NOT NULL);
-- Un'asta: una riga sola, aggiornata dall'apertura alla chiusura. Le colonne
-- del riepilogo restano NULL finché l'asta è in corso.
CREATE TABLE IF NOT EXISTS auctions(
auction_id TEXT PRIMARY KEY,
item_id INTEGER REFERENCES items(item_id),
product_key TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'normal',
status TEXT NOT NULL DEFAULT 'open',
me TEXT,
app_version TEXT,
added_at TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
opened_at TEXT NOT NULL,
closed_at TEXT,
end_time TEXT,
final_price REAL,
winner_user TEXT,
won_by_me INTEGER NOT NULL DEFAULT 0,
outcome TEXT,
final_status TEXT,
bid_increment REAL NOT NULL DEFAULT 0.01,
bid_fee REAL,
shipping REAL,
transaction_fee REAL,
buy_now_price REAL,
market_value REAL,
has_win_limit INTEGER NOT NULL DEFAULT 0,
win_limit TEXT,
my_bids INTEGER NOT NULL DEFAULT 0,
winner_bids_paid INTEGER,
winner_bids_free INTEGER,
resets INTEGER NOT NULL DEFAULT 0,
distinct_bidders INTEGER NOT NULL DEFAULT 0,
total_observed_bids INTEGER NOT NULL DEFAULT 0,
top_bidder_share REAL,
observed_from_start INTEGER NOT NULL DEFAULT 0,
observed_to_end INTEGER NOT NULL DEFAULT 0,
observed_minutes REAL,
avg_ping_ms REAL,
p95_ping_ms REAL,
polls INTEGER NOT NULL DEFAULT 0,
poll_errors INTEGER NOT NULL DEFAULT 0,
configured_lead_ms INTEGER,
lead_is_manual INTEGER NOT NULL DEFAULT 0,
timer_expired INTEGER NOT NULL DEFAULT 0,
successful_bids INTEGER NOT NULL DEFAULT 0,
failed_bids INTEGER NOT NULL DEFAULT 0,
price_velocity_per_minute REAL,
price_series_json TEXT,
bids_by_user_json TEXT,
config_json TEXT,
abandon_reason TEXT,
learned_at TEXT);
CREATE INDEX IF NOT EXISTS ix_auctions_end ON auctions(end_time);
CREATE INDEX IF NOT EXISTS ix_auctions_product ON auctions(product_key);
CREATE INDEX IF NOT EXISTS ix_auctions_status ON auctions(status);
-- Ogni puntata di ogni utente, come la riporta Bidoo. Il prezzo dopo la
-- puntata la identifica: ogni puntata alza il prezzo di un centesimo esatto.
CREATE TABLE IF NOT EXISTS bids(
bid_id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
username TEXT NOT NULL,
price_after REAL NOT NULL,
bid_type TEXT NOT NULL DEFAULT '—',
is_mine INTEGER NOT NULL DEFAULT 0,
server_ts INTEGER,
seen_t REAL,
local_ts TEXT,
timer_before REAL,
rtt_ms INTEGER,
clock_offset_ms INTEGER,
UNIQUE(auction_id, price_after));
CREATE INDEX IF NOT EXISTS ix_bids_user ON bids(username);
CREATE INDEX IF NOT EXISTS ix_bids_server_ts ON bids(auction_id, server_ts);
-- Le interrogazioni, già coalescenti: una riga quando cambia qualcosa, e
-- comunque una al secondo perché il ping resti misurabile.
CREATE TABLE IF NOT EXISTS polls(
poll_id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
t REAL NOT NULL,
local_ts TEXT,
price REAL,
timer REAL,
status TEXT,
last_bidder TEXT,
is_mine INTEGER NOT NULL DEFAULT 0,
ping_ms INTEGER,
expiry_unix INTEGER,
server_unix INTEGER,
clock_offset_ms INTEGER);
CREATE INDEX IF NOT EXISTS ix_polls_auction ON polls(auction_id, t);
-- Un reset del timer: chiude un ciclo, di cui si sa fin dove è sceso il
-- cronometro e quanto è durato.
CREATE TABLE IF NOT EXISTS resets(
reset_id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
t REAL NOT NULL,
local_ts TEXT,
reset_count INTEGER,
price REAL,
bidder TEXT,
min_timer REAL,
cycle_seconds REAL);
CREATE INDEX IF NOT EXISTS ix_resets_auction ON resets(auction_id, t);
-- Le nostre puntate, con anticipo voluto e ottenuto: la coppia su cui si
-- tara il modello di latenza.
CREATE TABLE IF NOT EXISTS my_bids(
id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
t REAL NOT NULL,
local_ts TEXT,
price REAL,
planned_lead_ms INTEGER,
actual_lead_ms REAL,
ping_ms INTEGER,
rtt_ms INTEGER,
success INTEGER NOT NULL DEFAULT 0,
error TEXT,
bids_used INTEGER,
remaining_bids INTEGER,
decision_id INTEGER);
CREATE INDEX IF NOT EXISTS ix_my_bids_auction ON my_bids(auction_id, t);
-- Il registro dell'asta (strategie, avvisi, errori), riga per riga.
CREATE TABLE IF NOT EXISTS auction_log(
id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL REFERENCES auctions(auction_id) ON DELETE CASCADE,
t REAL NOT NULL,
local_ts TEXT,
level TEXT,
category TEXT,
msg TEXT);
CREATE INDEX IF NOT EXISTS ix_log_auction ON auction_log(auction_id, t);
-- Ogni decisione del motore, in Osserva (shadow) come in Attiva (live):
-- cosa avrebbe fatto o ha fatto, con che stima e per quale motivo.
CREATE TABLE IF NOT EXISTS bot_decisions(
decision_id INTEGER PRIMARY KEY,
auction_id TEXT NOT NULL,
ts TEXT NOT NULL,
t REAL,
price REAL,
timer REAL,
action TEXT NOT NULL,
p_win REAL,
ev_estimate REAL,
model_version TEXT,
policy TEXT,
regime TEXT,
mode TEXT NOT NULL,
executed INTEGER NOT NULL DEFAULT 0,
outcome TEXT,
reason_detail TEXT,
state_json TEXT,
context_key TEXT,
alt_policy TEXT,
alt_action TEXT,
alt_p REAL);
CREATE INDEX IF NOT EXISTS ix_decisions_auction ON bot_decisions(auction_id);
CREATE INDEX IF NOT EXISTS ix_decisions_ts ON bot_decisions(ts);
-- Misure di rete aggregate a finestre di dieci secondi.
CREATE TABLE IF NOT EXISTS network_metrics(
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
rtt_ms INTEGER,
jitter_ms INTEGER,
p95_ms INTEGER,
clock_offset_ms INTEGER,
samples INTEGER,
source TEXT);
CREATE INDEX IF NOT EXISTS ix_network_ts ON network_metrics(ts);
-- Chi punta: aggregato asta per asta alla chiusura.
CREATE TABLE IF NOT EXISTS bidders(
username TEXT PRIMARY KEY,
first_seen TEXT,
last_seen TEXT,
n_auctions INTEGER NOT NULL DEFAULT 0,
n_bids INTEGER NOT NULL DEFAULT 0,
n_auto INTEGER NOT NULL DEFAULT 0,
n_manual INTEGER NOT NULL DEFAULT 0,
win_count INTEGER NOT NULL DEFAULT 0,
aggressiveness_score REAL,
profile_class TEXT);
CREATE TABLE IF NOT EXISTS sessions(
session_id INTEGER PRIMARY KEY,
started_at TEXT NOT NULL,
ended_at TEXT,
username TEXT,
app_version TEXT,
notes TEXT);
-- Uscite ed entrate in euro, per il gestore del rischio: ogni puntata
-- riuscita è una riga, ogni vincita è una riga.
CREATE TABLE IF NOT EXISTS risk_ledger(
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
kind TEXT NOT NULL,
amount_euro REAL NOT NULL,
auction_id TEXT,
note TEXT);
CREATE INDEX IF NOT EXISTS ix_ledger_ts ON risk_ledger(ts);
""";
// ── Scrittura ────────────────────────────────────────────────────
/// <summary>Accoda una scrittura. Torna subito: la esegue il thread del database.</summary>
public void Enqueue(string sql, params object?[] args)
{
if (_disposed) return;
Push(new Statement { Sql = sql, Args = args });
}
/// <summary>Accoda più scritture da eseguire insieme, tutte o nessuna.</summary>
public void EnqueueBatch(IEnumerable<(string Sql, object?[] Args)> ops)
{
if (_disposed) return;
var batch = new Batch();
batch.Ops.AddRange(ops);
if (batch.Ops.Count == 0) return;
Push(batch);
}
/// <summary>
/// Esegue subito, sul thread del database, un'operazione che ha bisogno della
/// connessione (per esempio VACUUM INTO, che non può stare in una transazione).
/// Aspetta che sia finita e rilancia l'eventuale errore.
/// </summary>
public void RunOnWriter(Action<SqliteConnection> action, int timeoutMs = 60000)
{
if (_disposed) throw new ObjectDisposedException(nameof(AuctionDatabase));
var custom = new Custom { Action = action };
Push(custom);
if (!custom.Done.Wait(timeoutMs)) throw new TimeoutException("il database non ha risposto in tempo");
if (custom.Error != null) throw custom.Error;
}
/// <summary>Accoda ed aspetta che sia scritta: per le operazioni amministrative.</summary>
public void ExecuteNow(string sql, params object?[] args)
{
Enqueue(sql, args);
Flush();
}
private void Push(Pending p)
{
lock (_queueSync)
{
p.Seq = ++_lastEnqueued;
_queue.Enqueue(p);
Monitor.PulseAll(_queueSync);
}
}
/// <summary>
/// Aspetta che tutto ciò che è stato accodato finora sia sul disco. Da chiamare
/// prima di leggere qualcosa appena scritto, o prima di chiudere.
/// </summary>
public bool Flush(int timeoutMs = 15000)
{
long target;
lock (_queueSync) target = _lastEnqueued;
var deadline = Environment.TickCount64 + timeoutMs;
lock (_queueSync)
{
while (Interlocked.Read(ref _lastCommitted) < target)
{
var left = deadline - Environment.TickCount64;
if (left <= 0 || _disposed) return Interlocked.Read(ref _lastCommitted) >= target;
Monitor.Wait(_queueSync, (int)Math.Min(left, 500));
}
}
return true;
}
private void WriterLoop()
{
var buffer = new List<Pending>(512);
while (!_stop)
{
buffer.Clear();
lock (_queueSync)
{
while (_queue.Count == 0 && !_stop) Monitor.Wait(_queueSync, 250);
while (_queue.Count > 0 && buffer.Count < 2000) buffer.Add(_queue.Dequeue());
}
if (buffer.Count == 0) continue;
// Le istruzioni semplici si raggruppano in una transazione sola; un
// blocco atomico o un'operazione speciale chiudono il gruppo corrente e
// vanno per conto proprio.
var i = 0;
while (i < buffer.Count)
{
switch (buffer[i])
{
case Statement:
var j = i;
while (j < buffer.Count && buffer[j] is Statement) j++;
RunStatements(buffer, i, j);
i = j;
break;
case Batch b:
RunBatch(b);
i++;
break;
case Custom c:
try { c.Action(_writer); }
catch (Exception ex) { c.Error = ex; }
finally { MarkCommitted(c.Seq); c.Done.Set(); }
i++;
break;
default:
i++;
break;
}
}
}
}
private void RunStatements(List<Pending> buffer, int from, int to)
{
long lastSeq = 0;
var inTx = false;
try
{
_writer.Execute("BEGIN IMMEDIATE");
inTx = true;
for (var k = from; k < to; k++)
{
var s = (Statement)buffer[k];
lastSeq = s.Seq;
try
{
var stmt = _writer.Cached(s.Sql);
stmt.Bind(s.Args);
stmt.RunToEnd();
Interlocked.Increment(ref _writesCommitted);
if (s.Sql.Contains("auctions", StringComparison.Ordinal)) Interlocked.Increment(ref _auctionsVersion);
}
catch (SqliteException ex)
{
// Una riga rifiutata non deve far perdere le altre del gruppo.
Interlocked.Increment(ref _writesFailed);
OnLog?.Invoke($"[DATABASE] Scrittura rifiutata: {ex.Message}");
}
}
_writer.Execute("COMMIT");
inTx = false;
}
catch (Exception ex)
{
OnLog?.Invoke($"[DATABASE] Transazione non riuscita: {ex.Message}");
if (inTx) { try { _writer.Execute("ROLLBACK"); } catch { } }
Interlocked.Add(ref _writesFailed, to - from);
}
finally
{
MarkCommitted(Math.Max(lastSeq, ((Pending)buffer[to - 1]).Seq));
}
}
private void RunBatch(Batch b)
{
var inTx = false;
try
{
_writer.Execute("BEGIN IMMEDIATE");
inTx = true;
foreach (var (sql, args) in b.Ops)
{
var stmt = _writer.Cached(sql);
stmt.Bind(args);
stmt.RunToEnd();
if (sql.Contains("auctions", StringComparison.Ordinal)) Interlocked.Increment(ref _auctionsVersion);
}
_writer.Execute("COMMIT");
inTx = false;
Interlocked.Add(ref _writesCommitted, b.Ops.Count);
}
catch (Exception ex)
{
OnLog?.Invoke($"[DATABASE] Blocco annullato: {ex.Message}");
if (inTx) { try { _writer.Execute("ROLLBACK"); } catch { } }
Interlocked.Add(ref _writesFailed, b.Ops.Count);
}
finally
{
MarkCommitted(b.Seq);
}
}
private void MarkCommitted(long seq)
{
lock (_queueSync)
{
if (seq > _lastCommitted) Interlocked.Exchange(ref _lastCommitted, seq);
Monitor.PulseAll(_queueSync);
}
}
// ── Lettura ──────────────────────────────────────────────────────
public List<SqliteRow> Query(string sql, params object?[] args)
{
if (_disposed) return new List<SqliteRow>();
lock (_readSync) return _reader.Query(sql, args);
}
public SqliteRow? QueryOne(string sql, params object?[] args)
{
var rows = Query(sql, args);
return rows.Count > 0 ? rows[0] : null;
}
public long ScalarLong(string sql, params object?[] args)
{
var v = QueryOne(sql, args)?.At(0);
return v switch { long l => l, double d => (long)d, string s when long.TryParse(s, out var x) => x, _ => 0 };
}
public double ScalarDouble(string sql, params object?[] args)
{
var v = QueryOne(sql, args)?.At(0);
return v switch { long l => l, double d => d, string s when double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) => x, _ => 0 };
}
public string? ScalarString(string sql, params object?[] args) => QueryOne(sql, args)?.At(0)?.ToString();
// ── Manutenzione ─────────────────────────────────────────────────
/// <summary>Copia coerente dell'intero archivio in un altro file (VACUUM INTO).</summary>
public void BackupTo(string destination)
{
Flush();
if (File.Exists(destination)) File.Delete(destination);
var folder = System.IO.Path.GetDirectoryName(destination);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
RunOnWriter(c => c.Execute("VACUUM INTO ?1", destination));
}
/// <summary>Byte occupati sul disco (file principale più giornale WAL).</summary>
public long SizeBytes
{
get
{
long total = 0;
foreach (var f in new[] { Path, Path + "-wal" })
{
try { if (File.Exists(f)) total += new FileInfo(f).Length; } catch { }
}
return total;
}
}
/// <summary>Numero di righe di una tabella.</summary>
public long Count(string table) => ScalarLong($"SELECT COUNT(*) AS n FROM {table}");
/// <summary>
/// Svuota le tabelle delle osservazioni e delle decisioni. Restano i prodotti
/// (portano il valore reale scelto dall'utente) e le sessioni.
/// </summary>
public void WipeObservations()
{
EnqueueBatch(new (string, object?[])[]
{
("DELETE FROM auctions", Array.Empty<object?>()),
("DELETE FROM bot_decisions", Array.Empty<object?>()),
("DELETE FROM network_metrics", Array.Empty<object?>()),
("DELETE FROM bidders", Array.Empty<object?>()),
("DELETE FROM risk_ledger", Array.Empty<object?>())
});
Flush();
try { RunOnWriter(c => c.Execute("VACUUM")); } catch { /* lo spazio si recupera la prossima volta */ }
}
// ── Attrezzi ─────────────────────────────────────────────────────
/// <summary>Le date si scrivono in ISO 8601 UTC: ordinano bene e non dipendono dal fuso.</summary>
public static string Iso(DateTime dt)
{
var utc = dt.Kind switch
{
DateTimeKind.Utc => dt,
DateTimeKind.Local => dt.ToUniversalTime(),
_ => DateTime.SpecifyKind(dt, DateTimeKind.Local).ToUniversalTime()
};
return utc.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
}
public static string Now() => Iso(DateTime.UtcNow);
public void Dispose()
{
if (_disposed) return;
Flush(5000);
_stop = true;
lock (_queueSync) Monitor.PulseAll(_queueSync);
try { _writerThread.Join(3000); } catch { }
_disposed = true;
lock (_readSync) _reader.Dispose();
_writer.Dispose();
}
}
}
+521
View File
@@ -0,0 +1,521 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Data
{
/// <summary>
/// Registra nel database tutto ciò che succede a un'asta mentre la si segue: ogni
/// interrogazione, ogni puntata di ogni utente con l'ora al millisecondo, ogni reset
/// con la profondità del ciclo, ogni nostra puntata con anticipo voluto ed effettivo,
/// ogni riga di registro, e alla fine il riepilogo.
///
/// <para>È l'erede del dossier JSON per asta, con la stessa superficie: il motore
/// chiama gli stessi metodi di prima e non sa che dietro c'è SQLite. Cambia dove
/// finiscono i dati — una riga per tabella invece di una riga per file — e il fatto
/// che da lì si possano interrogare.</para>
///
/// <para>Perché tanto dettaglio: questi dati <b>non esistono a posteriori</b>. Bidoo
/// non espone lo storico dei prezzi né i tempi delle puntate di un'asta conclusa. O li
/// si raccoglie mentre l'asta va avanti, o sono persi — e sono esattamente quelli su
/// cui il modello impara.</para>
///
/// <para>Nessuna scrittura tocca il disco sul thread che segue l'asta: ogni riga va
/// nella coda del database e prosegue.</para>
/// </summary>
public sealed class AuctionRecorder
{
private static readonly ConcurrentDictionary<string, AuctionRecorder> Open = new(StringComparer.Ordinal);
private static readonly JsonSerializerOptions Json = new()
{
WriteIndented = false,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Il nostro nome utente su Bidoo. Lo imposta il motore appena conosce la
/// sessione; finisce sull'asta, così chi rilegge sa quali puntate erano nostre.
/// </summary>
public static volatile string CurrentUsername = "";
private static AuctionDatabase Db => AuctionDatabase.Instance;
private readonly DateTime _startedAt;
private readonly List<int> _pings = new();
private readonly object _sync = new();
private long _polls;
private long _events;
private bool _closed;
// Coalescenza dei poll: si scrive quando cambia qualcosa, e comunque una volta
// al secondo. Misurato sui dossier: l'88,8% delle interrogazioni ha la stessa
// faccia della precedente.
private string? _lastPollSignature;
private DateTime _lastPollWrittenAt = DateTime.MinValue;
// Del ciclo in corso: fin dove è sceso il cronometro e quando è cominciato.
private double _cycleMinTimer = double.MaxValue;
private double _cycleStartedElapsed = -1;
private double _completedCycleMinTimer = double.NaN;
private double _completedCycleSeconds = double.NaN;
private double _lastPollPrice = double.NaN;
private const int MaxPingSamples = 50_000;
public string AuctionId { get; }
/// <summary>Righe scritte finora per quest'asta.</summary>
public long EventCount => _events;
private AuctionRecorder(string auctionId, DateTime startedAt)
{
AuctionId = auctionId;
_startedAt = startedAt;
}
// ── Apertura e chiusura ──────────────────────────────────────────
/// <summary>
/// Apre (o riapre) la registrazione di quest'asta. Riaprendo l'applicazione
/// mentre l'asta è ancora viva si <b>riprende la stessa riga</b>: un'asta seguita
/// a cavallo di un riavvio deve restare una storia sola.
/// </summary>
public static AuctionRecorder? OpenFor(AuctionInfo auction, AppSettings settings)
{
if (auction == null || !settings.WriteAuctionDossiers) return null;
if (string.IsNullOrEmpty(auction.AuctionId)) return null;
return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings));
}
/// <summary>La registrazione già aperta per quest'asta, se c'è.</summary>
public static AuctionRecorder? For(string auctionId) =>
auctionId != null && Open.TryGetValue(auctionId, out var r) ? r : null;
/// <summary>Fa confluire nel database tutte le righe di registro delle aste. Una volta all'avvio.</summary>
public static void CaptureAuctionLogs()
{
AuctionInfo.LogSink = (auction, entry) =>
For(auction.AuctionId)?.Log(
entry.Level.ToString().ToLowerInvariant(),
entry.Category.ToString(),
entry.Message);
}
/// <summary>
/// L'asta è stata tolta dal monitor prima della fine: la riga resta, marcata
/// come interrotta, perché una serie di prezzi che si ferma a metà sembra
/// un'asta finita a quel prezzo.
/// </summary>
public static void Abandon(string auctionId, string reason)
{
if (auctionId == null) return;
if (!Open.TryRemove(auctionId, out var rec)) return;
rec.LogRow("info", "State", $"asta tolta dal monitor prima della conclusione: {reason}");
Db.Enqueue(
"UPDATE auctions SET status = CASE WHEN outcome IS NULL THEN 'abandoned' ELSE status END, " +
"abandon_reason = ?2, closed_at = COALESCE(closed_at, ?3), polls = ?4 WHERE auction_id = ?1",
auctionId, reason, AuctionDatabase.Now(), rec._polls);
}
/// <summary>Aspetta che tutto ciò che è in coda sia sul disco.</summary>
public static void FlushAll() => Db.Flush();
/// <summary>
/// Una decisione del motore, in Osserva (shadow) come in Attiva (live): cosa
/// avrebbe fatto o ha fatto, con che stima e per quale motivo. Restituisce l'id,
/// che la puntata porta con sé. L'esito si scrive alla chiusura dell'asta.
/// </summary>
public static long RecordDecision(
AuctionInfo auction, AuctionState state, string action, double? pWin, double? ev,
string? reason, string mode, string? regime, string? policy, double? leadMs,
string? contextKey = null, string? altAction = null, double? altP = null)
{
var id = Db.NextDecisionId();
var rec = For(auction.AuctionId);
var stateJson = JsonSerializer.Serialize(new
{
price = state.Price,
timer = Math.Round(state.Timer, 2),
lastBidder = string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder,
pingMs = state.PollingLatencyMs,
bidsUsed = auction.BidsUsedOnThisAuction,
resets = auction.ResetCount,
leadMs = leadMs ?? auction.BidBeforeDeadlineMs,
duel = auction.AutoBidDuelDetected,
autoResponses = auction.AutoResponsesInARow,
recentBidders = auction.SnapshotRecentBids(10).Select(b => b.Username).Distinct(StringComparer.OrdinalIgnoreCase).Count()
}, Json);
Db.Enqueue(
"INSERT INTO bot_decisions(decision_id, auction_id, ts, t, price, timer, action, p_win, ev_estimate, model_version, policy, regime, mode, reason_detail, state_json, context_key, alt_policy, alt_action, alt_p) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
id, auction.AuctionId, AuctionDatabase.Now(), rec?.Elapsed(), state.Price, Math.Round(state.Timer, 2), action,
pWin, ev, Ml.LearningService.ModelVersion, policy, regime, mode, reason, stateJson,
contextKey, altAction == null ? null : "bandit", altAction, altP);
return id;
}
public static int OpenCount => Open.Count;
private static AuctionRecorder Create(AuctionInfo auction, AppSettings settings)
{
var rec = new AuctionRecorder(auction.AuctionId, DateTime.Now);
var now = AuctionDatabase.Now();
var productKey = ProductKeyHelper.GenerateProductKey(auction.Name);
var config = JsonSerializer.Serialize(new
{
state = auction.State.ToString(),
bidBeforeDeadlineMs = auction.BidBeforeDeadlineMs > 0 ? auction.BidBeforeDeadlineMs : settings.DefaultBidBeforeDeadlineMs,
minPrice = auction.MinPrice,
maxPrice = auction.MaxPrice,
maxBids = auction.MaxClicks,
minResets = auction.MinResets,
maxResets = auction.MaxResets,
bidLeadIsManual = auction.BidLeadIsManual,
adaptiveLead = settings.AdaptiveLeadEnabled,
leadMinMs = settings.LeadMinMs,
leadMaxMs = settings.LeadMaxMs,
valueCheckEnabled = settings.ValueCheckEnabled,
minSavingsPercentage = settings.MinSavingsPercentage,
rawPollsIncluded = settings.DossierIncludeRawPolls
}, Json);
var me = string.IsNullOrEmpty(CurrentUsername) ? null : CurrentUsername;
Db.EnqueueBatch(new (string, object?[])[]
{
("INSERT INTO items(product_key, title, retail_price_bidoo, shipping, first_seen_at, updated_at) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?5) " +
"ON CONFLICT(product_key) DO UPDATE SET " +
" title = CASE WHEN excluded.title != '' THEN excluded.title ELSE title END, " +
" retail_price_bidoo = COALESCE(excluded.retail_price_bidoo, retail_price_bidoo), " +
" shipping = COALESCE(excluded.shipping, shipping), updated_at = excluded.updated_at",
new object?[] { productKey, auction.Name ?? "", auction.BuyNowPrice, auction.ShippingCost, now }),
("INSERT INTO auctions(auction_id, item_id, product_key, name, url, status, me, app_version, " +
" added_at, first_seen_at, opened_at, bid_fee, shipping, buy_now_price, market_value, has_win_limit, win_limit, " +
" configured_lead_ms, lead_is_manual, config_json) " +
"VALUES(?1, (SELECT item_id FROM items WHERE product_key = ?2), ?2, ?3, ?4, 'open', ?5, ?6, ?7, ?8, ?8, ?9, ?10, ?11, " +
" (SELECT market_price_est FROM items WHERE product_key = ?2), ?12, ?13, ?14, ?15, ?16) " +
"ON CONFLICT(auction_id) DO UPDATE SET " +
" me = COALESCE(excluded.me, me), app_version = excluded.app_version, " +
" buy_now_price = COALESCE(excluded.buy_now_price, buy_now_price), " +
" shipping = COALESCE(excluded.shipping, shipping), " +
" configured_lead_ms = excluded.configured_lead_ms, lead_is_manual = excluded.lead_is_manual, " +
" config_json = excluded.config_json",
new object?[]
{
auction.AuctionId, productKey, auction.Name ?? "", auction.OriginalUrl ?? "", me, AppInfo.Version,
AuctionDatabase.Iso(auction.AddedAt), now, auction.BidCost, auction.ShippingCost, auction.BuyNowPrice,
auction.HasWinLimit, auction.WinLimitDescription,
auction.BidBeforeDeadlineMs > 0 ? auction.BidBeforeDeadlineMs : settings.DefaultBidBeforeDeadlineMs,
auction.BidLeadIsManual, config
})
});
rec.LogRow("info", "State", "registrazione aperta");
return rec;
}
/// <summary>
/// Chiude la registrazione con il riepilogo. Da qui in avanti la riga non cambia
/// più: è ciò che dice a chi analizza che la storia è completa.
/// </summary>
public static void Close(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
if (auction == null) return;
if (!Open.TryRemove(auction.AuctionId, out var rec)) return;
rec.WriteSummary(auction, detail, finalState);
}
private void WriteSummary(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
lock (_sync)
{
if (_closed) return;
_closed = true;
}
var pings = PingStats();
var series = JsonSerializer.Serialize(detail.PriceSeries.Select(p => new { t = Math.Round(p.T, 2), price = p.Price }), Json);
var byUser = JsonSerializer.Serialize(detail.BidsByUser, Json);
Db.EnqueueBatch(new (string, object?[])[]
{
("UPDATE auctions SET status = 'closed', closed_at = ?2, end_time = ?3, outcome = ?4, won_by_me = ?5, winner_user = ?6, " +
" final_price = ?7, final_status = ?8, buy_now_price = COALESCE(?9, buy_now_price), shipping = COALESCE(?10, shipping), " +
" bid_fee = ?11, my_bids = ?12, resets = ?13, distinct_bidders = ?14, total_observed_bids = ?15, top_bidder_share = ?16, " +
" observed_from_start = ?17, observed_to_end = ?18, observed_minutes = ?19, first_seen_at = ?20, " +
" avg_ping_ms = ?21, p95_ping_ms = ?22, polls = ?23, poll_errors = ?24, configured_lead_ms = ?25, lead_is_manual = ?26, " +
" timer_expired = ?27, successful_bids = ?28, failed_bids = ?29, price_velocity_per_minute = ?30, " +
" price_series_json = ?31, bids_by_user_json = ?32 " +
"WHERE auction_id = ?1",
new object?[]
{
AuctionId, AuctionDatabase.Now(), AuctionDatabase.Iso(detail.EndedAt), detail.Outcome, detail.WonByMe, detail.Winner,
detail.FinalPrice, finalState?.Status.ToString(), detail.BuyNowPrice, detail.ShippingCost,
auction.BidCost, detail.MyBids, detail.Resets, detail.DistinctBidders, detail.TotalObservedBids, Math.Round(detail.TopBidderShare, 4),
auction.ObservedFromStart, auction.ObservedToEnd, Math.Round(detail.ObservedMinutes, 2), AuctionDatabase.Iso(detail.FirstSeenAt),
pings.Avg, pings.P95, _polls, detail.PollErrors, detail.ConfiguredLeadMs, auction.BidLeadIsManual,
auction.TimerExpiredCount, auction.SuccessfulBidCount, auction.FailedBidCount, Math.Round(detail.PriceVelocityPerMinute, 4),
series, byUser
}),
// Chi ha puntato in quest'asta: aggregato una volta, alla chiusura.
("INSERT INTO bidders(username, first_seen, last_seen, n_auctions, n_bids, n_auto, n_manual, win_count) " +
"SELECT username, MIN(local_ts), MAX(local_ts), 1, COUNT(*), " +
" SUM(CASE WHEN bid_type = 'Auto' THEN 1 ELSE 0 END), SUM(CASE WHEN bid_type = 'Manuale' THEN 1 ELSE 0 END), 0 " +
"FROM bids WHERE auction_id = ?1 GROUP BY username " +
"ON CONFLICT(username) DO UPDATE SET last_seen = excluded.last_seen, first_seen = COALESCE(first_seen, excluded.first_seen), " +
" n_auctions = n_auctions + 1, n_bids = n_bids + excluded.n_bids, n_auto = n_auto + excluded.n_auto, n_manual = n_manual + excluded.n_manual",
new object?[] { AuctionId }),
("UPDATE bidders SET win_count = win_count + 1 WHERE username = ?1 AND ?1 != ''",
new object?[] { detail.Winner ?? "" }),
("UPDATE items SET retail_price_bidoo = COALESCE(?2, retail_price_bidoo), shipping = COALESCE(?3, shipping), updated_at = ?4 " +
"WHERE product_key = ?1",
new object?[] { detail.ProductKey, detail.BuyNowPrice, detail.ShippingCost, AuctionDatabase.Now() }),
// L'esito di ogni decisione, ora che si sa come è finita. Per una puntata
// partita davvero il prezzo dopo di lei era price + 0,01; per una
// decisione shadow o un NO-OP il prezzo è quello del momento. Se il prezzo
// finale non l'ha superato, nessuno ha più puntato: quella sarebbe stata
// (o è stata) la puntata vincente.
("UPDATE bot_decisions SET outcome = CASE " +
" WHEN price + 0.01 >= ?2 - 0.005 THEN 'won' ELSE 'answered' END " +
"WHERE auction_id = ?1 AND executed = 1 AND action = 'BID' AND (outcome IS NULL OR outcome = 'sent')",
new object?[] { AuctionId, detail.FinalPrice }),
("UPDATE bot_decisions SET outcome = CASE " +
" WHEN price >= ?2 - 0.005 THEN (CASE WHEN action = 'BID' THEN 'would_win' ELSE 'missed' END) " +
" ELSE (CASE WHEN action = 'BID' THEN 'would_be_answered' ELSE 'right_skip' END) END " +
"WHERE auction_id = ?1 AND executed = 0 AND outcome IS NULL",
new object?[] { AuctionId, detail.FinalPrice })
});
_events++;
}
// ── Eventi ───────────────────────────────────────────────────────
/// <summary>Una singola interrogazione a Bidoo.</summary>
public void Poll(AuctionState state, bool includeRaw)
{
_polls++;
lock (_sync)
{
if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs);
var now = Elapsed();
if (!double.IsNaN(_lastPollPrice) && state.Price > _lastPollPrice)
{
_completedCycleMinTimer = _cycleMinTimer == double.MaxValue ? double.NaN : _cycleMinTimer;
_completedCycleSeconds = _cycleStartedElapsed >= 0 ? now - _cycleStartedElapsed : double.NaN;
_cycleMinTimer = double.MaxValue;
_cycleStartedElapsed = now;
}
else if (_cycleStartedElapsed < 0)
{
_cycleStartedElapsed = now;
}
_lastPollPrice = state.Price;
if (state.Timer > 0 && state.Timer < _cycleMinTimer) _cycleMinTimer = state.Timer;
}
NetworkSampler.Note(state);
if (!includeRaw) return;
var signature = string.Concat(
state.Price.ToString("F2"), '|', state.LastBidder, '|', state.Status, '|',
(int)state.Timer, '|', state.IsMyBid ? '1' : '0');
var wall = DateTime.Now;
if (signature == _lastPollSignature && (wall - _lastPollWrittenAt).TotalMilliseconds < 1000)
return;
_lastPollSignature = signature;
_lastPollWrittenAt = wall;
Write("INSERT INTO polls(auction_id, t, local_ts, price, timer, status, last_bidder, is_mine, ping_ms, expiry_unix, server_unix, clock_offset_ms) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
AuctionId, Elapsed(), AuctionDatabase.Now(), state.Price, Math.Round(state.Timer, 2), state.Status.ToString(),
string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder, state.IsMyBid, state.PollingLatencyMs,
state.ExpiryUnixSeconds, state.ServerUnixSeconds, NetworkSampler.ClockOffsetMs(state));
}
/// <summary>Una puntata altrui (o nostra, vista dallo storico), come l'ha riportata Bidoo.</summary>
public void ForeignBid(BidHistoryEntry entry, double price)
{
double? timerBefore;
lock (_sync) timerBefore = _cycleMinTimer == double.MaxValue ? null : Math.Round(_cycleMinTimer, 2);
// Il tipo dichiarato dal server vince su quello ignoto di una riga già presente;
// una puntata ricostruita da un cambio di prezzo non sovrascrive mai un tipo vero.
Write("INSERT INTO bids(auction_id, username, price_after, bid_type, is_mine, server_ts, seen_t, local_ts, timer_before) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " +
"ON CONFLICT(auction_id, price_after) DO UPDATE SET " +
" bid_type = CASE WHEN excluded.bid_type != '—' THEN excluded.bid_type ELSE bid_type END, " +
" server_ts = COALESCE(NULLIF(excluded.server_ts, 0), server_ts), " +
" is_mine = MAX(is_mine, excluded.is_mine), " +
" username = CASE WHEN excluded.username != '' THEN excluded.username ELSE username END",
AuctionId, entry.Username ?? "", price, entry.BidType ?? BidHistoryEntry.TipoSconosciuto, entry.IsMyBid,
entry.Timestamp > 0 ? entry.Timestamp : (object?)null, Elapsed(), AuctionDatabase.Now(), timerBefore);
}
/// <summary>Una mia puntata, con tutto ciò che serve a giudicarla.</summary>
public void MyBid(
double price,
int plannedLeadMs,
double actualLeadMs,
int pingMs,
bool success,
string? error,
int? bidsUsed,
int? remainingBids,
int rttMs = 0,
long? decisionId = null)
{
Write("INSERT INTO my_bids(auction_id, t, local_ts, price, planned_lead_ms, actual_lead_ms, ping_ms, rtt_ms, success, error, bids_used, remaining_bids, decision_id) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
AuctionId, Elapsed(), AuctionDatabase.Now(), price, plannedLeadMs, Math.Round(actualLeadMs, 1), pingMs, rttMs,
success, error, bidsUsed, remainingBids, decisionId);
if (decisionId is { } id)
Db.Enqueue("UPDATE bot_decisions SET executed = 1, outcome = ?2 WHERE decision_id = ?1", id, success ? "sent" : "failed:" + (error ?? "?"));
}
/// <summary>Il timer è stato azzerato da una puntata: l'asta continua.</summary>
public void Reset(int resetCount, double price, string? bidder)
{
double? minTimer, cycleSeconds;
lock (_sync)
{
minTimer = double.IsNaN(_completedCycleMinTimer) ? null : Math.Round(_completedCycleMinTimer, 2);
cycleSeconds = double.IsNaN(_completedCycleSeconds) ? null : Math.Round(_completedCycleSeconds, 2);
_completedCycleMinTimer = double.NaN;
_completedCycleSeconds = double.NaN;
}
Write("INSERT INTO resets(auction_id, t, local_ts, reset_count, price, bidder, min_timer, cycle_seconds) " +
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
AuctionId, Elapsed(), AuctionDatabase.Now(), resetCount, price, bidder, minTimer, cycleSeconds);
}
/// <summary>Una riga del registro dell'asta.</summary>
public void Log(string level, string category, string message)
{
// Le righe "[RESET #n]" ripetono l'evento reset che sta già nella sua tabella.
if (message != null && message.StartsWith("[RESET #", StringComparison.Ordinal)) return;
LogRow(level, category, message ?? "");
}
/// <summary>Cambio di modo: Ferma, Osserva, Attiva.</summary>
public void StateChanged(string from, string to) => LogRow("info", "State", $"{from} → {to}");
private void LogRow(string level, string category, string message)
{
Write("INSERT INTO auction_log(auction_id, t, local_ts, level, category, msg) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
AuctionId, Elapsed(), AuctionDatabase.Now(), level, category, message);
}
// ── Interno ──────────────────────────────────────────────────────
private void Write(string sql, params object?[] args)
{
if (_closed) return;
Db.Enqueue(sql, args);
_events++;
}
internal double Elapsed() => Math.Round((DateTime.Now - _startedAt).TotalSeconds, 3);
private (int Count, double Avg, int P95) PingStats()
{
int[] copy;
lock (_sync) copy = _pings.Where(p => p > 0).ToArray();
if (copy.Length == 0) return (0, 0, 0);
Array.Sort(copy);
return (copy.Length, Math.Round(copy.Average(), 1), copy[Math.Min(copy.Length - 1, (int)(copy.Length * 0.95))]);
}
}
/// <summary>
/// Misure di rete aggregate: una riga ogni dieci secondi con mediana, p95, jitter e
/// scarto dell'orologio, su tutte le interrogazioni di tutte le aste. Una riga per
/// poll sarebbero milioni di righe uguali; una ogni dieci secondi racconta la stessa
/// storia in un decimillesimo dello spazio.
/// </summary>
public static class NetworkSampler
{
private static readonly object Sync = new();
private static readonly List<int> _pings = new(512);
private static long _windowStartedTicks = Environment.TickCount64;
private static int _minOffset = int.MaxValue;
private const int WindowMs = 10_000;
/// <summary>
/// Scarto fra il nostro orologio e quello del server, in ms, per questa risposta.
/// Bidoo dichiara i secondi interi, quindi il valore è quantizzato: il minimo su
/// una finestra converge al confine reale del secondo.
/// </summary>
public static int ClockOffsetMs(AuctionState state)
{
if (state.ServerUnixSeconds <= 0) return 0;
var localMs = new DateTimeOffset(state.SnapshotTime.Kind == DateTimeKind.Utc ? state.SnapshotTime : state.SnapshotTime.ToUniversalTime())
.ToUnixTimeMilliseconds() - state.PollingLatencyMs / 2;
return (int)Math.Clamp(localMs - state.ServerUnixSeconds * 1000L, int.MinValue / 2, int.MaxValue / 2);
}
public static void Note(AuctionState state)
{
if (state.PollingLatencyMs <= 0) return;
List<int>? flush = null;
int offset = 0;
lock (Sync)
{
_pings.Add(state.PollingLatencyMs);
var o = ClockOffsetMs(state);
if (state.ServerUnixSeconds > 0 && o < _minOffset) _minOffset = o;
if (Environment.TickCount64 - _windowStartedTicks >= WindowMs)
{
flush = new List<int>(_pings);
offset = _minOffset == int.MaxValue ? 0 : _minOffset;
_pings.Clear();
_minOffset = int.MaxValue;
_windowStartedTicks = Environment.TickCount64;
}
}
if (flush == null || flush.Count == 0) return;
flush.Sort();
var p50 = flush[flush.Count / 2];
var p95 = flush[Math.Min(flush.Count - 1, (int)(flush.Count * 0.95))];
AuctionDatabase.Instance.Enqueue(
"INSERT INTO network_metrics(ts, rtt_ms, jitter_ms, p95_ms, clock_offset_ms, samples, source) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'poll')",
AuctionDatabase.Now(), p50, p95 - p50, p95, offset, flush.Count);
}
}
}
+495
View File
@@ -0,0 +1,495 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace AutoBidder.Data
{
/// <summary>
/// SQLite senza librerie: si chiama direttamente <c>winsqlite3.dll</c>, la copia di
/// SQLite che Windows 10 e 11 tengono in <c>System32</c> per i propri componenti.
///
/// <para><b>Perché così.</b> L'applicazione non deve dipendere da pacchetti esterni.
/// Un provider ADO.NET porterebbe con sé quattro assembly e una DLL nativa; qui ci
/// sono le quindici funzioni C che servono davvero, avvolte in due classi che non
/// fanno niente di più di quello che il programma usa: aprire, preparare, legare
/// parametri, scorrere righe, chiudere.</para>
///
/// <para><b>Fili.</b> Ogni connessione è usata da un thread alla volta: il database
/// scrive da un thread suo e legge sotto un lucchetto (vedi <see cref="AuctionDatabase"/>).
/// La connessione è comunque aperta in modalità serializzata, per non dipendere dalla
/// disciplina del chiamante.</para>
/// </summary>
internal static class SqliteNative
{
private const string Lib = "winsqlite3.dll";
public const int Ok = 0;
public const int Row = 100;
public const int Done = 101;
public const int Busy = 5;
public const int Locked = 6;
public const int OpenReadWrite = 0x2;
public const int OpenCreate = 0x4;
public const int OpenFullMutex = 0x10000;
public const int TypeInteger = 1;
public const int TypeFloat = 2;
public const int TypeText = 3;
public const int TypeBlob = 4;
public const int TypeNull = 5;
/// <summary>SQLITE_TRANSIENT: SQLite copia il testo legato prima che il buffer venga liberato.</summary>
public static readonly IntPtr Transient = new(-1);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_open_v2(byte[] filename, out IntPtr db, int flags, IntPtr vfs);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_close_v2(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_prepare_v2(IntPtr db, byte[] sql, int nByte, out IntPtr stmt, out IntPtr tail);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_step(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_reset(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_clear_bindings(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_finalize(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_int64(IntPtr stmt, int index, long value);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_double(IntPtr stmt, int index, double value);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_null(IntPtr stmt, int index);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_bind_text(IntPtr stmt, int index, byte[] text, int nBytes, IntPtr destructor);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_column_count(IntPtr stmt);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_column_type(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern long sqlite3_column_int64(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern double sqlite3_column_double(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_column_text(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_column_bytes(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_column_name(IntPtr stmt, int col);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_errmsg(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern long sqlite3_last_insert_rowid(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_changes(IntPtr db);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern int sqlite3_busy_timeout(IntPtr db, int ms);
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr sqlite3_libversion();
public static byte[] Utf8Z(string s)
{
var bytes = Encoding.UTF8.GetBytes(s);
var z = new byte[bytes.Length + 1];
Buffer.BlockCopy(bytes, 0, z, 0, bytes.Length);
return z;
}
public static string? Utf8(IntPtr p) => p == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(p);
}
public sealed class SqliteException : Exception
{
public int Code { get; }
public SqliteException(int code, string message) : base(message) => Code = code;
}
/// <summary>Una connessione. Da usare da un thread alla volta.</summary>
public sealed class SqliteConnection : IDisposable
{
private IntPtr _db;
private readonly Dictionary<string, SqliteStatement> _cache = new(StringComparer.Ordinal);
public string Path { get; }
public static string LibraryVersion
{
get
{
try { return SqliteNative.Utf8(SqliteNative.sqlite3_libversion()) ?? "?"; }
catch (DllNotFoundException) { return "winsqlite3.dll non trovata"; }
}
}
public SqliteConnection(string path)
{
Path = path;
var rc = SqliteNative.sqlite3_open_v2(
SqliteNative.Utf8Z(path), out _db,
SqliteNative.OpenReadWrite | SqliteNative.OpenCreate | SqliteNative.OpenFullMutex,
IntPtr.Zero);
if (rc != SqliteNative.Ok)
{
var msg = _db != IntPtr.Zero ? ErrorMessage() : $"codice {rc}";
if (_db != IntPtr.Zero) SqliteNative.sqlite3_close_v2(_db);
_db = IntPtr.Zero;
throw new SqliteException(rc, $"apertura di {path} non riuscita: {msg}");
}
// Un altro processo (o l'altra connessione) può tenere il file per qualche
// millisecondo: si aspetta invece di fallire.
SqliteNative.sqlite3_busy_timeout(_db, 5000);
}
internal IntPtr Handle => _db;
public string ErrorMessage() => SqliteNative.Utf8(SqliteNative.sqlite3_errmsg(_db)) ?? "errore sconosciuto";
public long LastInsertRowId => SqliteNative.sqlite3_last_insert_rowid(_db);
public int Changes => SqliteNative.sqlite3_changes(_db);
/// <summary>Esegue un'istruzione senza risultati (o ne scarta le righe).</summary>
public void Execute(string sql, params object?[] args)
{
using var stmt = Prepare(sql);
stmt.Bind(args);
stmt.RunToEnd();
}
/// <summary>Esegue più istruzioni separate da punto e virgola, senza parametri.</summary>
public void ExecuteScript(string script)
{
var bytes = SqliteNative.Utf8Z(script);
var offset = 0;
while (offset < bytes.Length - 1)
{
var remaining = new byte[bytes.Length - offset];
Buffer.BlockCopy(bytes, offset, remaining, 0, remaining.Length);
var rc = SqliteNative.sqlite3_prepare_v2(_db, remaining, -1, out var stmt, out var tail);
if (rc != SqliteNative.Ok) throw new SqliteException(rc, ErrorMessage());
// Il puntatore alla coda restituito da SQLite riguarda una copia nativa
// del buffer di cui non abbiamo la base: si ricava quanto è stato
// consumato scandendo fino al primo ';' fuori da apici e commenti, che è
// esattamente dove SQLite si è fermato.
var consumed = IndexAfterStatement(remaining);
if (stmt != IntPtr.Zero)
{
using var s = new SqliteStatement(this, stmt, "");
s.RunToEnd();
}
offset += Math.Max(1, consumed);
}
}
/// <summary>Fino al primo ';' fuori da apici e commenti, incluso.</summary>
private static int IndexAfterStatement(byte[] bytes)
{
var inQuote = false;
var inLineComment = false;
for (var i = 0; i < bytes.Length - 1; i++)
{
var c = (char)bytes[i];
if (inLineComment) { if (c == '\n') inLineComment = false; continue; }
if (c == '\'') inQuote = !inQuote;
else if (!inQuote && c == '-' && i + 1 < bytes.Length && bytes[i + 1] == '-') inLineComment = true;
else if (!inQuote && c == ';') return i + 1;
}
return bytes.Length - 1;
}
/// <summary>Prepara un'istruzione nuova, non condivisa. Chi la chiede la libera.</summary>
public SqliteStatement Prepare(string sql)
{
var rc = SqliteNative.sqlite3_prepare_v2(_db, SqliteNative.Utf8Z(sql), -1, out var stmt, out _);
if (rc != SqliteNative.Ok || stmt == IntPtr.Zero)
throw new SqliteException(rc, $"{ErrorMessage()} — SQL: {sql}");
return new SqliteStatement(this, stmt, sql);
}
/// <summary>
/// Un'istruzione preparata e riusata: le stesse dieci frasi vengono eseguite
/// migliaia di volte, e prepararle ogni volta costerebbe più dell'esecuzione.
/// Viene azzerata prima di ogni uso.
/// </summary>
public SqliteStatement Cached(string sql)
{
if (!_cache.TryGetValue(sql, out var stmt))
{
stmt = Prepare(sql);
stmt.Shared = true;
_cache[sql] = stmt;
}
stmt.Reset();
return stmt;
}
public List<SqliteRow> Query(string sql, params object?[] args)
{
var stmt = Cached(sql);
stmt.Bind(args);
return stmt.ReadAll();
}
public void Dispose()
{
foreach (var s in _cache.Values) s.Shared = false;
foreach (var s in _cache.Values) s.Dispose();
_cache.Clear();
if (_db != IntPtr.Zero)
{
SqliteNative.sqlite3_close_v2(_db);
_db = IntPtr.Zero;
}
}
}
/// <summary>Un'istruzione preparata.</summary>
public sealed class SqliteStatement : IDisposable
{
private readonly SqliteConnection _conn;
private IntPtr _stmt;
private string[]? _columns;
public string Sql { get; }
/// <summary>Appartiene alla cache della connessione: Dispose non la libera.</summary>
internal bool Shared { get; set; }
internal SqliteStatement(SqliteConnection conn, IntPtr stmt, string sql)
{
_conn = conn;
_stmt = stmt;
Sql = sql;
}
public void Reset()
{
SqliteNative.sqlite3_reset(_stmt);
SqliteNative.sqlite3_clear_bindings(_stmt);
}
/// <summary>Lega i parametri posizionali (?1, ?2, …) nell'ordine dato.</summary>
public void Bind(object?[] args)
{
for (var i = 0; i < args.Length; i++)
{
var idx = i + 1;
var rc = args[i] switch
{
null => SqliteNative.sqlite3_bind_null(_stmt, idx),
string s => BindText(idx, s),
bool b => SqliteNative.sqlite3_bind_int64(_stmt, idx, b ? 1 : 0),
int n => SqliteNative.sqlite3_bind_int64(_stmt, idx, n),
long l => SqliteNative.sqlite3_bind_int64(_stmt, idx, l),
double d => double.IsNaN(d) || double.IsInfinity(d)
? SqliteNative.sqlite3_bind_null(_stmt, idx)
: SqliteNative.sqlite3_bind_double(_stmt, idx, d),
float f => SqliteNative.sqlite3_bind_double(_stmt, idx, f),
decimal m => SqliteNative.sqlite3_bind_double(_stmt, idx, (double)m),
DateTime dt => BindText(idx, AuctionDatabase.Iso(dt)),
DateTimeOffset dto => BindText(idx, dto.UtcDateTime.ToString("o")),
Enum e => BindText(idx, e.ToString()),
_ => BindText(idx, args[i]!.ToString() ?? "")
};
if (rc != SqliteNative.Ok)
throw new SqliteException(rc, $"{_conn.ErrorMessage()} — parametro {idx} di: {Sql}");
}
}
private int BindText(int idx, string s)
{
var bytes = Encoding.UTF8.GetBytes(s);
return SqliteNative.sqlite3_bind_text(_stmt, idx, bytes, bytes.Length, SqliteNative.Transient);
}
/// <summary>Avanza di una riga. False alla fine.</summary>
public bool Step()
{
var rc = SqliteNative.sqlite3_step(_stmt);
if (rc == SqliteNative.Row) return true;
if (rc == SqliteNative.Done) return false;
throw new SqliteException(rc, $"{_conn.ErrorMessage()} — SQL: {Sql}");
}
public void RunToEnd()
{
while (Step()) { }
}
public string[] Columns
{
get
{
if (_columns != null) return _columns;
var n = SqliteNative.sqlite3_column_count(_stmt);
_columns = new string[n];
for (var i = 0; i < n; i++)
_columns[i] = SqliteNative.Utf8(SqliteNative.sqlite3_column_name(_stmt, i)) ?? $"c{i}";
return _columns;
}
}
public object? Value(int col)
{
switch (SqliteNative.sqlite3_column_type(_stmt, col))
{
case SqliteNative.TypeInteger: return SqliteNative.sqlite3_column_int64(_stmt, col);
case SqliteNative.TypeFloat: return SqliteNative.sqlite3_column_double(_stmt, col);
case SqliteNative.TypeText:
var p = SqliteNative.sqlite3_column_text(_stmt, col);
var n = SqliteNative.sqlite3_column_bytes(_stmt, col);
return p == IntPtr.Zero ? "" : Marshal.PtrToStringUTF8(p, n);
default: return null;
}
}
public List<SqliteRow> ReadAll()
{
var rows = new List<SqliteRow>();
var columns = Columns;
var index = SqliteRow.IndexFor(columns);
while (Step())
{
var values = new object?[columns.Length];
for (var i = 0; i < values.Length; i++) values[i] = Value(i);
rows.Add(new SqliteRow(index, values));
}
return rows;
}
public void Dispose()
{
if (Shared) return;
if (_stmt != IntPtr.Zero)
{
SqliteNative.sqlite3_finalize(_stmt);
_stmt = IntPtr.Zero;
}
}
}
/// <summary>Una riga letta, con accesso per nome di colonna e conversioni tolleranti.</summary>
public sealed class SqliteRow
{
private readonly Dictionary<string, int> _index;
private readonly object?[] _values;
private static readonly Dictionary<string, Dictionary<string, int>> IndexCache = new(StringComparer.Ordinal);
internal static Dictionary<string, int> IndexFor(string[] columns)
{
var key = string.Join("|", columns);
lock (IndexCache)
{
if (!IndexCache.TryGetValue(key, out var idx))
{
idx = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < columns.Length; i++) idx[columns[i]] = i;
IndexCache[key] = idx;
}
return idx;
}
}
internal SqliteRow(Dictionary<string, int> index, object?[] values)
{
_index = index;
_values = values;
}
public object? this[string column] => _index.TryGetValue(column, out var i) ? _values[i] : null;
/// <summary>Valore per posizione: per i risultati scalari.</summary>
public object? At(int i) => i >= 0 && i < _values.Length ? _values[i] : null;
public bool Has(string column) => _index.ContainsKey(column);
public string Str(string column) => this[column]?.ToString() ?? "";
public string? StrOrNull(string column) => this[column] as string;
public long Long(string column) => this[column] switch
{
long l => l,
double d => (long)d,
string s when long.TryParse(s, out var v) => v,
_ => 0
};
public int Int(string column) => (int)Long(column);
public bool Bool(string column) => Long(column) != 0;
public double Dbl(string column) => this[column] switch
{
double d => d,
long l => l,
string s when double.TryParse(s, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
_ => 0
};
public double? DblOrNull(string column) => this[column] == null ? null : Dbl(column);
public int? IntOrNull(string column) => this[column] == null ? null : Int(column);
public long? LongOrNull(string column) => this[column] == null ? null : Long(column);
/// <summary>Data in UTC, o null. Le date sono scritte in ISO 8601 UTC.</summary>
public DateTime? Date(string column)
{
var s = this[column] as string;
if (string.IsNullOrEmpty(s)) return null;
if (!DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind, out var d))
return null;
return d.Kind switch
{
DateTimeKind.Utc => d,
DateTimeKind.Local => d.ToUniversalTime(),
_ => DateTime.SpecifyKind(d, DateTimeKind.Utc) // scritto senza fuso: era UTC
};
}
}
}
+3 -3
View File
@@ -14,11 +14,11 @@
Foreground="{DynamicResource Brush.Text}"/>
<TextBlock Style="{StaticResource Hint}" Margin="0,6,0,14"
Text="Accanto a ogni voce c'è quanto spazio occupa adesso. Niente viene toccato finché non premi Azzera. Lo storico delle aste concluse viene copiato nella cartella dei backup prima di sparire; il resto no."/>
Text="Accanto a ogni voce c'è quanto spazio occupa adesso. Niente viene toccato finché non premi Azzera. Il database viene copiato per intero nella cartella dei backup prima di essere svuotato; il resto no."/>
<CheckBox x:Name="ChkHistory" Margin="0,4" IsChecked="True"
Foreground="{DynamicResource Brush.Text}"
ToolTip="Il file completed-auctions.json: una riga per asta conclusa. È la base di tutto il resto — griglia Storico, statistiche per prodotto, limiti consigliati. Viene copiato nei backup prima di essere cancellato."
ToolTip="Tutto ciò che il database ha osservato e deciso: aste, puntate di ogni utente, interrogazioni, reset, tue puntate, decisioni del motore, misure di rete, puntatori, contabilità del rischio. I prodotti con il loro valore reale restano. Prima viene fatta una copia completa del database nei backup."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkProducts" Margin="0,4" IsChecked="True"
@@ -38,7 +38,7 @@
<CheckBox x:Name="ChkLegacy" Margin="0,4" IsChecked="True"
Foreground="{DynamicResource Brush.Text}"
ToolTip="Gli archivi mensili aste-AAAA-MM.jsonl delle versioni precedenti: erano una copia del riepilogo che ogni dossier ha già in coda, e l'applicazione non li scrive né li legge più."
ToolTip="I file JSON delle versioni precedenti (storico compatto completed-auctions.json e archivi mensili aste-AAAA-MM): il database li ha sostituiti e l'applicazione non li legge più."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkLearning" Margin="0,4" IsChecked="False"
+2 -2
View File
@@ -41,11 +41,11 @@ namespace AutoBidder.Dialogs
{
if (!IsLoaded) return;
ChkHistory.Content = $"Storico delle aste concluse ({_sizes.HistoryAuctions} aste, {Fmt(_sizes.HistoryBytes)})";
ChkHistory.Content = $"Osservazioni nel database: aste, puntate, interrogazioni, decisioni ({_sizes.HistoryAuctions} aste, {Fmt(_sizes.HistoryBytes)})";
ChkProducts.Content = $"Statistiche per prodotto ({Fmt(_sizes.ProductsBytes)})";
ChkBidLead.Content = $"Misure dell'anticipo effettivo ({_sizes.BidLeadSamples} misure, {Fmt(_sizes.BidLeadBytes)})";
ChkExports.Content = $"Esportazioni ({_sizes.ExportFiles} file, {Fmt(_sizes.ExportBytes)})";
ChkLegacy.Content = $"Archivi mensili delle versioni precedenti ({_sizes.LegacyFiles} file, {Fmt(_sizes.LegacyBytes)})";
ChkLegacy.Content = $"Archivi JSON delle versioni precedenti ({_sizes.LegacyFiles} file, {Fmt(_sizes.LegacyBytes)})";
ChkLearning.Content = $"Apprendimento: modello, profilo, latenza ({Fmt(_sizes.LearningBytes)})";
var o = ReadOptions();
+9 -4
View File
@@ -29,9 +29,10 @@ namespace AutoBidder.Engine
/// <summary>
/// Verdetto delle strategie sulla puntata. Il runner decide <i>quando</i>,
/// questo metodo decide <i>se</i>.
/// questo metodo decide <i>se</i>. In shadow (asta in Osserva) la decisione si
/// registra ma la risposta è sempre «no»: si osserva, non si punta.
/// </summary>
bool EvaluateBid(AuctionInfo auction, AuctionState state);
bool EvaluateBid(AuctionInfo auction, AuctionState state, bool shadow = false);
/// <summary>Invia la puntata e registra l'esito.</summary>
Task FireBidAsync(AuctionInfo auction, AuctionState state, int leadMs, double actualLeadMs, CancellationToken ct);
@@ -263,11 +264,15 @@ namespace AutoBidder.Engine
{
try
{
if (_auction.State != RunState.Active || _host.IsFinished(_auction))
// In Osserva il cecchino arriva fino all'istante di fuoco come in Attiva,
// ma invece di sparare registra cosa avrebbe fatto: è la modalità shadow,
// ed è così che una policy si giudica prima di darle soldi veri.
if (_auction.State == RunState.Stopped || _host.IsFinished(_auction))
{
if (!await Wait.DelayAsync(150, ct).ConfigureAwait(false)) return;
continue;
}
var shadow = _auction.State == RunState.Watch;
var state = _auction.LastState;
var deadline = _auction.DeadlineTicks;
@@ -320,7 +325,7 @@ namespace AutoBidder.Engine
continue;
}
if (!_host.EvaluateBid(_auction, state))
if (!_host.EvaluateBid(_auction, state, shadow))
{
_handledCycle = cycle;
continue;
+35 -22
View File
@@ -1,16 +1,16 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder.Engine.Backtest
{
/// <summary>
/// Rigioca una cartella di dossier e riassume cosa avrebbe fatto il motore, con un
/// anticipo alla volta.
/// Rigioca le aste registrate nel database e riassume cosa avrebbe fatto il motore,
/// con un anticipo alla volta.
///
/// <para>Serve a due cose distinte. La prima è la <b>taratura</b>: si vede subito
/// quante puntate costerebbe ciascun anticipo, e dove sta il gradino oltre il quale
@@ -35,7 +35,6 @@ namespace AutoBidder.Engine.Backtest
public Dictionary<string, int> Blocks { get; init; } = new(StringComparer.Ordinal);
/// <summary>Puntate per asta: mediana e coda.</summary>
public double MedianBidsPerAuction { get; init; }
public int P90BidsPerAuction { get; init; }
public int MaxBidsPerAuction { get; init; }
@@ -49,31 +48,45 @@ namespace AutoBidder.Engine.Backtest
}
/// <summary>
/// Rigioca ogni dossier della cartella con ciascun anticipo indicato.
/// <paramref name="onProgress"/> riceve (fatti, totale).
/// Rigioca ogni asta chiusa del database con ciascun anticipo indicato.
/// <paramref name="onProgress"/> riceve (fatte, totale).
/// </summary>
public static List<Aggregate> Run(
string dossierFolder,
AuctionDatabase db,
IReadOnlyList<int> leads,
AppSettings settings,
string username = "",
int maxFiles = 0,
int maxAuctions = 0,
Action<int, int>? onProgress = null)
{
var files = Directory.Exists(dossierFolder)
? Directory.GetFiles(dossierFolder, "*.jsonl").OrderBy(f => f).ToArray()
: Array.Empty<string>();
db.Flush();
var ids = db.Query("SELECT auction_id FROM auctions WHERE outcome IS NOT NULL ORDER BY end_time")
.Select(r => r.Str("auction_id")).ToList();
if (maxAuctions > 0 && ids.Count > maxAuctions) ids = ids.Skip(ids.Count - maxAuctions).ToList();
if (maxFiles > 0 && files.Length > maxFiles) files = files.Take(maxFiles).ToArray();
var sessions = new List<DossierReader.Session>();
for (var i = 0; i < ids.Count; i++)
{
var s = DossierReader.FromDatabase(db, ids[i]);
if (s != null && s.IsUsable) sessions.Add(s);
}
return Run(sessions, leads, settings, username, onProgress);
}
/// <summary>Rigioca sessioni già in memoria (anche vecchi dossier letti da file).</summary>
public static List<Aggregate> Run(
IReadOnlyList<DossierReader.Session> sessions,
IReadOnlyList<int> leads,
AppSettings settings,
string username = "",
Action<int, int>? onProgress = null)
{
var perLead = leads.ToDictionary(l => l, _ => new List<BacktestRunner.Result>());
for (var i = 0; i < files.Length; i++)
for (var i = 0; i < sessions.Count; i++)
{
DossierReader.Session session;
try { session = DossierReader.ReadFile(files[i]); }
catch (IOException) { continue; }
var session = sessions[i];
if (!session.IsUsable) continue;
var cost = session.Header?.BidCostEuro ?? 0.20;
@@ -84,7 +97,7 @@ namespace AutoBidder.Engine.Backtest
perLead[lead].Add(BacktestRunner.Run(session, options));
}
onProgress?.Invoke(i + 1, files.Length);
onProgress?.Invoke(i + 1, sessions.Count);
}
return leads.Select(lead => Summarize(lead, perLead[lead])).ToList();
@@ -120,7 +133,7 @@ namespace AutoBidder.Engine.Backtest
var sb = new StringBuilder();
var c = CultureInfo.GetCultureInfo("it-IT");
sb.AppendLine("RIGIOCATA SUI DOSSIER DELLE ASTE CONCLUSE");
sb.AppendLine("RIGIOCATA SULLE ASTE CONCLUSE");
sb.AppendLine();
sb.AppendLine(" Il motore punta solo nei cicli che arrivano fino all'anticipo senza che");
sb.AppendLine(" nessun altro abbia puntato. Piu' l'anticipo e' largo, piu' cicli lo");
@@ -160,9 +173,9 @@ namespace AutoBidder.Engine.Backtest
}
sb.AppendLine();
sb.AppendLine(" I blocchi 'pareggio', 'tetto-spesa' e 'tetto-puntate' sono il");
sb.AppendLine(" comportamento voluto: sono le puntate che avrebbero fatto perdere");
sb.AppendLine(" soldi. Gli altri motivi sono strategie difensive, e vanno guardati.");
sb.AppendLine(" I blocchi 'pareggio', 'tetto-spesa' e 'tetto-puntate' sono i paletti");
sb.AppendLine(" dell'utente; 'duello', 'valore-atteso', 'sfogo' e 'sondaggio' sono cio'");
sb.AppendLine(" che il sistema ha imparato o misurato sul momento.");
sb.AppendLine();
sb.AppendLine(" NOTA: la rigiocata misura i costi, non l'esito. Una nostra puntata rimette");
sb.AppendLine(" in gioco l'asta, e come avrebbero reagito gli avversari non e' registrato");
+1 -1
View File
@@ -180,7 +180,7 @@ namespace AutoBidder.Engine.Backtest
continue;
}
var decision = strategy.ShouldPlaceBid(auction, state, options.EffectiveSettings, options.Username);
var decision = strategy.ShouldPlaceBid(auction, state, options.EffectiveSettings, options.Username, allAuctions: null, shadow: true);
if (!decision.ShouldBid)
{
+71 -24
View File
@@ -2,22 +2,20 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoBidder.Data;
namespace AutoBidder.Engine.Backtest
{
/// <summary>
/// Legge un dossier d'asta (JSON Lines) e lo riporta in memoria come sequenza di
/// eventi tipizzati.
/// Porta in memoria un'asta registrata come sequenza di eventi tipizzati, per
/// rigiocarla contro il motore.
///
/// <para>È volutamente separato da chi lo consuma e non conosce il motore: il dossier
/// è un formato scritto altrove e cambierà, quindi la sua interpretazione va tenuta in
/// un punto solo e verificabile su righe scritte a mano.</para>
///
/// <para>Una riga che non si capisce viene <b>saltata</b>, non fa fallire la lettura:
/// i dossier vengono chiusi dal processo mentre gira, quindi l'ultima riga di un file
/// interrotto può benissimo essere monca — e sarebbe assurdo buttare via otto ore di
/// osservazione per l'ultimo mezzo evento.</para>
/// <para>La fonte normale è il database (<see cref="FromDatabase"/>). Resta il lettore
/// dei vecchi dossier JSON Lines (<see cref="Read"/>) per i test scritti a mano e per
/// un'eventuale importazione dell'archivio precedente. Una riga che non si capisce
/// viene saltata, non fa fallire la lettura.</para>
/// </summary>
public static class DossierReader
{
@@ -61,7 +59,7 @@ namespace AutoBidder.Engine.Backtest
int DistinctBidders,
int MyBids);
/// <summary>Un dossier completo.</summary>
/// <summary>Un'asta completa.</summary>
public sealed class Session
{
public Header? Header { get; init; }
@@ -69,13 +67,73 @@ namespace AutoBidder.Engine.Backtest
public List<Poll> Polls { get; init; } = new();
public List<Bid> Bids { get; init; } = new();
/// <summary>Righe che non si è riusciti a interpretare.</summary>
/// <summary>Righe che non si è riusciti a interpretare (solo dai file).</summary>
public int SkippedLines { get; init; }
/// <summary>Un dossier senza intestazione non dice di che asta parla.</summary>
/// <summary>Senza intestazione e interrogazioni non c'è nulla da rigiocare.</summary>
public bool IsUsable => Header != null && Polls.Count > 0;
}
// ── Dal database ─────────────────────────────────────────────────
public static Session? FromDatabase(AuctionDatabase db, string auctionId)
{
var a = db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auctionId);
if (a == null) return null;
var state = "";
try
{
var cfg = a.StrOrNull("config_json");
if (!string.IsNullOrEmpty(cfg))
{
using var doc = JsonDocument.Parse(cfg);
if (doc.RootElement.TryGetProperty("state", out var s) && s.ValueKind == JsonValueKind.String)
state = s.GetString() ?? "";
}
}
catch { }
var header = new Header(
AuctionId: a.Str("auction_id"),
Name: a.Str("name"),
ProductKey: a.Str("product_key"),
BuyNowPrice: a.DblOrNull("buy_now_price"),
BidCostEuro: a.DblOrNull("bid_fee") is { } fee && fee > 0 ? fee : 0.20,
ConfiguredLeadMs: a.Int("configured_lead_ms"),
State: state);
var summary = a.StrOrNull("outcome") == null ? null : new Summary(
Outcome: a.Str("outcome"),
Winner: a.Str("winner_user"),
FinalPrice: a.Dbl("final_price"),
WonByMe: a.Bool("won_by_me"),
Resets: a.Int("resets"),
DistinctBidders: a.Int("distinct_bidders"),
MyBids: a.Int("my_bids"));
var polls = db.Query(
"SELECT t, price, timer, status, last_bidder, is_mine, expiry_unix, server_unix, ping_ms FROM polls " +
"WHERE auction_id = ?1 ORDER BY t", auctionId)
.Select(r => new Poll(
T: r.Dbl("t"), Price: r.Dbl("price"), Timer: r.Dbl("timer"), Status: r.Str("status"),
LastBidder: r.Str("last_bidder"), Mine: r.Bool("is_mine"),
ExpiryUnix: r.Long("expiry_unix"), ServerUnix: r.Long("server_unix"), PingMs: r.Dbl("ping_ms")))
.ToList();
var bids = db.Query(
"SELECT seen_t, username, server_ts, price_after, is_mine FROM bids WHERE auction_id = ?1 " +
"ORDER BY COALESCE(server_ts, 0), price_after", auctionId)
.Select(r => new Bid(
T: r.Dbl("seen_t"), User: r.Str("username"), UnixSeconds: r.LongOrNull("server_ts") ?? 0,
Price: r.Dbl("price_after"), Mine: r.Bool("is_mine")))
.ToList();
return new Session { Header = header, Summary = summary, Polls = polls, Bids = bids };
}
// ── Dai vecchi dossier JSON Lines ────────────────────────────────
public static Session ReadFile(string path) => Read(File.ReadLines(path));
public static Session Read(IEnumerable<string> lines)
@@ -90,7 +148,6 @@ namespace AutoBidder.Engine.Backtest
{
if (string.IsNullOrWhiteSpace(raw)) continue;
// Il primo file scritto porta il segnaposto di codifica in testa.
var line = raw.TrimStart('', ' ', '\t');
if (line.Length == 0 || line[0] != '{') { skipped++; continue; }
@@ -105,8 +162,6 @@ namespace AutoBidder.Engine.Backtest
case "summary": summary = ReadSummary(root); break;
case "poll": polls.Add(ReadPoll(root)); break;
case "bid": bids.Add(ReadBid(root)); break;
// reset e log non servono alla simulazione: il reset è deducibile
// dal cambio di scadenza, e il log è testo per l'utente.
}
}
catch (JsonException)
@@ -125,8 +180,6 @@ namespace AutoBidder.Engine.Backtest
};
}
// ── Lettura dei singoli eventi ───────────────────────────────────
private static Header ReadHeader(JsonElement root)
{
var product = Obj(root, "product");
@@ -174,10 +227,6 @@ namespace AutoBidder.Engine.Backtest
Price: Num(root, "price", 0),
Mine: Bool(root, "mine"));
/// <summary>
/// Le puntate portano l'istante come data ISO con fuso. Serve il secondo unix,
/// perché è nella stessa unità dei timestamp che il motore vede a runtime.
/// </summary>
private static long ParseUnix(string? iso)
{
if (string.IsNullOrWhiteSpace(iso)) return 0;
@@ -188,8 +237,6 @@ namespace AutoBidder.Engine.Backtest
: 0;
}
// ── Accessori tolleranti ─────────────────────────────────────────
private static string? Str(JsonElement owner, string name) =>
owner.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
+5 -1
View File
@@ -148,6 +148,7 @@
StartClicked="AuctionMonitor_StartClicked"
PauseAllClicked="AuctionMonitor_PauseAllClicked"
StopClicked="AuctionMonitor_StopClicked"
KillSwitchChanged="AuctionMonitor_KillSwitchChanged"
ExportClicked="AuctionMonitor_ExportClicked"
AddUrlClicked="AuctionMonitor_AddUrlClicked"
RemoveUrlClicked="AuctionMonitor_RemoveUrlClicked"
@@ -361,7 +362,10 @@
OpenDataFolderClicked="Settings_OpenDataFolderClicked"
OpenStatsFolderClicked="Settings_OpenStatsFolderClicked"
BrowseLogFolderClicked="Settings_BrowseLogFolderClicked"
OpenLogFolderClicked="Settings_OpenLogFolderClicked"/>
OpenLogFolderClicked="Settings_OpenLogFolderClicked"
BrowseDatabaseFileClicked="Settings_BrowseDatabaseFileClicked"
OpenDatabaseFolderClicked="Settings_OpenDatabaseFolderClicked"
KillSwitchChanged="Settings_KillSwitchChanged"/>
</Grid>
</Grid>
</Window>
+1
View File
@@ -102,6 +102,7 @@ namespace AutoBidder
// Inizializza servizi
_auctionMonitor = new AuctionMonitor();
Learning.AuctionsProvider = () => _auctionMonitor.GetAuctions();
RefreshKillSwitchToggle();
// ? NUOVO: Inizializza SessionService
InitializeSessionService();
+85 -38
View File
@@ -2,17 +2,19 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoBidder.Data;
namespace AutoBidder.Ml
{
/// <summary>
/// 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.
/// Porta un'asta conclusa nella forma che serve ad addestrare: la sequenza delle
/// puntate in ordine di tempo dichiarato dal server, chi ha vinto, a quanto.
///
/// <para>È 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>
/// <para>La fonte normale è il database (<see cref="FromDatabase"/>). Resta anche il
/// lettore dei vecchi dossier JSON Lines (<see cref="Read"/>): serve ai test scritti
/// a mano e a un'eventuale importazione dell'archivio precedente.</para>
/// </summary>
public static class DossierTrainingReader
{
@@ -29,23 +31,69 @@ namespace AutoBidder.Ml
public double FinalPrice { get; init; }
public bool HasSummary { get; init; }
/// <summary>Il nostro nome utente, dall'intestazione (dossier recenti) o da fuori.</summary>
/// <summary>Il nostro nome utente, dall'asta o da fuori.</summary>
public string Me { get; init; } = "";
/// <summary>
/// Utilizzabile per l'addestramento: chiusa, con almeno qualche puntata, e con
/// l'ultima puntata registrata che sia davvero del vincitore. Se non lo è, il
/// dossier ha un buco proprio in fondo, e l'etichetta sarebbe sbagliata.
/// l'ultima puntata registrata che sia davvero del vincitore. Se non lo è, la
/// registrazione ha un buco proprio in fondo, e l'etichetta sarebbe sbagliata.
/// </summary>
public bool IsUsable =>
HasSummary && Bids.Count >= 3 && !string.IsNullOrEmpty(Winner) &&
string.Equals(Bids[^1].User, Winner, StringComparison.OrdinalIgnoreCase);
}
/// <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>
// ── Dal database ─────────────────────────────────────────────────
/// <summary>L'asta indicata, dal database. Null se non esiste.</summary>
public static Session? FromDatabase(AuctionDatabase db, string auctionId, string? me = null)
{
var a = db.QueryOne(
"SELECT auction_id, product_key, buy_now_price, added_at, winner_user, final_price, outcome, me " +
"FROM auctions WHERE auction_id = ?1", auctionId);
if (a == null) return null;
var chiSonoIo = a.StrOrNull("me") ?? me ?? "";
var bids = db.Query(
"SELECT username, price_after, bid_type, is_mine, server_ts, seen_t FROM bids " +
"WHERE auction_id = ?1 ORDER BY COALESCE(server_ts, 0), price_after", auctionId)
.Select(r => new Bid(
T: r.Dbl("seen_t"),
User: r.Str("username"),
UnixSeconds: r.LongOrNull("server_ts") ?? 0,
BidType: r.Str("bid_type"),
Mine: r.Bool("is_mine"),
Price: r.Dbl("price_after")))
.ToList();
return Finish(new Session
{
AuctionId = a.Str("auction_id"),
ProductKey = a.Str("product_key"),
BuyNowPrice = a.DblOrNull("buy_now_price"),
AddedAtLocal = (a.Date("added_at") ?? DateTime.MinValue) == DateTime.MinValue
? DateTime.MinValue
: a.Date("added_at")!.Value.ToLocalTime(),
Bids = bids,
Winner = a.Str("winner_user"),
FinalPrice = a.Dbl("final_price"),
HasSummary = a.StrOrNull("outcome") != null,
Me = chiSonoIo
});
}
/// <summary>Gli id delle aste concluse, dalla più vecchia alla più recente.</summary>
public static List<string> ClosedAuctionIds(AuctionDatabase db, int max = 0)
{
var ids = db.Query("SELECT auction_id FROM auctions WHERE outcome IS NOT NULL ORDER BY end_time")
.Select(r => r.Str("auction_id")).ToList();
return max > 0 && ids.Count > max ? ids.Skip(ids.Count - max).ToList() : ids;
}
// ── Dai vecchi dossier JSON Lines ────────────────────────────────
public static Session? ReadFile(string path, string? me = null)
{
try { return Read(File.ReadLines(path), me); }
@@ -65,7 +113,6 @@ namespace AutoBidder.Ml
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;
@@ -111,27 +158,7 @@ namespace AutoBidder.Ml
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
return Finish(new Session
{
AuctionId = auctionId,
ProductKey = productKey,
@@ -142,11 +169,32 @@ namespace AutoBidder.Ml
FinalPrice = finalPrice,
HasSummary = hasSummary,
Me = chiSonoIo
};
});
}
/// <summary>Ordina le puntate nel tempo del server e marca le nostre dal nome utente.</summary>
private static Session Finish(Session s)
{
s.Bids.Sort((a, b) =>
{
var c = a.UnixSeconds.CompareTo(b.UnixSeconds);
return c != 0 ? c : a.Price.CompareTo(b.Price);
});
if (s.Me.Length > 0)
{
for (var i = 0; i < s.Bids.Count; i++)
{
if (!s.Bids[i].Mine && string.Equals(s.Bids[i].User, s.Me, StringComparison.OrdinalIgnoreCase))
s.Bids[i] = s.Bids[i] with { Mine = true };
}
}
return s;
}
/// <summary>
/// Gli esempi di addestramento di un dossier: uno per puntata, con etichetta
/// Gli esempi di addestramento di un'asta: uno per puntata, con etichetta
/// «è rimasta senza risposta» — vera solo per l'ultima. La prima puntata non ha un
/// ciclo da misurare e si salta.
/// </summary>
@@ -185,8 +233,7 @@ namespace AutoBidder.Ml
}
// 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.
// viste nell'istante di decidere.
private static double AutoShare(List<Bid> bids, int i, int window)
{
+209 -115
View File
@@ -1,154 +1,248 @@
# Come decide il motore
# AutoBidder — il sistema di decisione e apprendimento
Questo documento descrive l'algoritmo di puntata di AutoBidder dopo la rimozione delle
euristiche a soglia fissa. Vale come specifica: il codice in `Services/BidStrategyService.cs`,
`Engine/AuctionRunner.cs` e in questa cartella la implementa, e i test in `Tests/` la
verificano.
Questo documento descrive come AutoBidder osserva, impara e decide. Vale come specifica:
il codice in `Data/`, `Ml/`, `Services/BidStrategyService.cs`, `Services/RiskManager.cs` e
`Engine/AuctionRunner.cs` la implementa, i test in `Tests/` la verificano. Nessuna libreria
esterna: SQLite arriva da `winsqlite3.dll` di Windows, rete neurale, bandit, simulatore e
agente di rinforzo sono C# puro.
## Principio
---
**Tutto ciò che si può imparare dai dati non si scrive a mano.** L'utente fissa i
paletti — quanto è disposto a spendere, quando non vuole puntare, entro quali limiti
l'anticipo può muoversi — e dentro quei paletti il sistema decide da solo, imparando
sia dallo storico (seimila dossier) sia dalla sessione in corso (il ping di adesso, le
risposte degli avversari di adesso).
## 0. Avvertenze, prima di tutto
Le vecchie euristiche — calore dell'asta, ritiro morbido dopo le collisioni, avversari
aggressivi, anti-bot, puntata probabilistica, velocità del prezzo, esaurimento
dell'avversario — sono state tolte perché rigiocate sui dossier non hanno mai fermato
una puntata sbagliata senza fermarne anche di giuste, e i loro numeri magici andavano
tarati a mano per ogni prodotto e ora del giorno.
**Termini e condizioni di Bidoo.** I termini (it.bidoo.com/terms.php) vietano «l'uso di
software di monitoraggio dell'offerta» e la creazione di account multipli, e consentono
un solo account per persona. Questo programma è software di monitoraggio e puntata: il suo
uso può comportare il blocco o la chiusura dell'account e la perdita delle puntate
acquistate. Non implementa e non implementerà la creazione di account multipli.
## La pipeline: quattro passi, sempre nello stesso ordine
**Quadro legale.** In Italia le aste al centesimo sono in una zona grigia: non
classificate come gioco d'azzardo (art. 721 c.p. richiede aleatorietà pressoché totale;
qui c'è un elemento di abilità), ma trattate dall'AGCM come pratica commerciale ai sensi
del Codice del Consumo. Nel 2015 (provvedimenti PS8243 e PS9030) l'AGCM ha sanzionato due
operatori del settore per un milione di euro complessivo. Non risulta alcuna azione contro
Bidoo, ma ciò non è una garanzia.
**Realtà economica.** Augenblick (2016, *Review of Economic Studies* 83(1)), su 166.000
aste: il margine medio del banco supera il 50% del valore del bene; il 75% dei puntatori
abbandona prima di 50 puntate, l'86% prima di 100. Platt, Price e Tappen (2013) e Hinnosaar
(2016): in ogni equilibrio che non finisce subito, il profitto atteso di ogni puntata è
**zero**. Un vantaggio, se esiste, viene solo dall'irrazionalità altrui (costo affondato,
gusto per il rischio), non dalla struttura del gioco.
Il sistema è quindi **scettico per costruzione**: ogni stima di profittabilità si confronta
con il null-model (ROI per puntata = 0), e l'esito atteso onesto è astenersi dalla grande
maggioranza delle aste. Lo stato *Osserva* è la modalità shadow: registra ogni decisione
che avrebbe preso senza puntare. L'utente ha scelto di non avere una shadow globale
obbligatoria; le aste in *Attiva* puntano davvero, dentro i paletti del gestore del rischio.
---
## 1. Cosa si registra: il database
Un solo file SQLite (`%LocalAppData%\AutoBidder\Database\autobidder.sqlite`, cambiabile
dalle Impostazioni; mai in una cartella sincronizzata). Le scritture vanno in coda e un
thread dedicato le svuota a blocchi in transazioni: nulla sul percorso della puntata
aspetta il disco. Le letture usano una seconda connessione con giornale WAL.
| tabella | cosa contiene |
|---|---|
| `items` | il prodotto: chiave, titolo, «Compralo Ora», **valore reale V** scelto dall'utente, spedizione |
| `auctions` | una riga per asta, aggiornata dall'apertura alla chiusura: esito, prezzo finale, vincitore, puntate mie e del vincitore, copertura dell'osservazione, rete, motore, serie dei prezzi, puntate per utente, configurazione |
| `bids` | ogni puntata di ogni utente: prezzo dopo, tipo (Auto/Manuale), ora del server, secondi residui quando è arrivata, mia/altrui |
| `polls` | le interrogazioni, coalescenti (quando cambia qualcosa, e almeno una al secondo): prezzo, timer, ultimo puntatore, ping, scarto dell'orologio |
| `resets` | ogni azzeramento del timer con la **profondità del ciclo** (fin dove era sceso) e la sua durata |
| `my_bids` | le nostre puntate: anticipo voluto ed effettivo, ping, andata e ritorno, esito, id della decisione |
| `bot_decisions` | ogni decisione del motore, in shadow e dal vivo: azione, P(vittoria), EV, policy, regime, **reason_detail**, e l'**esito** scritto alla chiusura |
| `network_metrics` | una riga ogni dieci secondi: mediana e p95 del ping, jitter, scarto dell'orologio |
| `bidders` | chi punta: aste, puntate, quota di automatiche, vittorie |
| `sessions`, `risk_ledger`, `meta` | sessioni dell'applicazione; uscite ed entrate in euro per il gestore del rischio; stato HALT |
Lo storico compatto (`CompletedAuctionsStore`), le schede dettagliate (`AuctionDetailStore`),
l'apprendimento, la rigiocata e le esportazioni leggono tutti da qui. I vecchi dossier
JSON Lines restano leggibili (`DossierReader.Read`, `DossierTrainingReader.Read`) per
un'eventuale importazione, ma non vengono più scritti.
**Esiti delle decisioni.** Alla chiusura dell'asta ogni decisione riceve un esito: se il
prezzo non è più salito dopo quell'istante, una puntata lì sarebbe stata l'ultima
(`won`/`would_win`; `missed` se non si è puntato), altrimenti sarebbe stata coperta
(`answered`/`would_be_answered`; `right_skip`). È quello che permette di giudicare le policy
sugli stessi istanti (§5).
---
## 2. Il modello matematico — `Ml/Theory.cs`
**Valore atteso di una puntata** (§3.1 del documento di progetto, nella forma corretta:
la puntata si paga in entrambi i rami):
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. PALETTI │──▶│ 2. DUELLO │──▶│ 3. VALORE │──▶│ 4. REGIME │──▶ punta?
│ (utente) │ │ (sessione) │ │ ATTESO │ │ (sessione) │
└──────────────┘ └──────────────┘ │ (storico+ora)│ └──────────────┘
└──────────────┘
┌──────────▼──────────┐
│ QUANDO: anticipo │
│ adattivo (sessione) │
└─────────────────────┘
EV = P · (V (p + Δ) fee spedizione c) (1 P) · c_perdita
```
### 1. Paletti — `BidStrategyService`, `BiddingHours`
con `V` il valore reale del bene (colonna «Valore reale €» nella scheda Prodotti; se vuota,
il «Compralo Ora»), `Δ = 0,01 €`, `fee ≈ 1 €`, `c` il costo della puntata.
Regole dell'utente che non si discutono e non imparano niente:
**Copertura «Compralo Ora».** Chi perde può comprare al listino `L` recuperando le puntate
spese come credito: la perdita finale, se si esercita, è `L V` qualunque sia il numero di
puntate. Quindi `c_perdita = c` finché `spese + c ≤ L V`, zero dopo. Per un buono al
nominale (`V = L`) la copertura è totale dal primo centesimo. È un'ipotesi forte, e per
questo è un'impostazione (`Copertura «Compralo Ora»`), accesa per scelta dell'utente.
- tetti di puntate per asta e per sessione, budget giornaliero (`BankrollManager*`);
- fascia oraria sospesa (`QuietHours*`): fra le 0 e le 9 la stessa asta costa quasi il
doppio che fra le 10 e le 13 (6060 aste concluse). L'asta resta *Attiva* e riprende da
sola alla fine della fascia; si può scavalcare per prodotto e per singola asta.
**Null-model.** Profitto atteso per puntata = 0; numero critico di puntatori `V/c`
(Hinnosaar). **Sopravvivenza empirica:** dalla tabella `resets`, P(vittoria) a un dato
anticipo = quota dei cicli scesi almeno fin lì che hanno chiuso l'asta; curva di
Kaplan-Meier della profondità dei cicli. **Arrivi di Poisson:** `P(nessuna puntata in τ) =
exp(−λτ)` con λ dagli intervalli recenti.
### 2. Duello con l'autopuntata — `AutoBidDuel`
---
L'autopuntata ufficiale di Bidoo scatta a 2 s dalla fine e risponde alla nostra puntata
in 6 ± 2,5 s, sempre. Cinque risposte di fila con quella firma = autopuntata armata. Nelle
prove reali il motore ci ha lasciato 191 puntate su tre aste per zero vittorie. Il
riconoscimento è un segnale della sessione in corso: informa il regime (passo 4) e, se
l'utente lo vuole (`AutoBidDuelWithdrawEnabled`, predefinito acceso), ferma le puntate.
## 3. La pipeline di decisione — `Services/BidStrategyService.cs`
### 3. Valore atteso — `LearningService`, `OnlineLogit`, `BidFeatures`
Un modello logistico in linea, aggiornato a ogni asta chiusa, stima la probabilità `p`
che una puntata fatta *adesso* resti senza risposta. Le variabili sono a fasce e leggibili:
prezzo in percentuale del valore, ora e giorno, prodotto (32 cassetti con hash stabile),
densità delle puntate negli ultimi cicli, profondità del ciclo, se l'ultimo puntatore
risponde a macchina.
Il valore atteso in euro di puntare è
Cinque passi, sempre nello stesso ordine, una volta per ciclo del timer, ~60 ms prima
dell'istante di fuoco:
```
EV = p × (Valore Prezzo 0,01) CostoPuntata
RISCHIO ──▶ PALETTI ──▶ DUELLO ──▶ VALORE ATTESO ──▶ REGIME ──▶ punta?
(solo dal (utente) (sessione) (storico + (sessione)
vivo) sessione) │
QUANDO: anticipo adattivo
```
Sotto zero, in media, puntare perde soldi. La calibrazione si corregge in linea con il
rapporto osservato/previsto (finestra 20 000, limitata a 0,254): il mercato deriva, e
un modello congelato sbaglierebbe di 1,51,8×. Il modello parla sempre; decide solo
dopo `LearningMinAuctions` aste apprese (predefinito 300).
1. **Rischio**`RiskManager.Authorize`: kill-switch (file `KILL_SWITCH` in
`%LocalAppData%\AutoBidder`, anche dalla barra del monitor), HALT persistente, stop-loss
del giorno, drawdown dal picco, tetto di spesa del giorno, aste in gioco insieme. Lo
stop-loss e il drawdown mettono in HALT, che sopravvive al riavvio e si toglie a mano.
La contabilità è in `risk_ledger`: ogni puntata riuscita un'uscita, ogni vincita
un'entrata al valore reale meno prezzo, fee e spedizione.
2. **Paletti** — fascia oraria sospesa (globale, per prodotto, per asta), tetti di puntate.
3. **Duello** — cinque risposte di fila con la firma dell'autopuntata (6 ± 2,5 s) = macchina
armata dall'altra parte; si smette e si informa il regime.
4. **Valore atteso** — il modello dà P(senza risposta); `Theory` la trasforma in euro.
Il modello parla sempre, decide dopo `LearningMinAuctions` aste apprese.
5. **Regime**`CompetitionRegime`: Calmo / Sfogo / Sondaggio con isteresi e pazienza
che raddoppia a ogni sondaggio coperto. Uscire è facile, rientrare è difficile.
Valutazione prequenziale sui dossier reali: sollevamento 10,8× sul decile alto,
calibrazione entro il 1030%; 45 delle 116 puntate vere dell'utente sarebbero state
fermate senza perdere nessuna vittoria.
**Quando**`LatencyModel`: anticipo = margine + p99 della latenza di sessione, fra
`LeadMinMs` e `LeadMaxMs`; una puntata tardiva alza il margine subito, venti in tempo lo
abbassano piano. Un anticipo scritto a mano su un'asta vince sempre.
### 4. Regime di concorrenza — `CompetitionRegime`
**Shadow.** In *Osserva* il cecchino arriva allo stesso istante e chiama la stessa pipeline
(saltando il gestore del rischio, che riguarda solo i soldi veri), registra la decisione e
non punta. Ogni decisione porta anche il parere del bandit (§4.3) sullo stesso istante.
Il valore atteso è un numero per istante; il regime lo trasforma in una condotta per
asta. Tre stati:
---
| Stato | Cosa fa | Ci si entra quando |
|---|---|---|
| **Calmo** | punta se EV ≥ 0 | stato iniziale; un sondaggio riuscito |
| **Sfogo** | non punta: lascia che gli altri si battano | 3 EV negativi di fila; autopuntata riconosciuta; sondaggio coperto subito |
| **Sondaggio** | una sola puntata di prova | in Sfogo, `Pazienza` cicli buoni di fila |
## 4. Cosa impara, e come — `Ml/`
**Isteresi nel verso giusto.** Uscire è facile (3 negativi), rientrare è difficile
(`Pazienza` positivi di fila, che parte da 3). Sbagliare a rientrare costa una puntata;
sbagliare a uscire costa al massimo un'attesa.
### 4.1 P(senza risposta): campione e sfidante
**La pazienza raddoppia.** Se la puntata di prova viene coperta entro 9 s, il regime torna
in Sfogo e la pazienza raddoppia (fino a 24). Così il sistema "capisce da solo" che quell'asta
oggi non si lascia prendere, senza deciderlo una volta per tutte: se la risposta arriva
lenta (una persona, non una macchina) si torna Calmo e la pazienza si dimezza.
Due modelli imparano dagli stessi esempi — ogni puntata di ogni asta chiusa, etichettata
«è rimasta l'ultima?» — con le stesse variabili a fasce (`BidFeatures`: durata del ciclo,
quota di autopuntate, puntatori distinti, prezzo sul valore, ora, giorno, ora di apertura,
tipo di puntata, profondità, prodotto in 32 cassetti):
È questo il passo che risponde a «quando c'è troppa concorrenza li lascio sfogare, e il
sistema deve capire da solo quando può tornare a puntare».
- **campione**: regressione logistica online (`OnlineLogit`, SGD, L2, calibrazione in linea);
- **sfidante**: rete neurale 70→32→16→1 (`NeuralNet`, ReLU, Adam, L2, stessa calibrazione).
### Quando puntare: l'anticipo adattivo — `LatencyModel`
A ogni esempio, *prima* di imparare, entrambi prevedono; il quadrato dell'errore va in una
media mobile (20.000 esempi): è il **punteggio di Brier prequenziale** di ciascuno. Risponde
al motore chi ce l'ha più basso; la rete non comanda prima di 5.000 esempi. La scheda
Apprendimento mostra chi risponde e i due punteggi.
L'anticipo giusto dipende dalla latenza di *questa* rete in *questo* momento. Un numero
fisso è sbagliato quasi sempre. La regola:
Perché non le nostre vittorie come etichetta: sono troppo poche (l'utente non ne ha ancora).
Il comportamento avversario non dipende da chi ha puntato: questo rende utilizzabili i
milioni di puntate altrui.
```
anticipo = clamp( margine + p99(latenza) , LeadMinMs , LeadMaxMs )
```
### 4.2 Profilo per prodotto e ora
- la **coda** (p99 delle ultime 300 andate-e-ritorno delle puntate, o dei ping delle
interrogazioni finché non ci sono puntate), non la media: una puntata su cento in
ritardo costa un'asta intera;
- il **margine** (parte da 300 ms) sale di 150 ms a ogni puntata tardiva e scende di
25 ms ogni 20 puntate in tempo. Asimmetrico apposta;
- i **paletti** `LeadMinMs` (300) e `LeadMaxMs` (1500) li fissa l'utente; un anticipo
scritto a mano su una singola asta vince sempre (`BidLeadIsManual`).
`AuctionProfileStats`: puntate del vincitore e prezzo di chiusura in frazione del valore, per
prodotto × fascia oraria × feriale/festivo, con restringimento gerarchico dove i dati
scarseggiano. Alimentato asta per asta.
Lo stato si salva fra le sessioni (`Statistiche/Apprendimento/latenza.json`).
### 4.3 Bandit contestuale (Thompson)
## Cosa impara, e da dove
`ThompsonBandit`: per ogni contesto (ciclo, prezzo/valore, fascia, puntatori, autopuntate)
una Beta(α, β) su P(senza risposta). Decide estraendo p dalla Beta e applicando la regola
del valore atteso: esplora dove ha visto poco, si stringe con i dati. Impara dagli **esiti
delle decisioni** — informazione completa, anche dove non si è puntato. È la seconda policy,
sempre in shadow, confrontata con la prima sugli stessi istanti.
| Componente | Dati storici | Sessione in corso |
|---|---|---|
| Modello logistico | ogni puntata di ogni dossier | l'asta appena chiusa |
| Profilo per prodotto/ora | riepiloghi dei dossier | l'asta appena chiusa |
| Regime | — | risposte avversarie di quest'asta |
| Anticipo adattivo | margine salvato | ping e puntate di adesso |
| Duello | — | ritmo delle risposte di quest'asta |
### 4.4 Regime e latenza
## Dove si vede
Sessione in corso, non storico: `CompetitionRegime` per asta, `LatencyModel` per la rete.
Vedi §3.
Scheda **Apprendimento**: stato del modello, pesi, ultime decisioni, profilo per
prodotto, valutazione, e la sezione *Autonomia sul momento* con il modello di latenza e
il regime di ogni asta seguita.
### 4.5 Simulatore e agente di rinforzo
Nel registro di ogni asta: `[REGIME] Calmo → Sfogo: …`, `[TIMING] … anticipo adattivo
portato a N ms`, `⛔ Strategia blocca: valore atteso negativo: P(senza risposta) … = 0,012 €`.
`PennyAuctionEnv`: aste sintetiche con quattro profili di avversario (aggressivo, cecchino,
autopuntata a 2 s, principiante), ognuno con credito e **persistenza crescente con lo speso**
(costo affondato, Augenblick). Taratura dal database: puntatori per asta, istogramma della
profondità dei cicli, quota di autopuntate, chiusura mediana in frazione del valore.
## Come si prova
`QLearningAgent`: funzione Q su rete neurale, replay delle esperienze, ε-greedy decrescente,
**penalità conservativa** sulle azioni poco viste (nello spirito di CQL). Si addestra solo nel
simulatore: non si esplora con denaro vero.
- `Tests/AutonomyTests.cs` — regime (isteresi, raddoppio, tetto) e latenza (paletti,
asimmetria);
- `Tests/MlTests.cs`, `Tests/MlModelBacktest.cs` — modello e valutazione prequenziale;
- `Tests/AutoBidDuelTests.cs`, `Tests/BiddingHoursTests.cs` — duello e fascia oraria;
- `Tests/BacktestTests.cs`, `Tests/RealDossierBacktest.cs` — rigiocata sui dossier con
la stessa `BidStrategyService` del motore.
`SimulationLab` mette a confronto su aste identiche (stessi semi): non punta mai, punta
sempre, valore atteso con P empirica, bandit che impara giocando, agente Q. Il simulatore
non è Bidoo: i numeri assoluti non contano, il confronto sì. È l'unico posto dove si vede
come reagirebbero gli altri a una nostra puntata.
## Archiviazione
---
Ogni asta seguita ha **un solo** file: il dossier JSON Lines in `Registri/Aste/`
(intestazione, eventi coalescenti, riepilogo in coda). Le schede dettagliate si leggono
da lì (`AuctionDetailStore` legge testa e coda del file, con cache); l'archivio mensile
`aste-AAAA-MM.jsonl` che le duplicava non esiste più. Lo storico compatto delle aste
concluse resta in `Statistiche/completed-auctions.json`, con copia di sicurezza a ogni
pulizia.
## 5. Come si giudica — `ShadowReport`, `LearningEvaluator`, rigiocata
- **Shadow** (`ShadowReport`): sugli istanti di decisione con esito noto, per ogni policy:
puntate, vincenti, spesi, incassati al valore reale, ROI per puntata con estremo inferiore
dell'intervallo al 95%. Il null-model è battuto solo se l'estremo inferiore è sopra zero,
su abbastanza puntate.
- **Prequenziale** (`LearningEvaluator`, «Valuta ora»): addestra sulle aste più vecchie,
giudica sulle più recenti, poi ogni asta prima prevista e poi appresa; sollevamento,
calibrazione per fasce, punteggio di Brier, cosa avrebbe fatto il cancello sulle nostre
puntate vere.
- **Rigiocata** (`BacktestReport`): cosa avrebbe fatto il motore su ogni asta registrata,
anticipo per anticipo; misura i costi, non l'esito.
- **Esportazione**: «Esporta decisioni (CSV)» — punto e virgola, colonna `reason_detail`.
**Criteri di promozione e di stop** (dal documento di progetto): una policy passa dal
shadow al vivo solo con ROI per puntata > 0 ed estremo inferiore > 0 su almeno quattro
settimane, calibrazione buona, drawdown simulato entro il limite. Se dopo un periodo di
vita reale il ROI cumulato non batte il null-model, il sistema resta strumento analitico e
si spegne il vivo. La posizione di default è che un vantaggio consistente sia improbabile.
---
## 6. Dove si vede
Scheda **Apprendimento**: stato e pesi del modello, ultime decisioni, profilo per prodotto,
valutazione, autonomia sul momento (latenza e regime per asta), shadow (policy a confronto,
campione/sfidante, bandit), simulatore. Scheda **Impostazioni Gestione del rischio**:
paletti, kill-switch, HALT e contabilità. Scheda **Prodotti**: colonna «Valore reale €».
Nel registro di ogni asta: `[SHADOW] Avrei puntato…`, `[REGIME] Calmo → Sfogo…`, `[TIMING]
… anticipo adattivo…`, `⛔ Strategia blocca: …`.
---
## 7. Cosa non c'è, e perché
- **Feed DOM (Playwright)**: gli endpoint HTTP di Bidoo (`data.php`, `get_auction_updates`)
sono verificati e usati da mesi; un browser sarebbe più lento e più fragile.
- **Dataset Swoopo (2008-09)**: sito e anni diversi; il database si riempie da solo.
- **Modelli sequenziali (LSTM/Transformer)**: rimandati; le variabili a fasce con la rete
MLP coprono le interazioni che contano, e i dati per una sequenza profonda non ci sono.
- **Account multipli**: mai.
## 8. Milestone
| | stato |
|---|---|
| M1 rischio, kill-switch, HALT, shadow per asta | fatto |
| M2 database SQLite, registrazione, sessioni, misure di rete | fatto (si parte da zero) |
| M3 teoria, simulatore con avversari tarati, rigiocata | fatto |
| M4 modelli: logistico, rete neurale, profilo, hazard/Kaplan-Meier, Brier | fatto |
| M5 policy: valore atteso + regime, bandit Thompson, agente Q conservativo | fatto (Q solo nel simulatore) |
| M6 valutazione shadow ≥ 4 settimane, confronto fra policy | strumenti pronti; servono le settimane |
| M7 esecuzione e latenza | fatto (anticipo adattivo) |
| M8 monitoraggio e riaddestramento | scheda pronta; riaddestramento a ogni asta chiusa; deriva: da fare |
+36 -27
View File
@@ -1,36 +1,35 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using AutoBidder.Data;
namespace AutoBidder.Ml
{
/// <summary>
/// Valuta il modello sui dossier veri, con una divisione onesta nel tempo: si impara
/// dai più vecchi e si giudica sui più recenti, mai visti. Poi, sui recenti, la
/// valutazione <b>prequenziale</b>: ogni asta prima prevista e poi appresa, in ordine di
/// tempo, che è esattamente la sequenza del motore in produzione.
/// Valuta il modello sulle aste vere del database, con una divisione onesta nel tempo:
/// si impara dalle più vecchie e si giudica sulle più recenti, mai viste. Poi, sulle
/// recenti, la valutazione <b>prequenziale</b>: ogni asta prima prevista e poi appresa,
/// in ordine di tempo, che è esattamente la sequenza del motore in produzione.
///
/// <para>Sta in una classe a sé perché la usano in due: il test da riga di comando
/// (target <c>Apprendimento</c>) e la scheda nell'applicazione. Una sola
/// implementazione, così i numeri che vede l'utente sono gli stessi che vede chi
/// sviluppa.</para>
/// (target <c>Apprendimento</c>) e la scheda nell'applicazione.</para>
///
/// <para><b>Cosa misura.</b> Quanto il modello separa le puntate rimaste senza risposta
/// (sollevamento nella cima della classifica), se la probabilità è nella scala giusta
/// (calibrazione per fasce), e sulle nostre puntate vere cosa avrebbe fatto il cancello
/// del valore atteso: quante fermate, e quante di quelle erano invece vincenti.</para>
/// (calibrazione per fasce, punteggio di Brier), e sulle nostre puntate vere cosa
/// avrebbe fatto il cancello del valore atteso.</para>
/// </summary>
public static class LearningEvaluator
{
public sealed class Report
{
public string Text { get; init; } = "";
public int Files { get; init; }
public int Auctions { get; init; }
public int Usable { get; init; }
public double Lift5 { get; init; }
public double Brier { get; init; }
public double CalibrationFactor { get; init; }
public int MyBids { get; init; }
public int MyBlocked { get; init; }
@@ -40,28 +39,34 @@ namespace AutoBidder.Ml
private sealed record Row(double P, bool Unanswered, bool Mine, double Ev);
public static Report Run(string folder, int maxFiles, string? me, Action<string>? progress = null, CancellationToken ct = default)
public static Report Run(AuctionDatabase db, int maxAuctions, string? me, Action<string>? progress = null, CancellationToken ct = default)
{
var files = Directory.GetFiles(folder, "*.jsonl").OrderBy(Path.GetFileName, StringComparer.Ordinal).ToList();
if (maxFiles > 0 && files.Count > maxFiles) files = files.Skip(files.Count - maxFiles).ToList();
db.Flush();
var ids = DossierTrainingReader.ClosedAuctionIds(db, maxAuctions);
var sessions = new List<DossierTrainingReader.Session>();
var letti = 0;
foreach (var f in files)
foreach (var id in ids)
{
ct.ThrowIfCancellationRequested();
var s = DossierTrainingReader.ReadFile(f, me);
var s = DossierTrainingReader.FromDatabase(db, id, me);
if (s != null && s.IsUsable) sessions.Add(s);
if (++letti % 200 == 0) progress?.Invoke($"letti {letti:N0} dossier su {files.Count:N0}…");
if (++letti % 200 == 0) progress?.Invoke($"lette {letti:N0} aste su {ids.Count:N0}…");
}
return Evaluate(sessions, ids.Count, progress, ct);
}
/// <summary>La valutazione vera e propria, su sessioni già in memoria (anche per i test).</summary>
public static Report Evaluate(List<DossierTrainingReader.Session> sessions, int totalAuctions, Action<string>? progress = null, CancellationToken ct = default)
{
var report = new StringBuilder();
report.AppendLine($"Dossier letti: {files.Count:N0}, utilizzabili: {sessions.Count:N0}");
report.AppendLine($"Aste chiuse: {totalAuctions:N0}, utilizzabili: {sessions.Count:N0}");
if (sessions.Count < 20)
{
report.AppendLine("Troppo pochi dossier utilizzabili per una valutazione.");
return new Report { Text = report.ToString(), Files = files.Count, Usable = sessions.Count };
report.AppendLine("Troppo poche aste utilizzabili per una valutazione.");
return new Report { Text = report.ToString(), Auctions = totalAuctions, Usable = sessions.Count };
}
progress?.Invoke("addestramento e confronto delle configurazioni…");
@@ -70,7 +75,6 @@ namespace AutoBidder.Ml
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.
var trainX = train.SelectMany(s => DossierTrainingReader.Examples(s))
.Select(e => (X: BidFeatures.Build(e.Context), Y: e.Unanswered)).ToList();
@@ -90,10 +94,6 @@ namespace AutoBidder.Ml
report.AppendLine();
// ── Confronto fra configurazioni (modello congelato) ─────────────
//
// Serve a scegliere; non è la misura finale. Un modello congelato sui vecchi e
// applicato ai nuovi sconta la deriva del mercato, che nell'uso online non
// subisce: la misura che conta è la prequenziale qui sotto.
const double costo = 0.20;
var configs = new List<(string Nome, double Lr, double L2)>();
foreach (var lr in new[] { 0.01, 0.003 })
@@ -129,7 +129,7 @@ namespace AutoBidder.Ml
if (best == null)
{
report.AppendLine("Nessuna configurazione separa abbastanza (sollevamento al 5% > 2x).");
return new Report { Text = report.ToString(), Files = files.Count, Usable = sessions.Count };
return new Report { Text = report.ToString(), Auctions = totalAuctions, Usable = sessions.Count };
}
report.AppendLine($"Scelta: {bestName}");
@@ -157,6 +157,14 @@ namespace AutoBidder.Ml
report.AppendLine($"P media sulle puntate senza risposta : {meanPos:P3}");
report.AppendLine($"P media sulle altre : {meanNeg:P3}");
report.AppendLine($"rapporto : {meanPos / Math.Max(meanNeg, 1e-9):N2}x");
// Il punteggio di Brier: media del quadrato dell'errore fra probabilità e
// esito. È la misura onesta di una probabilità: premia chi è calibrato E
// discrimina. Si confronta con quello del tasso di base, che è il minimo
// ottenibile senza guardare nulla.
var brier = rows.Average(r => Math.Pow(r.P - (r.Unanswered ? 1 : 0), 2));
var brierBase = rows.Average(r => Math.Pow(baseRate - (r.Unanswered ? 1 : 0), 2));
report.AppendLine($"punteggio di Brier : {brier:F6} (tasso di base: {brierBase:F6}, miglioramento {(1 - brier / Math.Max(1e-12, brierBase)):P1})");
report.AppendLine();
var sorted = rows.OrderByDescending(r => r.P).ToList();
@@ -204,9 +212,10 @@ namespace AutoBidder.Ml
return new Report
{
Text = report.ToString(),
Files = files.Count,
Auctions = totalAuctions,
Usable = sessions.Count,
Lift5 = liftTop5,
Brier = brier,
CalibrationFactor = model.CalibrationFactor,
MyBids = mie.Count,
MyBlocked = fermate.Count,
+238 -132
View File
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Utilities;
@@ -19,49 +20,84 @@ namespace AutoBidder.Ml
/// 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
/// <para><b>Da dove impara.</b> Dal database. Ogni puntata di ogni asta chiusa è un
/// esempio: rimasta senza risposta oppure no. Non dalle nostre vittorie, che sono
/// 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>
/// troppo poche: il comportamento avversario non dipende da chi ha puntato, e questo
/// rende utilizzabili le 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>Quando impara.</b> Alla chiusura di ogni asta, subito. All'avvio, in
/// sottofondo e con un tempo massimo, ripassa le aste chiuse che non risultano ancora
/// apprese (colonna <c>learned_at</c>): capita dopo un riaddestramento da capo o se
/// l'applicazione era stata chiusa nel mezzo.</para>
///
/// <para><b>Cosa NON può dire.</b> Il modello impara come finiscono le aste senza di
/// noi. La nostra puntata cambia il gioco: chi stava per smettere può rispondere.
/// Nessun dato storico dice questo. Il modello dà la stima migliore possibile di
/// quando gli altri sono esausti, ed è quanto si può chiedere ai dati.</para>
/// Nessun dato storico dice questo.</para>
/// </summary>
public static class LearningService
{
private static readonly object Sync = new();
// I salvataggi hanno un lucchetto tutto loro: all'avvio più aste concluse nel
// frattempo vengono chiuse insieme, ognuna su un proprio thread, e senza questo
// scrivevano lo stesso file nello stesso istante — "file in uso da un altro
// processo", che in realtà eravamo noi.
// frattempo vengono chiuse insieme, ognuna su un proprio thread.
private static readonly object SaveSync = new();
private static OnlineLogit _model = new(BidFeatures.Size);
private static NeuralNet _challenger = NewChallenger();
private static ThompsonBandit _bandit = new();
private static AuctionProfileStats _profile = new();
private static readonly HashSet<string> _ingested = new(StringComparer.OrdinalIgnoreCase);
private static int _auctionsLearned;
private static bool _loaded;
private static volatile bool _bootstrapRunning;
// ── Campione e sfidante ──────────────────────────────────────────
//
// Il modello logistico (campione) e la rete neurale (sfidante) imparano dagli
// stessi esempi. A ogni esempio, PRIMA di imparare, entrambi prevedono: il
// quadrato dell'errore va in una media mobile, che è il punteggio di Brier
// prequenziale di ciascuno. Chi ce l'ha più basso risponde al motore. La rete
// non comanda mai prima di qualche migliaio di esempi: all'inizio direbbe cose
// a caso.
private static double _brierLogit = double.NaN;
private static double _brierNet = double.NaN;
private static long _brierSamples;
private const double BrierWindow = 20_000;
private const long ChallengerMinSamples = 5_000;
private static NeuralNet NewChallenger() =>
new(new[] { BidFeatures.Size, 32, 16, 1 }, seed: 42) { LearningRate = 0.001, L2 = 1e-5 };
/// <summary>La rete neurale ha un Brier prequenziale più basso del logistico: risponde lei.</summary>
public static bool ChallengerLeads
{
get { lock (Sync) return _brierSamples >= ChallengerMinSamples && !double.IsNaN(_brierNet) && _brierNet < _brierLogit; }
}
public static (double Logit, double Net, long Samples) BrierScores
{
get { lock (Sync) return (_brierLogit, _brierNet, _brierSamples); }
}
public static long ChallengerUpdates { get { lock (Sync) return _challenger.Updates; } }
/// <summary>Il bandit Thompson: la seconda policy, giudicata in shadow sugli stessi istanti.</summary>
public static ThompsonBandit Bandit => _bandit;
/// <summary>Righe di registro sull'apprendimento: chi si aggancia le scrive dove vuole.</summary>
public static event Action<string>? OnLog;
private static AuctionDatabase Db => AuctionDatabase.Instance;
// ── Percorsi ─────────────────────────────────────────────────────
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");
private static string ChallengerFile => Path.Combine(Folder, "sfidante-rete.json");
private static string BanditFile => Path.Combine(Folder, "bandit.json");
private static string BrierFile => Path.Combine(Folder, "brier.txt");
private static string CounterFile => Path.Combine(Folder, "aste-apprese.txt");
private static string LegacyManifestFile => Path.Combine(Folder, "dossier-appresi.txt");
// ── Stato, per l'interfaccia e il registro ───────────────────────
@@ -71,6 +107,19 @@ namespace AutoBidder.Ml
public static long ProfileAuctions { get { lock (Sync) return _profile.AuctionsObserved; } }
public static bool BootstrapRunning => _bootstrapRunning;
/// <summary>Versione del modello, per marcare le decisioni: chi risponde e quanti aggiornamenti ha.</summary>
public static string ModelVersion
{
get
{
lock (Sync)
{
var netLeads = _brierSamples >= ChallengerMinSamples && !double.IsNaN(_brierNet) && _brierNet < _brierLogit;
return netLeads ? $"mlp-{BidFeatures.Size}-{_challenger.Updates}" : $"logit-{BidFeatures.Size}-{_model.Updates}";
}
}
}
/// <summary>
/// Abbastanza aste apprese perché il cancello sul valore atteso possa fermare una
/// puntata. Prima di questa soglia il modello parla, ma non decide.
@@ -97,11 +146,6 @@ namespace AutoBidder.Ml
}
// ── Ultime decisioni, per la scheda ──────────────────────────────
//
// Il motore chiede al modello a ogni giro utile, e la risposta finisce nel
// registro dell'asta. Qui se ne tiene una coda corta per vederle tutte insieme:
// e' l'unico modo di capire a colpo d'occhio se il cancello sta fermando troppo o
// troppo poco.
/// <summary>Una risposta data al motore.</summary>
public readonly record struct Decision(
@@ -114,8 +158,6 @@ namespace AutoBidder.Ml
{
lock (_decisions)
{
// Lo stesso giro dell'asta produce molte risposte uguali di fila: si tiene
// solo il cambiamento, altrimenti la coda si riempie di una sola asta.
if (_decisions.Count > 0)
{
var last = _decisions.Last();
@@ -146,15 +188,15 @@ namespace AutoBidder.Ml
}
/// <summary>
/// Valuta il modello sui dossier di questa macchina, in sottofondo, e salva il
/// rapporto. È la stessa valutazione del target da riga di comando.
/// Valuta il modello sulle aste del database, in sottofondo, e salva il rapporto.
/// È la stessa valutazione del target da riga di comando.
/// </summary>
public static Task<LearningEvaluator.Report> EvaluateAsync(int maxFiles, Action<string>? progress, System.Threading.CancellationToken ct)
public static Task<LearningEvaluator.Report> EvaluateAsync(int maxAuctions, Action<string>? progress, System.Threading.CancellationToken ct)
{
return Task.Run(() =>
{
var me = AuctionDossier.CurrentUsername;
var report = LearningEvaluator.Run(AppPaths.AuctionLogFolder, maxFiles, string.IsNullOrEmpty(me) ? null : me, progress, ct);
var me = AuctionRecorder.CurrentUsername;
var report = LearningEvaluator.Run(Db, maxAuctions, string.IsNullOrEmpty(me) ? null : me, progress, ct);
try
{
@@ -170,15 +212,15 @@ namespace AutoBidder.Ml
// ── Ricominciare da capo ─────────────────────────────────────────
/// <summary>
/// Butta modello, profilo ed elenco dei dossier letti, e ricomincia a studiare
/// dall'archivio. Serve quando si cambiano le variabili o si sospetta che il
/// modello abbia imparato da dati sbagliati. Non tocca i dossier.
/// Butta modello e profilo, smarca tutte le aste come non apprese e ricomincia a
/// studiarle dal database. Serve quando si cambiano le variabili o si sospetta che
/// il modello abbia imparato da dati sbagliati.
/// </summary>
public static Task RetrainFromScratchAsync(AppSettings settings)
{
Forget();
OnLog?.Invoke("[APPRENDIMENTO] Ricomincio da capo: profilo dallo storico, poi tutti i dossier.");
OnLog?.Invoke("[APPRENDIMENTO] Ricomincio da capo: profilo dallo storico, poi tutte le aste chiuse.");
BootstrapProfileFromHistory();
// Un riaddestramento voluto ha diritto a tutto il tempo che serve.
@@ -187,18 +229,20 @@ namespace AutoBidder.Ml
}
/// <summary>
/// Dimentica tutto: modello, profilo, elenco dei dossier studiati, decisioni e
/// valutazione — in memoria e su disco. Senza, il modello ancora vivo riscriverebbe
/// il file al primo salvataggio. Al prossimo avvio si ristudiano i dossier da capo.
/// Dimentica tutto: modello, profilo, contatore, decisioni e valutazione — in
/// memoria e su disco — e smarca le aste come non apprese. Senza, il modello
/// ancora vivo riscriverebbe il file al primo salvataggio.
/// </summary>
public static void Forget()
{
lock (Sync)
{
_model = new OnlineLogit(BidFeatures.Size);
_challenger = NewChallenger();
_bandit = new ThompsonBandit();
_profile = new AuctionProfileStats();
_ingested.Clear();
_auctionsLearned = 0;
_brierLogit = double.NaN; _brierNet = double.NaN; _brierSamples = 0;
}
lock (_decisions) _decisions.Clear();
@@ -207,7 +251,7 @@ namespace AutoBidder.Ml
{
lock (SaveSync)
{
foreach (var f in new[] { ModelFile, ProfileFile, ManifestFile, EvaluationFile })
foreach (var f in new[] { ModelFile, ProfileFile, CounterFile, LegacyManifestFile, EvaluationFile, ChallengerFile, BanditFile, BrierFile })
if (File.Exists(f)) File.Delete(f);
}
}
@@ -215,6 +259,9 @@ namespace AutoBidder.Ml
{
OnLog?.Invoke($"[APPRENDIMENTO] Archivio non cancellato del tutto: {ex.Message}");
}
try { Db.ExecuteNow("UPDATE auctions SET learned_at = NULL"); }
catch (Exception ex) { OnLog?.Invoke($"[APPRENDIMENTO] Aste non smarcate: {ex.Message}"); }
}
// ── Avvio ────────────────────────────────────────────────────────
@@ -239,45 +286,72 @@ namespace AutoBidder.Ml
_model = OnlineLogit.FromJson(File.ReadAllText(ModelFile), BidFeatures.Size);
if (File.Exists(ProfileFile))
_profile = AuctionProfileStats.FromJson(File.ReadAllText(ProfileFile));
if (File.Exists(ManifestFile))
if (File.Exists(ChallengerFile))
_challenger = NeuralNet.FromJson(File.ReadAllText(ChallengerFile), new[] { BidFeatures.Size, 32, 16, 1 });
if (File.Exists(BanditFile))
_bandit = ThompsonBandit.FromJson(File.ReadAllText(BanditFile));
if (File.Exists(BrierFile))
{
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);
}
var parts = File.ReadAllText(BrierFile).Split(';');
if (parts.Length >= 3 &&
double.TryParse(parts[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var bl) &&
double.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var bn) &&
long.TryParse(parts[2], out var bs))
{ _brierLogit = bl; _brierNet = bn; _brierSamples = bs; }
}
_auctionsLearned = ReadCounter();
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Archivio illeggibile, si riparte da zero: {ex.Message}");
_model = new OnlineLogit(BidFeatures.Size);
_profile = new AuctionProfileStats();
_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}");
$"profilo: {ProfileAuctions:N0} aste");
}
/// <summary>
/// Il contatore delle aste apprese. Il file nuovo porta solo il numero; quello
/// delle versioni precedenti lo portava in testa all'elenco dei dossier letti, e
/// si legge ancora per non azzerare un modello che ha già studiato migliaia di aste.
/// </summary>
private static int ReadCounter()
{
foreach (var f in new[] { CounterFile, LegacyManifestFile })
{
if (!File.Exists(f)) continue;
foreach (var line in File.ReadLines(f))
{
var l = line.Trim();
if (l.StartsWith("#appresi=", StringComparison.Ordinal) && int.TryParse(l["#appresi=".Length..], out var n)) return n;
if (int.TryParse(l, out var plain)) return plain;
}
}
return 0;
}
private static void Save()
{
try
{
string model, profile;
string model, profile, challenger, bandit, brier;
int learned;
lock (Sync)
{
model = _model.ToJson();
profile = _profile.ToJson();
challenger = _challenger.ToJson();
bandit = _bandit.ToJson();
brier = string.Join(';',
_brierLogit.ToString("R", System.Globalization.CultureInfo.InvariantCulture),
_brierNet.ToString("R", System.Globalization.CultureInfo.InvariantCulture),
_brierSamples);
learned = _auctionsLearned;
}
lock (SaveSync)
@@ -285,6 +359,10 @@ namespace AutoBidder.Ml
Directory.CreateDirectory(Folder);
File.WriteAllText(ModelFile, model);
File.WriteAllText(ProfileFile, profile);
File.WriteAllText(ChallengerFile, challenger);
File.WriteAllText(BanditFile, bandit);
File.WriteAllText(BrierFile, brier);
File.WriteAllText(CounterFile, "#appresi=" + learned + Environment.NewLine);
}
}
catch (Exception ex)
@@ -293,34 +371,9 @@ namespace AutoBidder.Ml
}
}
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.
/// del vincitore già passate dal controllo di coerenza. Solo se il profilo è vuoto.
/// </summary>
private static void BootstrapProfileFromHistory()
{
@@ -329,6 +382,8 @@ namespace AutoBidder.Ml
try
{
var records = CompletedAuctionsStore.LoadAll();
if (records.Count == 0) return;
var fresh = new AuctionProfileStats();
foreach (var r in records)
@@ -347,8 +402,7 @@ namespace AutoBidder.Ml
}
/// <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.
/// Studia le aste chiuse non ancora apprese, dalla più recente, finché c'è tempo.
/// </summary>
private static async Task BootstrapAsync(AppSettings settings)
{
@@ -359,55 +413,53 @@ namespace AutoBidder.Ml
{
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)
List<string> pending;
try
{
pending = Directory.GetFiles(folder, "*.jsonl")
.Where(f => !_ingested.Contains(Path.GetFileName(f)))
.OrderByDescending(Path.GetFileName, StringComparer.Ordinal)
.ToArray();
Db.Flush();
pending = Db.Query("SELECT auction_id FROM auctions WHERE outcome IS NOT NULL AND learned_at IS NULL ORDER BY end_time DESC")
.Select(r => r.Str("auction_id")).ToList();
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Elenco delle aste da studiare non disponibile: {ex.Message}");
return;
}
if (pending.Length == 0) return;
if (pending.Count == 0) return;
var budget = TimeSpan.FromSeconds(Math.Max(10, settings.LearningBootstrapSecondsPerStart));
var sw = Stopwatch.StartNew();
var done = 0;
var usable = 0;
OnLog?.Invoke($"[APPRENDIMENTO] {pending.Length:N0} dossier da leggere, al massimo {budget.TotalSeconds:N0} s per questo avvio");
OnLog?.Invoke($"[APPRENDIMENTO] {pending.Count:N0} aste da studiare, al massimo {budget.TotalSeconds:N0} s per questo avvio");
foreach (var file in pending)
foreach (var id in pending)
{
if (sw.Elapsed > budget) break;
if (IngestFile(file)) usable++;
if (IngestAuction(id)) usable++;
done++;
if (done % 100 == 0)
{
Save();
SaveManifest();
OnLog?.Invoke($"[APPRENDIMENTO] {done:N0} dossier letti, {usable:N0} utilizzabili, {ModelUpdates:N0} aggiornamenti");
OnLog?.Invoke($"[APPRENDIMENTO] {done:N0} aste studiate, {usable:N0} utilizzabili, {ModelUpdates:N0} aggiornamenti");
}
// Un respiro ogni file: il motore delle aste ha la precedenza.
await Task.Yield();
await Task.Yield(); // il motore delle aste ha la precedenza
}
Save();
SaveManifest();
var remaining = pending.Length - done;
OnLog?.Invoke($"[APPRENDIMENTO] Letti {done:N0} dossier ({usable:N0} utilizzabili) in {sw.Elapsed.TotalSeconds:N0} s; " +
var remaining = pending.Count - done;
OnLog?.Invoke($"[APPRENDIMENTO] Studiate {done:N0} aste ({usable:N0} utilizzabili) in {sw.Elapsed.TotalSeconds:N0} s; " +
(remaining > 0 ? $"ne restano {remaining:N0} per i prossimi avvii." : "archivio completo."));
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Studio dei dossier interrotto: {ex.Message}");
OnLog?.Invoke($"[APPRENDIMENTO] Studio delle aste interrotto: {ex.Message}");
}
finally
{
@@ -416,36 +468,71 @@ namespace AutoBidder.Ml
}
/// <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.
/// Impara da un'asta del database e la marca come appresa. Restituisce true se era
/// utilizzabile. Un'asta studiata si marca anche se non era utilizzabile:
/// ristudiarla non la renderebbe tale.
/// </summary>
private static bool IngestFile(string path)
private static bool IngestAuction(string auctionId)
{
var name = Path.GetFileName(path);
var session = DossierTrainingReader.ReadFile(path);
var session = DossierTrainingReader.FromDatabase(Db, auctionId, AuctionRecorder.CurrentUsername);
var usable = false;
lock (Sync)
{
_ingested.Add(name);
if (session == null || !session.IsUsable) return false;
if (session != null && session.IsUsable)
{
foreach (var (ctx, unanswered, _) in DossierTrainingReader.Examples(session))
{
var x = BidFeatures.Build(ctx);
foreach (var (ctx, unanswered, _) in DossierTrainingReader.Examples(session))
_model.Update(BidFeatures.Build(ctx), unanswered);
// Prima si prevede, poi si impara: è la valutazione prequenziale
// fatta dal vivo, e decide chi dei due risponde al motore.
var y = unanswered ? 1.0 : 0.0;
var eL = _model.PredictCalibrated(x) - y;
var eN = _challenger.PredictCalibrated(x) - y;
var k = 1.0 / Math.Min(BrierWindow, _brierSamples + 1);
_brierLogit = double.IsNaN(_brierLogit) ? eL * eL : _brierLogit + k * (eL * eL - _brierLogit);
_brierNet = double.IsNaN(_brierNet) ? eN * eN : _brierNet + k * (eN * eN - _brierNet);
_brierSamples++;
_auctionsLearned++;
_model.Update(x, unanswered);
_challenger.Update(x, unanswered);
}
_auctionsLearned++;
usable = true;
}
}
return true;
Db.Enqueue("UPDATE auctions SET learned_at = ?2 WHERE auction_id = ?1", auctionId, AuctionDatabase.Now());
return usable;
}
/// <summary>
/// Il bandit impara dalle decisioni di quest'asta, ora che hanno un esito: per
/// ogni istante di decisione si sa se una puntata lì sarebbe stata l'ultima.
/// </summary>
private static int LearnBanditFromAuction(string auctionId)
{
var n = 0;
foreach (var r in Db.Query("SELECT context_key, outcome FROM bot_decisions WHERE auction_id = ?1 AND context_key IS NOT NULL AND outcome IS NOT NULL", auctionId))
{
var outcome = r.Str("outcome");
var unanswered = outcome is "won" or "would_win" or "missed";
_bandit.Observe(r.Str("context_key"), unanswered);
n++;
}
return n;
}
// ── Chiusura di un'asta: si impara subito ────────────────────────
/// <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à.
/// Da chiamare quando un'asta è chiusa e la sua registrazione è stata completata.
/// Aggiorna il profilo con lo storico appena registrato e il modello con le
/// puntate dell'asta, poi salva: la prossima decisione le vede già.
/// </summary>
public static void OnAuctionClosed(CompletedAuctionRecord record, string? dossierPath)
public static void OnAuctionClosed(CompletedAuctionRecord record)
{
if (record == null) return;
@@ -457,19 +544,19 @@ namespace AutoBidder.Ml
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);
}
// Le righe dell'asta sono in coda: prima devono essere sul disco.
Db.Flush();
var learned = !string.IsNullOrEmpty(record.AuctionId) && IngestAuction(record.AuctionId);
var decisioni = string.IsNullOrEmpty(record.AuctionId) ? 0 : LearnBanditFromAuction(record.AuctionId);
Save();
SaveManifest();
if (decisioni > 0)
OnLog?.Invoke($"[APPRENDIMENTO] '{record.Name}': {decisioni} decisioni con esito, bandit aggiornato ({_bandit.Observations:N0} osservazioni)");
OnLog?.Invoke(learned
? $"[APPRENDIMENTO] '{record.Name}': appresa ({ModelUpdates:N0} aggiornamenti, {AuctionsLearned:N0} aste)"
: $"[APPRENDIMENTO] '{record.Name}': profilo aggiornato, dossier non utilizzabile per il modello");
: $"[APPRENDIMENTO] '{record.Name}': profilo aggiornato, puntate non utilizzabili per il modello");
}
catch (Exception ex)
{
@@ -484,6 +571,27 @@ namespace AutoBidder.Ml
/// c'è abbastanza contesto (nessuna puntata recente vista).
/// </summary>
public static double? PredictUnanswered(AuctionInfo auction, AuctionState state, DateTime nowLocal, string? myUsername)
{
var ctx = BuildContext(auction, state, nowLocal, myUsername);
return ctx is { } c ? PredictUnanswered(c) : null;
}
/// <summary>La probabilità per un contesto già costruito: risponde il campione del momento.</summary>
public static double PredictUnanswered(in BidContext ctx)
{
var x = BidFeatures.Build(ctx);
lock (Sync)
{
var netLeads = _brierSamples >= ChallengerMinSamples && !double.IsNaN(_brierNet) && _brierNet < _brierLogit;
return netLeads ? _challenger.PredictCalibrated(x) : _model.PredictCalibrated(x);
}
}
/// <summary>
/// Lo stato dell'asta nell'istante della decisione, ridotto alle variabili del
/// modello. Null quando non c'è abbastanza contesto (nessuna puntata recente).
/// </summary>
public static BidContext? BuildContext(AuctionInfo auction, AuctionState state, DateTime nowLocal, string? myUsername)
{
if (auction == null || state == null) return null;
@@ -495,7 +603,6 @@ namespace AutoBidder.Ml
? 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))
{
@@ -510,10 +617,10 @@ namespace AutoBidder.Ml
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
if (!recent[i].IsAuto) break;
}
var ctx = new BidContext(
return new BidContext(
CycleSeconds: gap,
AutoShareRecent: autoShare,
DistinctBiddersRecent: Math.Max(1, distinct),
@@ -527,9 +634,6 @@ namespace AutoBidder.Ml
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>
@@ -547,9 +651,11 @@ namespace AutoBidder.Ml
lock (Sync)
{
_model = new OnlineLogit(BidFeatures.Size);
_challenger = NewChallenger();
_bandit = new ThompsonBandit();
_profile = new AuctionProfileStats();
_ingested.Clear();
_auctionsLearned = 0;
_brierLogit = double.NaN; _brierNet = double.NaN; _brierSamples = 0;
_loaded = true;
}
}
+274
View File
@@ -0,0 +1,274 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace AutoBidder.Ml
{
/// <summary>
/// Una rete neurale densa (MLP) in C# puro: strati completamente connessi, ReLU
/// nascosti, uscita lineare, ottimizzatore Adam, regolarizzazione L2. Niente librerie.
///
/// <para><b>A cosa serve qui.</b> Due usi. Come <i>sfidante</i> del modello logistico
/// per P(senza risposta): stesse variabili, stessi esempi, ma può imparare le
/// interazioni fra fasce che un modello lineare non vede (un ciclo lungo vale cose
/// diverse alle 12 e a mezzanotte). E come funzione Q dell'agente di rinforzo nel
/// simulatore. La valutazione prequenziale decide chi comanda: il modello che ha il
/// punteggio di Brier più basso sulle aste recenti.</para>
///
/// <para><b>Perché piccola.</b> Le variabili sono una settantina di indicatori a
/// fasce; una rete 70→32→16→1 ha circa tremila pesi e si addestra online, un esempio
/// alla volta, in microsecondi. Più grande non imparerebbe di più da questi dati:
/// li memorizzerebbe.</para>
///
/// <para>La calibrazione in linea è la stessa del modello logistico: il rapporto
/// osservato/previsto sugli ultimi esempi corregge la scala, perché il mercato
/// deriva e una rete addestrata su ieri sovrastima oggi.</para>
/// </summary>
public sealed class NeuralNet
{
private readonly int[] _sizes;
private readonly double[][] _w; // per strato: [out * in]
private readonly double[][] _b; // per strato: [out]
private readonly double[][] _mw, _vw, _mb, _vb; // momenti di Adam
private long _t;
// Attivazioni dell'ultimo passaggio in avanti, per la retropropagazione.
private readonly double[][] _a; // _a[0] = ingresso, _a[L] = uscita (lineare)
private readonly double[][] _z; // pre-attivazioni
public double LearningRate { get; set; } = 0.001;
public double L2 { get; set; } = 1e-5;
public double Beta1 { get; set; } = 0.9;
public double Beta2 { get; set; } = 0.999;
public long Updates { get; private set; }
// ── Calibrazione in linea (come OnlineLogit) ─────────────────────
private double _observedEma;
private double _predictedEma;
private const double CalibrationWindow = 20_000;
/// <summary>Fattore che riporta la scala delle probabilità a quella osservata di recente.</summary>
public double CalibrationFactor =>
_predictedEma > 1e-9 && Updates > 200 ? Math.Clamp(_observedEma / _predictedEma, 0.25, 4.0) : 1.0;
public int InputSize => _sizes[0];
public int OutputSize => _sizes[^1];
public IReadOnlyList<int> Sizes => _sizes;
public NeuralNet(int[] layerSizes, int seed = 42)
{
if (layerSizes == null || layerSizes.Length < 2) throw new ArgumentException("servono almeno ingresso e uscita");
_sizes = (int[])layerSizes.Clone();
var layers = _sizes.Length - 1;
_w = new double[layers][]; _b = new double[layers][];
_mw = new double[layers][]; _vw = new double[layers][];
_mb = new double[layers][]; _vb = new double[layers][];
_a = new double[_sizes.Length][];
_z = new double[_sizes.Length][];
var rng = new Random(seed);
for (var l = 0; l < layers; l++)
{
int nin = _sizes[l], nout = _sizes[l + 1];
_w[l] = new double[nout * nin];
_b[l] = new double[nout];
_mw[l] = new double[nout * nin]; _vw[l] = new double[nout * nin];
_mb[l] = new double[nout]; _vb[l] = new double[nout];
// Inizializzazione di He: adatta alla ReLU, tiene la varianza stabile in profondità.
var scale = Math.Sqrt(2.0 / nin);
for (var i = 0; i < _w[l].Length; i++) _w[l][i] = Gaussian(rng) * scale;
}
for (var l = 0; l < _sizes.Length; l++) { _a[l] = new double[_sizes[l]]; _z[l] = new double[_sizes[l]]; }
}
private static double Gaussian(Random rng)
{
var u1 = 1.0 - rng.NextDouble();
var u2 = rng.NextDouble();
return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
}
// ── In avanti ────────────────────────────────────────────────────
/// <summary>Uscita lineare della rete (senza sigmoide).</summary>
public double[] Forward(double[] x)
{
if (x.Length != _sizes[0]) throw new ArgumentException($"ingresso di {x.Length} valori, attesi {_sizes[0]}");
Array.Copy(x, _a[0], x.Length);
var layers = _sizes.Length - 1;
for (var l = 0; l < layers; l++)
{
int nin = _sizes[l], nout = _sizes[l + 1];
var w = _w[l]; var b = _b[l]; var ain = _a[l]; var z = _z[l + 1]; var aout = _a[l + 1];
for (var o = 0; o < nout; o++)
{
var sum = b[o];
var row = o * nin;
for (var i = 0; i < nin; i++) sum += w[row + i] * ain[i];
z[o] = sum;
aout[o] = l == layers - 1 ? sum : Math.Max(0, sum); // ReLU nascosti, uscita lineare
}
}
return (double[])_a[layers].Clone();
}
public static double Sigmoid(double z) => z >= 0 ? 1.0 / (1.0 + Math.Exp(-z)) : Math.Exp(z) / (1.0 + Math.Exp(z));
/// <summary>Probabilità grezza (rete a una uscita, perdita logistica).</summary>
public double Predict(double[] x) => Sigmoid(Forward(x)[0]);
/// <summary>Probabilità con la scala corretta dalla calibrazione in linea.</summary>
public double PredictCalibrated(double[] x) => Math.Clamp(Predict(x) * CalibrationFactor, 1e-6, 1 - 1e-6);
// ── Addestramento ────────────────────────────────────────────────
/// <summary>
/// Un passo online con perdita logistica su un'uscita sola: è il caso
/// «P(senza risposta)». Il gradiente sull'uscita lineare è semplicemente p y.
/// </summary>
public void Update(double[] x, bool y)
{
var p = Predict(x);
var target = y ? 1.0 : 0.0;
// Calibrazione: media mobile di osservato e previsto.
var k = Math.Min(1.0, 1.0 / Math.Min(CalibrationWindow, Math.Max(1, Updates + 1)));
_observedEma += k * (target - _observedEma);
_predictedEma += k * (p - _predictedEma);
Backward(new[] { p - target });
}
/// <summary>Un passo con perdita quadratica su tutte le uscite (regressione, funzione Q).</summary>
public double UpdateRegression(double[] x, double[] target)
{
var y = Forward(x);
var grad = new double[y.Length];
var loss = 0.0;
for (var i = 0; i < y.Length; i++)
{
grad[i] = y[i] - target[i];
loss += 0.5 * grad[i] * grad[i];
}
Backward(grad);
return loss;
}
/// <summary>
/// Come <see cref="UpdateRegression"/> ma aggiorna una sola uscita (l'azione
/// scelta): è ciò che serve al Q-learning, dove il bersaglio esiste solo per
/// l'azione presa.
/// </summary>
public double UpdateOutput(double[] x, int output, double target)
{
var y = Forward(x);
var grad = new double[y.Length];
grad[output] = y[output] - target;
Backward(grad);
return 0.5 * grad[output] * grad[output];
}
/// <summary>Retropropagazione dal gradiente sull'uscita lineare, con passo di Adam.</summary>
private void Backward(double[] gradOut)
{
var layers = _sizes.Length - 1;
var delta = (double[])gradOut.Clone();
_t++;
Updates++;
var lrT = LearningRate * Math.Sqrt(1 - Math.Pow(Beta2, _t)) / (1 - Math.Pow(Beta1, _t));
for (var l = layers - 1; l >= 0; l--)
{
int nin = _sizes[l], nout = _sizes[l + 1];
var w = _w[l]; var b = _b[l]; var ain = _a[l];
var prevDelta = l > 0 ? new double[nin] : null;
for (var o = 0; o < nout; o++)
{
var d = delta[o];
var row = o * nin;
// gradiente rispetto all'ingresso di questo strato (per lo strato sotto)
if (prevDelta != null)
for (var i = 0; i < nin; i++) prevDelta[i] += w[row + i] * d;
// Adam sui pesi
for (var i = 0; i < nin; i++)
{
var g = d * ain[i] + L2 * w[row + i];
var idx = row + i;
_mw[l][idx] = Beta1 * _mw[l][idx] + (1 - Beta1) * g;
_vw[l][idx] = Beta2 * _vw[l][idx] + (1 - Beta2) * g * g;
w[idx] -= lrT * _mw[l][idx] / (Math.Sqrt(_vw[l][idx]) + 1e-8);
}
_mb[l][o] = Beta1 * _mb[l][o] + (1 - Beta1) * d;
_vb[l][o] = Beta2 * _vb[l][o] + (1 - Beta2) * d * d;
b[o] -= lrT * _mb[l][o] / (Math.Sqrt(_vb[l][o]) + 1e-8);
}
if (prevDelta != null)
{
// derivata della ReLU: passa solo dove la pre-attivazione era positiva
for (var i = 0; i < nin; i++) if (_z[l][i] <= 0) prevDelta[i] = 0;
delta = prevDelta;
}
}
}
// ── Persistenza ──────────────────────────────────────────────────
private sealed class Snapshot
{
public int[] Sizes { get; set; } = Array.Empty<int>();
public double[][] W { get; set; } = Array.Empty<double[]>();
public double[][] B { get; set; } = Array.Empty<double[]>();
public long Updates { get; set; }
public double ObservedEma { get; set; }
public double PredictedEma { get; set; }
}
public string ToJson() => JsonSerializer.Serialize(new Snapshot
{
Sizes = _sizes, W = _w, B = _b, Updates = Updates, ObservedEma = _observedEma, PredictedEma = _predictedEma
});
/// <summary>
/// Ricarica i pesi. Passo, L2 e momenti di Adam NON si rileggono: i primi sono
/// parametri del codice, i momenti ripartono da zero (un breve riscaldamento).
/// Se la forma salvata non coincide con quella richiesta, si riparte da capo.
/// </summary>
public static NeuralNet FromJson(string json, int[] expectedSizes, int seed = 42)
{
var fresh = new NeuralNet(expectedSizes, seed);
try
{
var s = JsonSerializer.Deserialize<Snapshot>(json);
if (s == null || !s.Sizes.SequenceEqual(expectedSizes)) return fresh;
for (var l = 0; l < fresh._w.Length; l++)
{
if (s.W[l].Length != fresh._w[l].Length || s.B[l].Length != fresh._b[l].Length) return new NeuralNet(expectedSizes, seed);
Array.Copy(s.W[l], fresh._w[l], s.W[l].Length);
Array.Copy(s.B[l], fresh._b[l], s.B[l].Length);
}
fresh.Updates = s.Updates;
fresh._observedEma = s.ObservedEma;
fresh._predictedEma = s.PredictedEma;
return fresh;
}
catch
{
return fresh;
}
}
}
}
+303
View File
@@ -0,0 +1,303 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Data;
namespace AutoBidder.Ml
{
/// <summary>
/// Il simulatore di un'asta al centesimo, con avversari sintetici tarati sui dati
/// osservati. È il banco di prova sicuro: qui una policy si può lasciar giocare
/// migliaia di aste senza spendere un centesimo, e si può misurare quello che la
/// rigiocata dei dati veri non può dire — come reagiscono gli altri a una nostra
/// puntata.
///
/// <para><b>Gli avversari.</b> Quattro profili, come nel documento di progetto:
/// <i>aggressivo</i> punta presto e insiste; <i>cecchino</i> punta negli ultimi
/// secondi; <i>autopuntata</i> spara a 2 secondi esatti finché ha credito;
/// <i>principiante</i> punta a caso e molla presto. Ognuno ha un credito di puntate e
/// una persistenza che <b>cresce con quanto ha già speso</b>: è l'effetto del costo
/// affondato documentato da Augenblick, ed è ciò che rende le aste lunghe.</para>
///
/// <para><b>La taratura.</b> Da <see cref="Calibration.FromDatabase"/>: quanti
/// puntatori distinti per asta, a che profondità del ciclo arrivano le puntate
/// (tabella <c>resets</c>), la quota di autopuntate, a che frazione del valore chiude
/// l'asta. Il simulatore non pretende di essere Bidoo: pretende di sbagliare nello
/// stesso verso su tutte le policy, così che il confronto fra loro sia onesto.</para>
///
/// <para><b>Il ciclo.</b> Ogni passo è un ciclo del timer. Si chiede all'agente cosa
/// fa (niente, oppure punta all'anticipo dato); gli avversari attivi estraggono
/// ciascuno il momento in cui punterebbero; vince chi arriva prima. Se nessuno punta
/// e non puntiamo noi, l'asta finisce e vince chi era in testa.</para>
/// </summary>
public sealed class PennyAuctionEnv
{
public enum OpponentType { Aggressive, Sniper, AutoBid, Beginner }
/// <summary>Parametri appresi dai dati, con predefiniti sensati per quando i dati mancano.</summary>
public sealed class Calibration
{
public double Value { get; set; } = 11;
public double BidCost { get; set; } = 0.20;
public double TransactionFee { get; set; } = 1.0;
public double Shipping { get; set; } = 0;
public double TimerSeconds { get; set; } = 10;
/// <summary>Puntatori distinti per asta: media e dispersione.</summary>
public double MeanBidders { get; set; } = 4;
public double AutoShare { get; set; } = 0.35;
/// <summary>Istogramma della profondità dei cicli (secondi residui quando arriva la puntata), da resets.min_timer.</summary>
public double[] CycleDepthHistogram { get; set; } = { 0.05, 0.10, 0.30, 0.25, 0.15, 0.08, 0.04, 0.02, 0.005, 0.005 };
/// <summary>Prezzo finale in frazione del valore: mediana osservata, usata per tarare la persistenza.</summary>
public double MedianFinalPriceRatio { get; set; } = 0.06;
public int Auctions { get; set; }
public static Calibration FromDatabase(AuctionDatabase db, string? productKey = null)
{
var c = new Calibration();
try
{
db.Flush();
var where = productKey == null ? "" : " AND a.product_key = ?1";
var args = productKey == null ? Array.Empty<object?>() : new object?[] { productKey };
var a = db.QueryOne(
"SELECT COUNT(*) AS n, AVG(distinct_bidders) AS bidders, AVG(buy_now_price) AS v, AVG(bid_fee) AS fee, AVG(shipping) AS ship " +
"FROM auctions a WHERE outcome IS NOT NULL AND distinct_bidders > 0" + where, args);
if (a != null && a.Long("n") > 0)
{
c.Auctions = (int)a.Long("n");
c.MeanBidders = Math.Max(1, a.Dbl("bidders"));
if (a.DblOrNull("v") is > 0) c.Value = a.Dbl("v");
if (a.DblOrNull("fee") is > 0) c.BidCost = a.Dbl("fee");
if (a.DblOrNull("ship") is > 0) c.Shipping = a.Dbl("ship");
}
var ratios = db.Query(
"SELECT final_price / buy_now_price AS r FROM auctions a WHERE outcome IS NOT NULL AND buy_now_price > 0 AND final_price > 0" + where + " ORDER BY r", args)
.Select(r => r.Dbl("r")).ToList();
if (ratios.Count >= 5) c.MedianFinalPriceRatio = ratios[ratios.Count / 2];
var types = db.QueryOne(
"SELECT SUM(CASE WHEN b.bid_type = 'Auto' THEN 1 ELSE 0 END) AS auto, SUM(CASE WHEN b.bid_type IN ('Auto', 'Manuale') THEN 1 ELSE 0 END) AS known " +
"FROM bids b JOIN auctions a ON a.auction_id = b.auction_id WHERE a.outcome IS NOT NULL" + where, args);
if (types != null && types.Long("known") > 50) c.AutoShare = (double)types.Long("auto") / types.Long("known");
var depths = db.Query(
"SELECT r.min_timer AS d FROM resets r JOIN auctions a ON a.auction_id = r.auction_id WHERE r.min_timer IS NOT NULL AND a.outcome IS NOT NULL" + where, args)
.Select(r => r.Dbl("d")).ToList();
if (depths.Count >= 100)
{
var hist = new double[10];
foreach (var d in depths) hist[Math.Clamp((int)Math.Floor(d), 0, 9)]++;
var total = hist.Sum();
for (var i = 0; i < hist.Length; i++) hist[i] = Math.Max(0.002, hist[i] / total);
c.CycleDepthHistogram = hist;
}
}
catch { /* si resta sui predefiniti */ }
return c;
}
}
public sealed class Opponent
{
public OpponentType Type;
public int Budget; // puntate che è disposto a spendere
public int Spent;
public bool Active = true;
/// <summary>Probabilità di rispondere a un ciclo, crescente con lo speso (costo affondato).</summary>
public double Persistence(double baseP) =>
Math.Min(0.98, baseP + 0.04 * Math.Min(Spent, 15));
}
/// <summary>Lo stato osservabile dall'agente a ogni ciclo.</summary>
public readonly record struct Observation(
double Price,
double PriceRatio,
int ActiveOpponents,
int TotalBids,
double LastGapSeconds,
bool AutoBidPresent,
int MyBids,
double MyBidsEuro,
bool ImLeading);
public readonly record struct StepResult(Observation Next, double Reward, bool Done, bool Won);
private readonly Calibration _cal;
private readonly Random _rng;
private readonly List<Opponent> _opponents = new();
private double _price;
private int _totalBids;
private int _myBids;
private bool _imLeading;
private double _lastGap;
private bool _done;
public PennyAuctionEnv(Calibration calibration, int seed = 1)
{
_cal = calibration;
_rng = new Random(seed);
}
public Calibration Cal => _cal;
/// <summary>Comincia un'asta nuova con avversari estratti dalla taratura.</summary>
public Observation Reset()
{
_opponents.Clear();
_price = 0.01;
_totalBids = 1;
_myBids = 0;
_imLeading = false;
_lastGap = _cal.TimerSeconds;
_done = false;
var n = Math.Max(1, (int)Math.Round(Gaussian() * Math.Sqrt(_cal.MeanBidders) + _cal.MeanBidders));
for (var i = 0; i < n; i++)
{
var r = _rng.NextDouble();
var type = r < _cal.AutoShare ? OpponentType.AutoBid
: r < _cal.AutoShare + 0.25 ? OpponentType.Sniper
: r < _cal.AutoShare + 0.45 ? OpponentType.Aggressive
: OpponentType.Beginner;
// Il credito complessivo degli avversari deve produrre chiusure vicine a
// quelle osservate: il prezzo finale in centesimi è il numero di puntate.
var expectedTotal = Math.Max(5, _cal.MedianFinalPriceRatio * _cal.Value * 100);
var budget = type switch
{
OpponentType.AutoBid => (int)(expectedTotal * (0.3 + 0.6 * _rng.NextDouble())),
OpponentType.Aggressive => (int)(expectedTotal * (0.2 + 0.5 * _rng.NextDouble())),
OpponentType.Sniper => (int)(expectedTotal * (0.1 + 0.3 * _rng.NextDouble())),
_ => (int)(expectedTotal * (0.02 + 0.15 * _rng.NextDouble()))
};
_opponents.Add(new Opponent { Type = type, Budget = Math.Max(1, budget) });
}
return Observe();
}
private Observation Observe() => new(
Price: _price,
PriceRatio: _cal.Value > 0 ? _price / _cal.Value : 0,
ActiveOpponents: _opponents.Count(o => o.Active),
TotalBids: _totalBids,
LastGapSeconds: _lastGap,
AutoBidPresent: _opponents.Any(o => o.Active && o.Type == OpponentType.AutoBid),
MyBids: _myBids,
MyBidsEuro: _myBids * _cal.BidCost,
ImLeading: _imLeading);
/// <summary>
/// Un ciclo. <paramref name="bidAtLeadSeconds"/> = null per non puntare, altrimenti
/// l'anticipo (secondi residui) a cui la nostra puntata partirebbe.
/// </summary>
public StepResult Step(double? bidAtLeadSeconds)
{
if (_done) return new StepResult(Observe(), 0, true, _imLeading);
// Ogni avversario attivo decide se e quando punterebbe in questo ciclo.
var bestOpponentTime = double.NegativeInfinity; // secondi residui: più alto = prima
Opponent? bidder = null;
foreach (var o in _opponents)
{
if (!o.Active) continue;
if (o.Spent >= o.Budget) { o.Active = false; continue; }
var baseP = o.Type switch
{
OpponentType.AutoBid => 0.97,
OpponentType.Aggressive => 0.75,
OpponentType.Sniper => 0.55,
_ => 0.35
};
if (_rng.NextDouble() > o.Persistence(baseP))
{
// Non risponde a questo ciclo: i principianti mollano per sempre.
if (o.Type == OpponentType.Beginner && _rng.NextDouble() < 0.5) o.Active = false;
continue;
}
var when = o.Type switch
{
OpponentType.AutoBid => 2.0,
OpponentType.Sniper => 0.5 + 2.5 * _rng.NextDouble(),
OpponentType.Aggressive => Math.Min(_cal.TimerSeconds, 5 + 5 * _rng.NextDouble()),
_ => SampleDepth()
};
if (when > bestOpponentTime) { bestOpponentTime = when; bidder = o; }
}
var reward = 0.0;
var weBid = bidAtLeadSeconds is { } lead && !_imLeading;
var ourTime = weBid ? bidAtLeadSeconds!.Value : double.NegativeInfinity;
if (bidder != null && bestOpponentTime >= ourTime)
{
// Un avversario arriva prima di noi (o noi non puntiamo).
bidder.Spent++;
_price += Theory.PriceIncrement;
_totalBids++;
_imLeading = false;
_lastGap = _cal.TimerSeconds - bestOpponentTime;
return new StepResult(Observe(), 0, false, false);
}
if (weBid)
{
// La nostra puntata resetta il timer; si paga subito.
_myBids++;
_price += Theory.PriceIncrement;
_totalBids++;
_imLeading = true;
_lastGap = _cal.TimerSeconds - ourTime;
reward -= _cal.BidCost;
return new StepResult(Observe(), reward, false, false);
}
// Nessuno ha puntato: l'asta finisce.
_done = true;
if (_imLeading)
{
reward += _cal.Value - _price - _cal.TransactionFee - _cal.Shipping;
return new StepResult(Observe(), reward, true, true);
}
return new StepResult(Observe(), 0, true, false);
}
private double SampleDepth()
{
var h = _cal.CycleDepthHistogram;
var u = _rng.NextDouble() * h.Sum();
for (var i = 0; i < h.Length; i++)
{
u -= h[i];
if (u <= 0) return Math.Min(_cal.TimerSeconds, i + _rng.NextDouble());
}
return 0.5;
}
private double Gaussian()
{
var u1 = 1.0 - _rng.NextDouble();
var u2 = _rng.NextDouble();
return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
}
/// <summary>Il prezzo corrente, per chi vuole valutare la fine.</summary>
public double Price => _price;
public bool ImLeading => _imLeading;
public int MyBids => _myBids;
}
}
+214
View File
@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace AutoBidder.Ml
{
/// <summary>
/// Apprendimento per rinforzo nel simulatore: un agente Q-learning con funzione Q su
/// rete neurale (<see cref="NeuralNet"/>), replay delle esperienze, esplorazione
/// ε-greedy decrescente, e una penalità conservativa sulle azioni non viste.
///
/// <para><b>Il problema di decisione</b> (§3.5 del documento): stato = prezzo sul
/// valore, avversari attivi, ritmo delle puntate, presenza dell'autopuntata, quanto
/// abbiamo speso, se siamo in testa; azioni = NON PUNTARE, PUNTA; ricompensa = c a
/// ogni puntata, +(V prezzo fee spedizione) se si vince, 0 se si esce.
/// Sconto γ vicino a 1: la ricompensa che conta è quella finale.</para>
///
/// <para><b>Perché nel simulatore e non dal vivo.</b> Non si esplora con denaro
/// vero. La penalità conservativa (nello spirito di CQL, Kumar et al. 2020) abbassa
/// le Q delle azioni che il replay ha visto poco: un agente ottimista su ciò che non
/// conosce è esattamente ciò che si vuole evitare quando poi lo si guarda in
/// shadow.</para>
///
/// <para>L'agente è una <i>policy</i> come le altre: si confronta con la regola del
/// valore atteso e con il bandit su aste simulate identiche (stessi semi), e la
/// scheda mostra ROI per puntata, tasso di vittoria, costo per vittoria.</para>
/// </summary>
public sealed class QLearningAgent
{
public const int StateSize = 9;
public const int Actions = 2; // 0 = NON PUNTARE, 1 = PUNTA
private readonly NeuralNet _q;
private readonly Random _rng;
private readonly List<(double[] S, int A, double R, double[]? S2)> _replay = new();
private const int ReplayCap = 20_000;
public double Gamma { get; set; } = 0.98;
public double Epsilon { get; private set; } = 0.30;
public double EpsilonMin { get; set; } = 0.02;
public double EpsilonDecay { get; set; } = 0.999;
/// <summary>Quanto si penalizzano le Q delle azioni poco viste (0 = Q-learning puro).</summary>
public double ConservativeWeight { get; set; } = 0.02;
public long Episodes { get; private set; }
private readonly long[] _actionCounts = new long[Actions];
public QLearningAgent(int seed = 7)
{
_q = new NeuralNet(new[] { StateSize, 32, 16, Actions }, seed) { LearningRate = 0.0007, L2 = 1e-6 };
_rng = new Random(seed);
}
public static double[] Encode(in PennyAuctionEnv.Observation o) => new[]
{
Math.Min(1, o.PriceRatio * 5), // prezzo sul valore, scalato
Math.Min(1, o.ActiveOpponents / 8.0),
Math.Min(1, o.TotalBids / 500.0),
Math.Min(1, o.LastGapSeconds / 10.0),
o.AutoBidPresent ? 1 : 0,
Math.Min(1, o.MyBids / 50.0),
Math.Min(1, o.MyBidsEuro / 10.0),
o.ImLeading ? 1 : 0,
1.0 // bias
};
/// <summary>L'azione secondo la policy appresa (senza esplorazione).</summary>
public int Act(in PennyAuctionEnv.Observation o)
{
var q = _q.Forward(Encode(o));
return q[1] > q[0] ? 1 : 0;
}
public double[] QValues(in PennyAuctionEnv.Observation o) => _q.Forward(Encode(o));
/// <summary>Un episodio di addestramento (un'asta simulata). Restituisce la ricompensa totale.</summary>
public double TrainEpisode(PennyAuctionEnv env, double leadSeconds)
{
var obs = env.Reset();
var total = 0.0;
var steps = 0;
while (steps++ < 2000)
{
var s = Encode(obs);
int a;
if (_rng.NextDouble() < Epsilon) a = _rng.Next(Actions);
else { var q = _q.Forward(s); a = q[1] > q[0] ? 1 : 0; }
var r = env.Step(a == 1 ? leadSeconds : null);
total += r.Reward;
_actionCounts[a]++;
var s2 = r.Done ? null : Encode(r.Next);
Remember(s, a, r.Reward, s2);
Learn(4);
if (r.Done) break;
obs = r.Next;
}
Episodes++;
Epsilon = Math.Max(EpsilonMin, Epsilon * EpsilonDecay);
return total;
}
private void Remember(double[] s, int a, double r, double[]? s2)
{
if (_replay.Count >= ReplayCap) _replay.RemoveAt(_rng.Next(_replay.Count / 2));
_replay.Add((s, a, r, s2));
}
private void Learn(int batch)
{
if (_replay.Count < 64) return;
var total = Math.Max(1, _actionCounts.Sum());
for (var k = 0; k < batch; k++)
{
var (s, a, r, s2) = _replay[_rng.Next(_replay.Count)];
var target = r;
if (s2 != null)
{
var qNext = _q.Forward(s2);
target += Gamma * Math.Max(qNext[0], qNext[1]);
}
// Penalità conservativa: un'azione vista di rado ha un bersaglio più basso,
// in proporzione a quanto è rara. Evita Q ottimiste su ciò che non si conosce.
var rarity = 1.0 - (double)_actionCounts[a] / total;
target -= ConservativeWeight * rarity;
_q.UpdateOutput(s, a, target);
}
}
// ── Confronto fra policy nel simulatore ──────────────────────────
public sealed class PolicyResult
{
public string Name { get; init; } = "";
public int Auctions { get; init; }
public int Wins { get; init; }
public int Bids { get; init; }
public double Spent { get; init; }
public double Gained { get; init; }
public double MaxDrawdown { get; init; }
public double Net => Gained - Spent;
public double RoiPerBid => Bids > 0 ? Net / Bids : 0;
public double WinRate => Auctions > 0 ? (double)Wins / Auctions : 0;
public double CostPerWin => Wins > 0 ? Spent / Wins : double.NaN;
}
/// <summary>Una policy da valutare: dallo stato all'anticipo (null = non puntare).</summary>
public delegate double? Policy(in PennyAuctionEnv.Observation o);
/// <summary>
/// Gioca <paramref name="auctions"/> aste simulate con una policy e ne misura i
/// risultati. Gli stessi semi per ogni policy: le differenze sono della policy,
/// non del caso.
/// </summary>
public static PolicyResult Evaluate(string name, PennyAuctionEnv.Calibration cal, Policy policy, int auctions, int seed = 1000)
{
int wins = 0, bids = 0;
double spent = 0, gained = 0, balance = 0, peak = 0, drawdown = 0;
for (var i = 0; i < auctions; i++)
{
var env = new PennyAuctionEnv(cal, seed + i);
var obs = env.Reset();
var steps = 0;
while (steps++ < 2000)
{
var lead = policy(obs);
var r = env.Step(lead);
if (r.Reward < 0) { spent += -r.Reward; bids++; }
else if (r.Reward > 0) gained += r.Reward;
balance += r.Reward;
if (balance > peak) peak = balance;
if (peak - balance > drawdown) drawdown = peak - balance;
if (r.Done) { if (r.Won) wins++; break; }
obs = r.Next;
}
}
return new PolicyResult { Name = name, Auctions = auctions, Wins = wins, Bids = bids, Spent = spent, Gained = gained, MaxDrawdown = drawdown };
}
public static string Format(IEnumerable<PolicyResult> results)
{
var sb = new StringBuilder();
sb.AppendLine(" policy aste vinte puntate spesi € incassati € netto € ROI/puntata costo/vittoria drawdown €");
sb.AppendLine(" ---------------------------------------------------------------------------------------------------------------------");
foreach (var r in results)
{
sb.AppendLine(string.Format(System.Globalization.CultureInfo.GetCultureInfo("it-IT"),
" {0,-22} {1,6} {2,7} {3,9} {4,9:F2} {5,13:F2} {6,10:F2} {7,13:F3} {8,16} {9,12:F2}",
r.Name, r.Auctions, r.Wins, r.Bids, r.Spent, r.Gained, r.Net, r.RoiPerBid,
double.IsNaN(r.CostPerWin) ? "—" : r.CostPerWin.ToString("F2"), r.MaxDrawdown));
}
return sb.ToString();
}
public string ToJson() => _q.ToJson();
}
}
+165
View File
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder.Ml
{
/// <summary>
/// Cosa avrebbero reso le decisioni registrate, policy per policy, con l'esito noto.
///
/// <para>Ogni decisione ha un esito alla chiusura dell'asta: se il prezzo non è più
/// salito dopo quell'istante, una puntata lì sarebbe stata l'ultima. Con questo si
/// valuta ogni policy sugli stessi istanti, dal vivo e in shadow: quante volte
/// avrebbe puntato, quante di quelle sarebbero state vincenti, quanto avrebbe speso e
/// incassato al valore reale del bene. È la valutazione <i>off-policy</i> più onesta
/// che i dati permettono — con l'avvertenza che una puntata nostra può cambiare il
/// comportamento altrui, e questo nessun dato registrato lo dice.</para>
///
/// <para>Il confronto è sempre con il null-model: profitto atteso per puntata zero.
/// Un ROI per puntata positivo su poche decine di decisioni non prova nulla; su
/// migliaia, e con l'estremo inferiore dell'intervallo sopra lo zero, comincia a
/// dire qualcosa.</para>
/// </summary>
public static class ShadowReport
{
public sealed class PolicyStats
{
public string Name { get; init; } = "";
public int Decisions { get; set; }
public int Bids { get; set; }
public int Wins { get; set; }
public double Spent { get; set; }
public double Gained { get; set; }
public double ForecastEv { get; set; }
public readonly List<double> PerBid = new();
public double Net => Gained - Spent;
public double RoiPerBid => Bids > 0 ? Net / Bids : 0;
public double WinRate => Bids > 0 ? (double)Wins / Bids : 0;
/// <summary>Estremo inferiore (95%) del ROI per puntata: sotto zero, il null-model non è battuto.</summary>
public double RoiLower95
{
get
{
if (PerBid.Count < 2) return double.NaN;
var m = PerBid.Average();
var sd = Math.Sqrt(PerBid.Sum(x => (x - m) * (x - m)) / (PerBid.Count - 1));
return m - 1.96 * sd / Math.Sqrt(PerBid.Count);
}
}
}
public sealed class Report
{
public int Days { get; init; }
public int TotalDecisions { get; init; }
public int WithOutcome { get; init; }
public int LiveBids { get; init; }
public int LiveWins { get; init; }
public List<PolicyStats> Policies { get; } = new();
public string Text { get; set; } = "";
}
public static Report Compute(AuctionDatabase db, AppSettings settings, int days = 30)
{
db.Flush();
var since = AuctionDatabase.Iso(DateTime.Today.AddDays(-days));
var rows = db.Query(
"SELECT d.mode, d.action, d.outcome, d.ev_estimate, d.price, d.alt_action, d.executed, " +
" COALESCE(a.market_value, a.buy_now_price) AS v, a.shipping, a.bid_fee " +
"FROM bot_decisions d JOIN auctions a ON a.auction_id = d.auction_id " +
"WHERE d.ts >= ?1 AND d.outcome IS NOT NULL", since);
var total = (int)db.ScalarLong("SELECT COUNT(*) AS n FROM bot_decisions WHERE ts >= ?1", since);
var acting = new PolicyStats { Name = "modello + regime (in uso)" };
var alt = new PolicyStats { Name = "bandit Thompson (shadow)" };
var always = new PolicyStats { Name = "punta sempre" };
var never = new PolicyStats { Name = "non punta mai" };
int liveBids = 0, liveWins = 0;
foreach (var r in rows)
{
var outcome = r.Str("outcome");
var unanswered = outcome is "won" or "would_win" or "missed";
var v = r.DblOrNull("v") ?? 0;
var price = r.Dbl("price");
var fee = settings.TransactionFeeEuro;
var ship = r.DblOrNull("shipping") ?? 0;
var cost = r.DblOrNull("bid_fee") is > 0 ? r.Dbl("bid_fee") : settings.AverageBidCostEuro;
var gainIfWin = v - (price + Theory.PriceIncrement) - fee - ship;
void Apply(PolicyStats s, bool bid, double? forecast)
{
s.Decisions++;
if (!bid) return;
s.Bids++;
s.Spent += cost;
if (forecast is { } f) s.ForecastEv += f;
if (unanswered)
{
s.Wins++;
s.Gained += gainIfWin;
s.PerBid.Add(gainIfWin - cost);
}
else s.PerBid.Add(-cost);
}
var actingBid = r.Str("action") == "BID";
Apply(acting, actingBid, r.DblOrNull("ev_estimate"));
Apply(alt, r.Str("alt_action") == "BID", null);
Apply(always, true, null);
Apply(never, false, null);
if (r.Str("mode") == "live" && r.Bool("executed"))
{
liveBids++;
if (outcome == "won") liveWins++;
}
}
var report = new Report
{
Days = days, TotalDecisions = total, WithOutcome = rows.Count, LiveBids = liveBids, LiveWins = liveWins
};
report.Policies.AddRange(new[] { acting, alt, always, never });
report.Text = Format(report);
return report;
}
public static string Format(Report r)
{
var sb = new StringBuilder();
var c = System.Globalization.CultureInfo.GetCultureInfo("it-IT");
sb.AppendLine($"Decisioni negli ultimi {r.Days} giorni: {r.TotalDecisions:N0}, con esito noto {r.WithOutcome:N0}. " +
$"Puntate dal vivo: {r.LiveBids:N0}, di cui vincenti {r.LiveWins:N0}.");
sb.AppendLine();
sb.AppendLine(" policy decisioni puntate vincenti spesi € incassati € netto € ROI/puntata IC95% inf. EV previsto");
sb.AppendLine(" ------------------------------------------------------------------------------------------------------------------------");
foreach (var p in r.Policies)
{
sb.AppendLine(string.Format(c,
" {0,-30} {1,9} {2,8} {3,9} {4,9:F2} {5,12:F2} {6,9:F2} {7,12:F3} {8,10} {9,12}",
p.Name, p.Decisions, p.Bids, p.Wins, p.Spent, p.Gained, p.Net, p.RoiPerBid,
double.IsNaN(p.RoiLower95) ? "—" : p.RoiLower95.ToString("F3", c),
p.Name.Contains("in uso") ? p.ForecastEv.ToString("F2", c) : "—"));
}
sb.AppendLine();
sb.AppendLine(" Il null-model teorico (Augenblick 2016) prevede ROI per puntata = 0. Una policy batte il caso solo se");
sb.AppendLine(" l'estremo inferiore dell'intervallo di confidenza è sopra zero, e su abbastanza puntate da contare.");
sb.AppendLine(" «Vincenti» = il prezzo non è più salito dopo quell'istante: una puntata lì sarebbe stata l'ultima.");
sb.AppendLine(" Avvertenza: una puntata nostra può far rispondere chi stava per smettere; i dati non lo dicono.");
return sb.ToString();
}
}
}
+193
View File
@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder.Ml
{
/// <summary>
/// Il laboratorio: mette le policy a confronto nel simulatore, su aste identiche
/// (stessi semi), e scrive un rapporto. Le policy: non puntare mai, puntare sempre
/// all'anticipo, la regola del valore atteso con P empirica, il bandit Thompson che
/// impara giocando, e l'agente Q addestrato nel simulatore.
///
/// <para>La taratura viene dal database quando ci sono abbastanza aste; altrimenti
/// dai predefiniti, e il rapporto lo dice. Il simulatore non è Bidoo: il confronto
/// fra policy è ciò che vale, non i numeri assoluti.</para>
/// </summary>
public static class SimulationLab
{
public static string Run(AuctionDatabase? db, AppSettings settings, int auctions, Action<string>? progress, CancellationToken ct)
{
var sb = new StringBuilder();
var cal = db != null ? PennyAuctionEnv.Calibration.FromDatabase(db) : new PennyAuctionEnv.Calibration();
cal.BidCost = settings.AverageBidCostEuro > 0 ? settings.AverageBidCostEuro : cal.BidCost;
cal.TransactionFee = settings.TransactionFeeEuro;
var leadSeconds = Math.Clamp(LatencyModel.RecommendedLeadMs(settings) / 1000.0, 0.3, 3.0);
sb.AppendLine("SIMULATORE — confronto fra policy su aste identiche");
sb.AppendLine();
sb.AppendLine(cal.Auctions > 0
? $" Taratura da {cal.Auctions:N0} aste del database: {cal.MeanBidders:F1} puntatori per asta, autopuntate {cal.AutoShare:P0}, " +
$"chiusura mediana al {cal.MedianFinalPriceRatio:P1} del valore ({cal.Value:F2} €), puntata {cal.BidCost:F2} €."
: $" Nessuna asta nel database: taratura predefinita (valore {cal.Value:F2} €, {cal.MeanBidders:F0} puntatori, autopuntate {cal.AutoShare:P0}).");
sb.AppendLine($" Anticipo di puntata: {leadSeconds:F2} s (dal modello di latenza). Aste simulate per policy: {auctions:N0}.");
sb.AppendLine();
ct.ThrowIfCancellationRequested();
progress?.Invoke("policy di base…");
var results = new List<QLearningAgent.PolicyResult>
{
QLearningAgent.Evaluate("non punta mai", cal, (in PennyAuctionEnv.Observation _) => null, auctions),
QLearningAgent.Evaluate("punta sempre", cal, (in PennyAuctionEnv.Observation o) => o.ImLeading ? null : leadSeconds, auctions)
};
// P empirica per contesto, misurata proprio in questo simulatore: la quota di
// nostre puntate rimaste senza risposta, per numero di avversari attivi e
// fascia di prezzo. È il «modello» della regola del valore atteso.
ct.ThrowIfCancellationRequested();
progress?.Invoke("stima della P empirica…");
var empirical = EmpiricalP(cal, leadSeconds, Math.Max(200, auctions), seed: 5000);
double? EvRule(in PennyAuctionEnv.Observation o)
{
if (o.ImLeading) return null;
var p = empirical(o);
var s = new Theory.BidSituation(p, cal.Value, o.Price, cal.BidCost, cal.TransactionFee, cal.Shipping, cal.Value, o.MyBidsEuro, settings.BuyNowHedgeEnabled);
return Theory.BidExpectedValue(s) > 0 ? leadSeconds : null;
}
results.Add(QLearningAgent.Evaluate("valore atteso (P empirica)", cal, EvRule, auctions));
ct.ThrowIfCancellationRequested();
progress?.Invoke("bandit Thompson…");
results.Add(BanditPolicy(cal, leadSeconds, auctions, settings.BuyNowHedgeEnabled));
ct.ThrowIfCancellationRequested();
progress?.Invoke("addestramento dell'agente Q…");
var agent = new QLearningAgent(seed: 7);
var episodes = Math.Clamp(auctions * 2, 300, 1500);
for (var i = 0; i < episodes; i++)
{
if (i % 100 == 0) ct.ThrowIfCancellationRequested();
agent.TrainEpisode(new PennyAuctionEnv(cal, 10_000 + i), leadSeconds);
}
results.Add(QLearningAgent.Evaluate($"agente Q ({episodes} episodi)", cal, (in PennyAuctionEnv.Observation o) => agent.Act(o) == 1 ? leadSeconds : null, auctions));
sb.AppendLine(QLearningAgent.Format(results));
sb.AppendLine();
sb.AppendLine(" Il null-model prevede ROI per puntata = 0. Qui gli avversari sintetici hanno il costo affondato degli");
sb.AppendLine(" umani (Augenblick): una policy che guadagna lo fa sfruttando quello, non un vantaggio strutturale.");
sb.AppendLine(" I numeri assoluti sono del simulatore; il confronto fra policy è ciò che si porta a casa.");
return sb.ToString();
}
/// <summary>P(senza risposta) per contesto, misurata puntando sempre in aste simulate.</summary>
private static QLearningAgent.Policy EmpiricalPPolicy(Func<PennyAuctionEnv.Observation, double> p, double lead) =>
(in PennyAuctionEnv.Observation o) => p(o) > 0 ? lead : null;
private static Func<PennyAuctionEnv.Observation, double> EmpiricalP(PennyAuctionEnv.Calibration cal, double lead, int auctions, int seed)
{
var wins = new Dictionary<(int, int), int>();
var tries = new Dictionary<(int, int), int>();
for (var i = 0; i < auctions; i++)
{
var env = new PennyAuctionEnv(cal, seed + i);
var obs = env.Reset();
(int, int)? lastKey = null;
var steps = 0;
while (steps++ < 2000)
{
if (!obs.ImLeading)
{
var key = Key(obs);
tries[key] = tries.GetValueOrDefault(key) + 1;
lastKey = key;
var r = env.Step(lead);
if (r.Done) { if (r.Won && lastKey != null) wins[lastKey.Value] = wins.GetValueOrDefault(lastKey.Value) + 1; break; }
obs = r.Next;
}
else
{
var r = env.Step(null);
if (r.Done) { if (r.Won && lastKey != null) wins[lastKey.Value] = wins.GetValueOrDefault(lastKey.Value) + 1; break; }
obs = r.Next;
}
}
}
var overall = tries.Values.Sum() > 0 ? (double)wins.Values.Sum() / tries.Values.Sum() : 0.05;
return o =>
{
var k = Key(o);
var n = tries.GetValueOrDefault(k);
if (n < 20) return overall;
return (double)wins.GetValueOrDefault(k) / n;
};
static (int, int) Key(in PennyAuctionEnv.Observation o) =>
(Math.Min(5, o.ActiveOpponents), o.PriceRatio < 0.02 ? 0 : o.PriceRatio < 0.05 ? 1 : o.PriceRatio < 0.10 ? 2 : o.PriceRatio < 0.2 ? 3 : 4);
}
/// <summary>Il bandit impara giocando: a fine asta sa quale delle sue puntate è rimasta senza risposta.</summary>
private static QLearningAgent.PolicyResult BanditPolicy(PennyAuctionEnv.Calibration cal, double lead, int auctions, bool hedge)
{
var bandit = new ThompsonBandit { BaseRate = 0.05, PriorStrength = 2 };
var rng = new Random(77);
int wins = 0, bids = 0;
double spent = 0, gained = 0, balance = 0, peak = 0, drawdown = 0;
for (var i = 0; i < auctions; i++)
{
var env = new PennyAuctionEnv(cal, 1000 + i); // stessi semi di Evaluate
var obs = env.Reset();
var keys = new List<string>();
var steps = 0;
while (steps++ < 2000)
{
double? action = null;
string? key = null;
if (!obs.ImLeading)
{
key = $"o{Math.Min(5, obs.ActiveOpponents)}|r{(obs.PriceRatio < 0.02 ? 0 : obs.PriceRatio < 0.05 ? 1 : obs.PriceRatio < 0.10 ? 2 : obs.PriceRatio < 0.2 ? 3 : 4)}|a{(obs.AutoBidPresent ? 1 : 0)}";
var s = new Theory.BidSituation(0, cal.Value, obs.Price, cal.BidCost, cal.TransactionFee, cal.Shipping, cal.Value, obs.MyBidsEuro, hedge);
var (bid, _) = bandit.Decide(key, s, rng);
if (bid) action = lead;
}
var r = env.Step(action);
if (action != null && key != null) { keys.Add(key); bids++; spent += cal.BidCost; }
if (r.Reward > 0) gained += r.Reward;
balance += r.Reward;
if (balance > peak) peak = balance;
if (peak - balance > drawdown) drawdown = peak - balance;
if (r.Done)
{
if (r.Won) wins++;
// Tutte le puntate tranne l'ultima sono state coperte; l'ultima
// è rimasta senza risposta solo se abbiamo vinto.
for (var k = 0; k < keys.Count; k++) bandit.Observe(keys[k], r.Won && k == keys.Count - 1);
break;
}
obs = r.Next;
}
}
return new QLearningAgent.PolicyResult
{
Name = "bandit Thompson (impara)", Auctions = auctions, Wins = wins, Bids = bids,
Spent = spent, Gained = gained, MaxDrawdown = drawdown
};
}
}
}
+217
View File
@@ -0,0 +1,217 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AutoBidder.Ml
{
/// <summary>
/// Il modello matematico delle aste al centesimo, in formule esplicite e con la
/// fonte accanto. Tutto ciò che il motore calcola in euro passa di qui.
///
/// <para><b>Il fatto da cui partire.</b> Augenblick (2016, <i>Review of Economic
/// Studies</i> 83(1)), su 166.000 aste: il margine medio del banco supera il 50% del
/// valore del bene; il 75% dei puntatori abbandona prima di 50 puntate. Platt, Price e
/// Tappen (2013) e Hinnosaar (2016): in ogni equilibrio che non finisce subito, il
/// profitto atteso di ogni puntata è <b>zero</b>. Un vantaggio, se esiste, viene solo
/// dall'irrazionalità altrui — costo affondato, gusto per il rischio — non dalla
/// struttura del gioco. È il <i>null-model</i>: ogni stima di profittabilità va
/// confrontata con zero, e se lo supera con sistematicità va guardata con sospetto.</para>
/// </summary>
public static class Theory
{
/// <summary>Incremento di prezzo per puntata su Bidoo: un centesimo, verificato.</summary>
public const double PriceIncrement = 0.01;
/// <summary>Profitto atteso per puntata nell'equilibrio teorico (Augenblick 2016; Platt-Price-Tappen 2013).</summary>
public const double NullModelProfitPerBid = 0.0;
// ── 3.1 Valore atteso di una puntata ─────────────────────────────
/// <summary>
/// Ciò che serve a valutare una puntata in euro.
/// </summary>
/// <param name="PWin">Probabilità che, puntando adesso, nessuno rilanci prima della scadenza.</param>
/// <param name="Value">Valore reale V del bene (quello scelto dall'utente; non il listino gonfiato).</param>
/// <param name="CurrentPrice">Prezzo corrente dell'asta.</param>
/// <param name="BidCost">Costo della puntata c.</param>
/// <param name="TransactionFee">Fee di transazione sulla vincita (~1 € su Bidoo).</param>
/// <param name="Shipping">Spedizione.</param>
/// <param name="ListPrice">Prezzo «Compralo Ora» L, per la copertura. Null = nessuna copertura.</param>
/// <param name="BidsSpentEuro">Quanto si è già speso in puntate su quest'asta.</param>
/// <param name="HedgeEnabled">La copertura «Compralo Ora» va considerata.</param>
public readonly record struct BidSituation(
double PWin,
double Value,
double CurrentPrice,
double BidCost,
double TransactionFee,
double Shipping,
double? ListPrice,
double BidsSpentEuro,
bool HedgeEnabled);
/// <summary>
/// Valore atteso in euro di una puntata fatta adesso.
///
/// <para>Senza copertura:
/// <c>EV = P · (V (p + Δ) fee spedizione c) (1 P) · c</c>.
/// La puntata si paga in entrambi i rami — la piattaforma non la rimborsa — quindi
/// il costo c sta sia sotto P sia sotto (1 P). È la forma corretta della formula
/// di §3.1 del documento di progetto, che lo metteva solo nel ramo di sconfitta.</para>
///
/// <para>Con la copertura «Compralo Ora»: chi perde può comprare al listino L
/// recuperando le puntate spese come credito. La perdita finale, se si esercita,
/// è <c>L V</c> qualunque sia il numero di puntate; si esercita quando conviene,
/// cioè quando le puntate spese superano <c>L V</c>. Da quel punto in poi il
/// ramo di sconfitta non costa più nulla in più per ogni puntata: il suo costo è
/// già coperto. Quindi il costo marginale nel ramo di sconfitta è c finché
/// <c>spese + c ≤ L V</c>, zero dopo.</para>
///
/// <para>Per i buoni Bidoo Shop con V = L la copertura è totale dal primo
/// centesimo: ogni puntata ha valore atteso non negativo appena il margine è
/// positivo. È un'ipotesi forte — presume che si eserciti davvero il Compralo Ora
/// e che il buono valga davvero il suo nominale — e per questo è un'impostazione
/// dell'utente, non un dato.</para>
/// </summary>
public static double BidExpectedValue(in BidSituation s)
{
var p = Math.Clamp(s.PWin, 0, 1);
var gainIfWin = s.Value - (s.CurrentPrice + PriceIncrement) - s.TransactionFee - s.Shipping - s.BidCost;
var lossCost = LossBranchCost(s);
return p * gainIfWin - (1 - p) * lossCost;
}
/// <summary>Quanto costa questa puntata nel ramo di sconfitta, tenuto conto della copertura.</summary>
public static double LossBranchCost(in BidSituation s)
{
if (!s.HedgeEnabled || s.ListPrice is not > 0) return s.BidCost;
var boundedLoss = Math.Max(0, s.ListPrice.Value - s.Value);
return s.BidsSpentEuro + s.BidCost <= boundedLoss ? s.BidCost : 0;
}
/// <summary>
/// La regola operativa di §3.1: si punta se il guadagno atteso condizionato alla
/// vittoria supera il costo. Equivale a <c>BidExpectedValue &gt; 0</c>.
/// </summary>
public static bool ShouldBid(in BidSituation s) => BidExpectedValue(s) > 0;
/// <summary>
/// La probabilità di vittoria minima perché la puntata abbia valore atteso nullo:
/// il punto di pareggio. Sopra si guadagna in media, sotto si perde.
/// </summary>
public static double BreakEvenPWin(in BidSituation s)
{
var gainIfWin = s.Value - (s.CurrentPrice + PriceIncrement) - s.TransactionFee - s.Shipping - s.BidCost;
var lossCost = LossBranchCost(s);
var denom = gainIfWin + lossCost;
if (denom <= 0) return 1; // non conviene neanche vincendo
return Math.Clamp(lossCost / denom, 0, 1);
}
// ── 3.2 Equilibrio a profitto nullo ──────────────────────────────
/// <summary>
/// Il rapporto critico V/c (Hinnosaar 2016): se i puntatori attivi sono più di
/// questo numero, il payoff atteso di almeno uno è zero, e in ogni equilibrio
/// simmetrico lo è per tutti.
/// </summary>
public static double CriticalBidders(double value, double bidCost) =>
bidCost > 0 ? value / bidCost : double.PositiveInfinity;
// ── 3.3 Sopravvivenza e hazard empirico ──────────────────────────
/// <summary>
/// Un ciclo del timer osservato: fin dove è sceso il cronometro prima che
/// arrivasse la puntata successiva, e se invece nessuno ha puntato e l'asta è
/// finita. Sono le righe della tabella <c>resets</c>, più l'ultimo ciclo di ogni
/// asta.
/// </summary>
public readonly record struct CycleObservation(double MinTimerSeconds, bool EndedAuction);
/// <summary>
/// Probabilità empirica che, arrivati con il cronometro a <paramref name="leadSeconds"/>
/// senza che nessuno abbia puntato, nessuno punti fino alla fine — cioè la
/// P(vittoria) di una puntata piazzata a quell'anticipo, stimata come frequenza.
///
/// <para>È lo stimatore di sopravvivenza condizionata: fra i cicli che sono
/// scesi almeno fino a <c>lead</c>, la quota di quelli che hanno chiuso l'asta.
/// Un ciclo che ha chiuso l'asta è sceso fino a zero, quindi passa da ogni
/// soglia. Nulla quando non ci sono cicli abbastanza profondi da dirlo.</para>
/// </summary>
public static double? WinProbabilityAtLead(IEnumerable<CycleObservation> cycles, double leadSeconds, int minCycles = 20)
{
var reached = 0;
var survived = 0;
foreach (var c in cycles)
{
if (c.EndedAuction) { reached++; survived++; continue; }
if (c.MinTimerSeconds <= leadSeconds) reached++;
}
return reached >= minCycles ? (double)survived / reached : null;
}
/// <summary>
/// Curva di sopravvivenza di Kaplan-Meier del ciclo: per ogni profondità, la
/// probabilità che il cronometro scenda fin lì senza puntate. Le osservazioni
/// «censurate» sono i cicli che hanno chiuso l'asta (si sa che hanno superato
/// tutto). Restituisce coppie (profondità in secondi dalla scadenza, S).
/// </summary>
public static List<(double TimerSeconds, double Survival)> KaplanMeier(IEnumerable<CycleObservation> cycles)
{
// Il «tempo» è quanto il ciclo è sceso: dal timer pieno verso zero. Un evento
// è una puntata che ferma la discesa a MinTimer; una censura è il ciclo che
// arriva a zero (fine asta).
var obs = cycles.ToList();
var result = new List<(double, double)>();
if (obs.Count == 0) return result;
var events = obs.Where(c => !c.EndedAuction).GroupBy(c => Math.Round(c.MinTimerSeconds, 1))
.Select(g => (Timer: g.Key, Count: g.Count()))
.OrderByDescending(e => e.Timer) // la discesa va dal timer alto al basso
.ToList();
var survival = 1.0;
var atRisk = obs.Count;
foreach (var (timer, count) in events)
{
if (atRisk <= 0) break;
survival *= 1.0 - (double)count / atRisk;
result.Add((timer, survival));
atRisk -= count;
}
return result;
}
// ── 3.4 Processo di arrivo delle puntate ─────────────────────────
/// <summary>
/// Con arrivi di Poisson a intensità λ (puntate al secondo), la probabilità che
/// nessuno punti nei prossimi τ secondi: <c>exp(−λ τ)</c>. Alimenta P(vittoria)
/// quando si conosce solo il ritmo recente delle puntate.
/// </summary>
public static double NoBidProbability(double lambdaPerSecond, double tauSeconds) =>
lambdaPerSecond <= 0 || tauSeconds <= 0 ? 1.0 : Math.Exp(-lambdaPerSecond * tauSeconds);
/// <summary>
/// Intensità stimata dagli ultimi intervalli fra puntate (secondi): l'inverso
/// della media. Null senza intervalli.
/// </summary>
public static double? ArrivalRate(IEnumerable<double> recentGapsSeconds)
{
var gaps = recentGapsSeconds.Where(g => g > 0).ToList();
if (gaps.Count == 0) return null;
return 1.0 / gaps.Average();
}
// ── Contabilità ──────────────────────────────────────────────────
/// <summary>Profitto realizzato da una vincita: valore reale meno tutto ciò che è costata.</summary>
public static double RealizedWinProfit(double value, double finalPrice, double bidsSpentEuro, double transactionFee, double shipping) =>
value - finalPrice - bidsSpentEuro - transactionFee - shipping;
}
}
+210
View File
@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace AutoBidder.Ml
{
/// <summary>
/// Un bandit contestuale con campionamento di Thompson (Agrawal e Goyal, 2013) per
/// la scelta PUNTA / NON PUNTARE.
///
/// <para><b>Come funziona.</b> Il contesto — durata del ciclo, prezzo sul valore,
/// fascia oraria, puntatori distinti — viene ridotto a una chiave; per ogni chiave si
/// tiene una distribuzione Beta(α, β) sulla probabilità che una puntata resti senza
/// risposta. Al momento di decidere si <i>estrae</i> una probabilità dalla Beta
/// invece di usarne la media, e si applica la regola del valore atteso con quella.
/// Dove si è visto poco, la Beta è larga e l'estrazione varia: è l'esplorazione, che
/// si spegne da sola man mano che i dati arrivano.</para>
///
/// <para><b>Da cosa impara.</b> Da ogni decisione registrata, anche quelle in cui non
/// si è puntato: alla chiusura dell'asta si sa se il prezzo è rimasto fermo dopo
/// quell'istante (una puntata sarebbe stata l'ultima) oppure no. È informazione
/// completa, non solo sull'azione presa — cosa rara per un bandit, e che qui rende
/// l'apprendimento molto più rapido.</para>
///
/// <para>È la seconda policy, accanto a quella del modello: gira in shadow su ogni
/// decisione e si confronta con l'altra sugli esiti. Vedi <c>LEGGIMI.md</c>.</para>
/// </summary>
public sealed class ThompsonBandit
{
private sealed class Arm
{
public double Alpha;
public double Beta;
}
private readonly Dictionary<string, Arm> _arms = new(StringComparer.Ordinal);
private readonly object _sync = new();
/// <summary>Peso del prior: equivale a tante osservazioni fittizie al tasso di base.</summary>
public double PriorStrength { get; set; } = 4;
/// <summary>Tasso di base P(senza risposta) usato come prior dove non si è visto nulla.</summary>
public double BaseRate { get; set; } = 0.02;
public int Arms { get { lock (_sync) return _arms.Count; } }
public long Observations { get; private set; }
/// <summary>La chiave del contesto: le fasce che contano di più, poche per avere dati in ognuna.</summary>
public static string KeyOf(in BidContext c) =>
$"g{BidFeatures.GapBucket(Math.Max(0, c.CycleSeconds))}|r{BidFeatures.RatioBucket(c.PriceRatio)}|h{BidFeatures.HourBand(c.Hour)}|b{BidFeatures.BiddersBucket(c.DistinctBiddersRecent)}|a{(c.AutoShareRecent >= 0.5 ? 1 : 0)}";
private Arm ArmFor(string key)
{
if (!_arms.TryGetValue(key, out var arm))
{
arm = new Arm { Alpha = BaseRate * PriorStrength, Beta = (1 - BaseRate) * PriorStrength };
_arms[key] = arm;
}
return arm;
}
/// <summary>Aggiorna il braccio del contesto con un esito osservato.</summary>
public void Observe(string key, bool unanswered)
{
lock (_sync)
{
var arm = ArmFor(key);
if (unanswered) arm.Alpha += 1; else arm.Beta += 1;
Observations++;
}
}
/// <summary>La media a posteriori per il contesto.</summary>
public double Mean(string key)
{
lock (_sync)
{
var arm = ArmFor(key);
return arm.Alpha / (arm.Alpha + arm.Beta);
}
}
/// <summary>Quante osservazioni vere ha visto questo contesto.</summary>
public double Evidence(string key)
{
lock (_sync)
{
var arm = ArmFor(key);
return arm.Alpha + arm.Beta - PriorStrength;
}
}
/// <summary>Un'estrazione dalla Beta del contesto: è la probabilità «di oggi».</summary>
public double Sample(string key, Random rng)
{
double a, b;
lock (_sync)
{
var arm = ArmFor(key);
a = arm.Alpha; b = arm.Beta;
}
var x = GammaSample(a, rng);
var y = GammaSample(b, rng);
return x + y > 0 ? x / (x + y) : 0;
}
/// <summary>
/// La decisione: si estrae p e si punta se il valore atteso con quel p è positivo.
/// Restituisce anche il p estratto, per il registro.
/// </summary>
public (bool Bid, double SampledP) Decide(string key, Theory.BidSituation situation, Random rng)
{
var p = Sample(key, rng);
var s = situation with { PWin = p };
return (Theory.BidExpectedValue(s) > 0, p);
}
/// <summary>Campione da Gamma(shape, 1) con il metodo di Marsaglia e Tsang.</summary>
private static double GammaSample(double shape, Random rng)
{
if (shape < 1)
{
// Gamma(a) = Gamma(a+1) · U^(1/a)
var u = rng.NextDouble();
return GammaSample(shape + 1, rng) * Math.Pow(Math.Max(u, 1e-300), 1.0 / shape);
}
var d = shape - 1.0 / 3.0;
var c = 1.0 / Math.Sqrt(9.0 * d);
while (true)
{
double x, v;
do
{
x = Gaussian(rng);
v = 1.0 + c * x;
} while (v <= 0);
v = v * v * v;
var u = rng.NextDouble();
if (u < 1 - 0.0331 * x * x * x * x) return d * v;
if (Math.Log(u) < 0.5 * x * x + d * (1 - v + Math.Log(v))) return d * v;
}
}
private static double Gaussian(Random rng)
{
var u1 = 1.0 - rng.NextDouble();
var u2 = rng.NextDouble();
return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
}
// ── Persistenza ──────────────────────────────────────────────────
private sealed class Snapshot
{
public double PriorStrength { get; set; }
public double BaseRate { get; set; }
public long Observations { get; set; }
public Dictionary<string, double[]> Arms { get; set; } = new();
}
public string ToJson()
{
lock (_sync)
{
return JsonSerializer.Serialize(new Snapshot
{
PriorStrength = PriorStrength,
BaseRate = BaseRate,
Observations = Observations,
Arms = _arms.ToDictionary(kv => kv.Key, kv => new[] { kv.Value.Alpha, kv.Value.Beta })
});
}
}
public static ThompsonBandit FromJson(string json)
{
var bandit = new ThompsonBandit();
try
{
var s = JsonSerializer.Deserialize<Snapshot>(json);
if (s == null) return bandit;
bandit.PriorStrength = s.PriorStrength > 0 ? s.PriorStrength : 4;
bandit.BaseRate = s.BaseRate > 0 ? s.BaseRate : 0.02;
bandit.Observations = s.Observations;
foreach (var kv in s.Arms)
bandit._arms[kv.Key] = new Arm { Alpha = kv.Value[0], Beta = kv.Value[1] };
}
catch { }
return bandit;
}
/// <summary>Le braccia con più evidenza, per la scheda.</summary>
public IReadOnlyList<(string Key, double Mean, double Evidence)> TopArms(int max = 30)
{
lock (_sync)
{
return _arms
.Select(kv => (kv.Key, kv.Value.Alpha / (kv.Value.Alpha + kv.Value.Beta), kv.Value.Alpha + kv.Value.Beta - PriorStrength))
.OrderByDescending(t => t.Item3)
.Take(max)
.ToList();
}
}
}
}
+7
View File
@@ -567,6 +567,13 @@ namespace AutoBidder.Models
// 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>
/// La decisione registrata nel database per il ciclo in corso: la puntata che
/// parte porta questo id, così esito e decisione restano legati.
/// </summary>
[JsonIgnore]
public long? PendingDecisionId { get; set; }
/// <summary>Probabilità che una puntata fatta adesso resti senza risposta.</summary>
[JsonIgnore]
public double? LearnedUnansweredProbability { get; set; }
+8
View File
@@ -45,6 +45,14 @@ namespace AutoBidder.Models
/// <summary>Tetto di puntate per singola asta. <c>null</c> = predefinito.</summary>
public int? MaxClicks { get; set; }
/// <summary>
/// Valore reale V del bene, scelto dall'utente. <c>null</c> = vale il «Compralo
/// Ora» di Bidoo. Il listino è gonfiato: un buono da 10 € spendibile solo su Bidoo
/// non vale 11 €. È il numero su cui si calcola il valore atteso di ogni puntata
/// (vedi Ml/Theory), quindi è il più importante di tutti.
/// </summary>
public double? MarketValue { get; set; }
/// <summary>
/// Stato con cui entra un'asta nuova di questo prodotto: <c>"Active"</c>,
/// <c>"Watch"</c> o <c>"Stopped"</c>. <c>null</c> = usa il predefinito generale.
+203
View File
@@ -0,0 +1,203 @@
# PROMPT PER CLAUDE CODE — Sistema "Bidoo-RL": bot analitico e auto-apprendente per aste al centesimo (Bidoo)
## TL;DR
- Questo documento è un **prompt completo e autosufficiente** da incollare in Claude Code per far costruire un sistema `bidoo-rl` di raccolta dati + ML/RL + esecuzione della puntata a bassa latenza su Bidoo, con meccanica verificata, modello matematico esplicito (valore atteso, hazard rate, MDP), schema DB, pipeline ML/RL, architettura software e piano a milestone.
- **Avvertenza sostanziale e non negoziabile**: le penny auction sono a valore atteso fortemente negativo per il bidder — Augenblick (2016), su 166.000 aste, calcola che "average profit margins actually exceed 50%" per il venditore; "75% of bidders leave the website forever before placing 50 bids, and 86% stop before placing 100 bids"; solo i bidder più esperti raggiungono profitti attesi "leggermente positivi". Inoltre l'automazione e i multi-account **violano i Termini e Condizioni di Bidoo** (un solo account, divieto di "software di monitoraggio dell'offerta"). Il sistema è progettato in modalità **SHADOW obbligatoria** con kill-switch e risk manager.
- Il valore reale del progetto è **analitico** (stimare quando un'asta è profittevole e astenersi quasi sempre), non "vincere". Il prompt istruisce esplicitamente Claude Code a costruire un sistema scettico by-design, pronto a concludere "non conviene".
## Key Findings (base di evidenza incorporata nel prompt)
1. **Meccanica Bidoo verificata**: incremento €0,01/puntata; timer breve (~1012 s, una pagina live mostrava "Timer: 12 sec") che si resetta ad ogni puntata; vince l'ultimo; puntate non rimborsabili; opzione "Compralo Ora" per recuperare le puntate spese; auto-puntata ufficiale che punta negli ultimi secondi fino a un massimo impostato; aste a prenotazione; slot/limiti di vincita ("7 ogni giorno" su pagina live); orari ~10:00/12:0024:00; fee transazione €1,00; unit-cost puntata tra ~€0,07 e ~€0,50 secondo il pacchetto (fonti pubbliche incoerenti).
2. **Teoria**: equilibrio a profitto atteso nullo per puntata (Augenblick 2016 *Review of Economic Studies* 83(1):5886; Platt-Price-Tappen 2013 *Management Science* 59:21172134; Hinnosaar 2016); deviazioni spiegate da sunk-cost, risk-loving, asimmetrie informative (Byers-Mitzenmacher-Zervas 2010).
3. **ML/RL trasferibile**: RTB come MDP (Cai et al., WSDM 2017), offline RL (CQL Kumar et al. 2020 / IQL Kostrikov et al. 2021), contextual bandit/Thompson sampling (Agrawal-Goyal 2013), gradient boosting + survival analysis (Cox/lifelines), sniping (Roth-Ockenfels 2002 AER).
4. **Dati**: dataset pubblico Swoopo (Byers/Mitzenmacher/Zervas, people.bu.edu/zg/swoopo.html): 121.419 aste "Outcomes" (8 set 200812 dic 2009) + ~2,5M bid "Traces"; repo Bidoo-Bot (Selenium). Meccanismo real-time di Bidoo (AJAX/WebSocket/SSE) **NON confermato**.
5. **Legale**: ToS Bidoo vietano bot e multi-account; AGCM (comunicato 25 settembre 2015, PS8243-PS9030) ha sanzionato operatori di aste al centesimo — €700.000 a Flamingo Intervest Ltd per pratiche commerciali scorrette e €300.000 a Marcandi Ltd (madbid.com) per "informazioni ingannevoli e omissive" e per non aver chiarito "l'aleatorietà" delle aste — ma **nessuna azione specifica contro Bidoo** risulta; in Italia le penny auction sono zona grigia (non gioco d'azzardo formale, trattate come pratica commerciale ex Codice del Consumo).
---
## IL DOCUMENTO-PROMPT (copiare da qui in Claude Code)
Sei un ingegnere ML/RL senior e sviluppatore Python esperto. Costruisci un sistema modulare chiamato `bidoo-rl` per l'analisi, la simulazione e (opzionalmente, in modalità controllata) l'automazione della puntata su aste al centesimo, con Bidoo (it.bidoo.com) come target. Il proprietario del progetto (Alberto) è esperto C++/Python e costruisce sistemi analitici AI-driven. Scrivi codice, identificatori, nomi di file e commenti in inglese; la documentazione utente in italiano. Rispetta rigorosamente le sezioni seguenti.
### 0. CONTESTO, OBIETTIVO E FILOSOFIA
Obiettivo primario: costruire un sistema che **impari dai dati quando conviene puntare e, soprattutto, quando NON puntare**, stimando il valore atteso di ogni puntata e il rischio, con esecuzione a bassa latenza. L'obiettivo NON è "vincere sempre" ma massimizzare il ROI atteso di lungo periodo con controllo del rischio; l'esito atteso onesto di default è "astenersi dalla stragrande maggioranza delle aste".
Realtà economica da codificare come prior nel sistema (fonti accademiche): nelle penny auction il margine medio del banco supera il 50% del valore del bene — Augenblick (2016), su un dataset di ~100 milioni di puntate su 166.000 aste, "calculate that average profit margins actually exceed 50%"; la maggioranza dei bidder perde denaro; "75% of bidders leave the website forever before placing 50 bids, and 86% stop before placing 100 bids"; solo i bidder più esperti/sofisticati raggiungono profitti attesi leggermente positivi. Il sistema deve essere scettico by-design e ancorare ogni stima di profittabilità al null-model teorico (profitto atteso per puntata = 0).
### 1. VINCOLI, AVVERTENZE LEGALI/ToS E GESTIONE DEL RISCHIO (PRIORITÀ MASSIMA)
Implementa questi vincoli PRIMA di ogni logica di puntata:
- **Modalità SHADOW obbligatoria di default**: il sistema decide e logga le decisioni ("avrei puntato / non puntato, con questa motivazione e questo EV") ma NON invia puntate reali. L'esecuzione live è dietro un flag esplicito `--enable-live-bidding` + variabile d'ambiente `BIDOO_LIVE_CONFIRM=I_UNDERSTAND_THE_RISKS` + doppia conferma interattiva.
- **Kill-switch** globale: file sentinella `KILL_SWITCH` e handler SIGTERM che fermano immediatamente ogni azione live.
- **Avvertenza ToS**: i Termini e Condizioni di Bidoo (it.bidoo.com/terms.php) vietano esplicitamente "l'uso di software di monitoraggio dell'offerta", la "creazione di account multipli dello stesso utente" e stabiliscono "È permesso avere un solo account per persona; se si tenta di aprire più di un account, quelli in eccesso e anche il principale, potranno essere bloccati o chiusi". La violazione comporta blocco/chiusura account e perdita delle puntate. Il README deve riportarlo in evidenza. NON implementare creazione automatica di account multipli né farming multi-account (a differenza del repo pubblico HunterStile/Bidoo-Bot che include `Crea_Account.py` e `Boosting.py`: NON replicarli).
- **Inquadramento legale**: in Italia le aste al centesimo sono in una zona grigia — non classificate formalmente come gioco d'azzardo (Art. 721 c.p. richiede vincita/perdita "interamente o quasi interamente aleatoria"; nelle penny auction è presente un elemento di abilità), trattate dall'AGCM come pratica commerciale (Codice del Consumo). L'AGCM (comunicato del 25 settembre 2015, provvedimenti PS8243 e PS9030) ha irrogato "una sanzione complessiva di un milione di euro" a due operatori: €700.000 a Flamingo Intervest Ltd per pratiche commerciali scorrette e €300.000 a Marcandi Ltd (operatore di madbid.com) per informazioni ingannevoli/omissive e per non aver chiarito "l'aleatorietà" delle aste. Non risulta alcuna azione specifica contro Bidoo. Le sanzioni AGCM per pratiche scorrette vanno da €5.000 a €10.000.000. Riportare questi fatti nel README.
- **Risk manager** con parametri hard (config `risk.yaml`): bankroll totale, budget massimo puntate per asta, budget giornaliero, stop-loss giornaliero, limite di puntate per singolo articolo/categoria, numero massimo di aste concorrenti, drawdown massimo oltre il quale il sistema entra in stato `HALT`. Ogni decisione live deve passare da `RiskManager.authorize(decision) -> bool`.
- **Hedge "Compralo Ora"**: modellare esplicitamente l'opzione buy-now come copertura (limita la perdita alla differenza tra prezzo di listino Bidoo e prezzo di mercato reale). Il risk manager deve valutare il buy-now come exit.
### 2. MECCANICA DI BIDOO DA MODELLARE (dati verificati; incertezze segnalate)
Modella una classe `AuctionMechanics` con questi parametri (default verificati, tutti overridabili da config perché possono cambiare):
- **Incremento prezzo**: `price_increment = 0.01 €` per puntata (verificato, costante su tutte le fonti).
- **Timer/countdown**: si resetta ad ogni puntata; durata breve, tipicamente 1015 secondi (una pagina asta live mostrava "Timer: 12 sec"; altre fonti citano fino a 60 s di finestra massima). Alla scadenza parte una fase di "controllo" in cui Bidoo verifica puntate valide arrivate all'ultimo secondo prima di decretare il vincitore. **INCERTEZZA**: valore esatto del timer e logica di reset/estensione non documentati ufficialmente; renderli parametrici e stimarli dai dati.
- **Costo puntata (bid fee)**: acquistata solo in pacchetti; unit-cost variabile ~€0,07–€0,50. Figure ricorrenti nelle guide: pacchetto piccolo ~150 puntate ≈ €0,20/puntata; pacchetto grande ~7000 puntate ≈ €0,070,08/puntata; alcune fonti indicano €0,100,12 medi, altre un listino nominale €0,50. **INCERTEZZA ALTA**: i prezzi esatti dei pacchetti 2025-26 non sono confermabili dalla pagina ufficiale (bloccata al fetch automatico); parametrizzare in `bid_packs.yaml` e ricalibrare all'uso reale.
- **Fee di transazione**: ~€1,00 sulla vincita (visto su pagina live it.bidoo.com/auction.php?a=<id>).
- **Vincita**: vince l'ultimo che ha puntato quando il timer arriva a 0 senza rilanci; paga prezzo finale + spedizione + fee, tenendo conto delle puntate spese.
- **Compralo Ora / buy-now**: chi non vince può comprare l'oggetto a prezzo di listino recuperando le puntate spese come credito.
- **Auto-puntata ufficiale (autobid)**: Bidoo offre una funzione nativa che punta automaticamente negli ultimi secondi fino a un massimo di puntate impostato dall'utente; attivabile su più aste. Modellare gli autobid concorrenti come avversari.
- **Tipi di asta**: aste normali, aste a prenotazione (solo utenti prenotati, con puntate prenotate scalate), aste su pacchetti di puntate, categorie prodotti (elettronica, buoni, elettrodomestici...). Aggiungere flag per aste principianti/con limitazioni per analogia con Swoopo (`flg_beginnerauction`; `flg_click_only` = "NailBiter"; `flg_fixedprice`).
- **Orari**: aste attive con finestra tipica 10:0024:00 (alcune fonti 12:0024:00), pausa notturna; nuove aste avviate di continuo.
- **Slot/limiti di vincita**: limiti settimanali/mensili e giornalieri al numero di aste vincibili da un utente (es. "7 ogni giorno" visto su pagina live). Codificare come vincolo che riduce il valore marginale di ulteriori vittorie.
- **Feed real-time — INCERTEZZA CRITICA**: NON è documentato pubblicamente se Bidoo aggiorni prezzo/timer/ultimo-offerente via AJAX polling, WebSocket, long-polling o SSE, né esistono endpoint JSON noti/confermati. L'unico bot pubblico noto (github.com/HunterStile/Bidoo-Bot) usa **Selenium** e, come da README, "parteciperà all'asta cliccando l'ultimo millisecondo prima che scada" — il che suggerisce che non sia banale colpire un endpoint di stato diretto. Pattern URL asta confermato: `https://it.bidoo.com/auction.php?a=<AUCTION_ID>`. **Istruzione a Claude Code**: implementare un `feed adapter` astratto con due backend: (a) `BrowserFeed` basato su Playwright che legge il DOM (default, robusto), e (b) `HttpFeed` da attivare SOLO se, durante una fase esplicita di reverse-engineering manuale del traffico di rete (DevTools/Network), si identifica un endpoint di stato reale. NON assumere endpoint inventati; documentare come `UNKNOWN` finché non verificato.
Includi parametri per confronto/portabilità (QuiBids, DealDash, Swoopo storico, MadBid) per riuso dei modelli.
### 3. MODELLO MATEMATICO (formule esplicite)
Implementa un modulo `models/theory.py` con le seguenti formule, ognuna documentata con la fonte.
**3.1 Valore atteso di una puntata (decisione istantanea)**
Sia `V` il valore reale di mercato del bene (prezzo Amazon/Idealo, NON il listino gonfiato Bidoo), `p_price` il prezzo corrente dell'asta, `c` il costo della puntata (bid fee), `Δ = 0,01€` l'incremento, `P_win` la probabilità stimata di vincere se punto ora (cioè che nessuno rilanci prima dello scadere del timer):
```
E[bid] = P_win · (V (p_price + Δ) fee_transaction shipping)
(1 P_win) · c
```
Regola operativa: **puntare solo se `P_win · (V p_price_next costs) > c`**, cioè se il guadagno atteso condizionato alla vittoria supera il costo della puntata. Con l'hedge buy-now, la perdita in caso di sconfitta è limitata a `max(0, prezzo_listino_bidoo V)`.
**3.2 Equilibrio a profitto atteso nullo (benchmark teorico)**
Augenblick (2016) e Platt-Price-Tappen (2013): in ogni equilibrio che non finisce subito, i giocatori puntano in modo probabilistico tale che **il profitto atteso di ogni puntata è zero**. Nel modello risk-neutral (Platt et al.) il ricavo atteso del venditore eguaglia il valore del bene ("produce the same expected revenue as standard auctions"); con preferenze risk-loving il ricavo eccede V (le penny auction sono "a mild form of gambling"). Il rapporto critico è `V/c`: se il numero di bidder attivi `n > V/c`, il payoff atteso di almeno un bidder è 0, e in ogni equilibrio simmetrico il payoff di tutti i bidder è 0 (Hinnosaar 2016). **Usa questo come null-model**: qualunque profittabilità stimata dai modelli ML che ecceda sistematicamente lo zero teorico va trattata con sospetto e attribuita ad asimmetrie/irrazionalità altrui (sunk-cost, risk-loving), non a un edge strutturale.
**3.3 Hazard rate empirico e survival analysis**
Modella la terminazione dell'asta come processo di sopravvivenza. Definisci `h(t | X)` = probabilità che l'asta termini al round `t` dato lo stato `X`. Stima con:
- **Kaplan-Meier** per la curva di sopravvivenza empirica per categoria/valore (usa `lifelines`).
- **Cox proportional hazards** e/o gradient boosting survival (XGBoost/LightGBM con obiettivo Cox, o modelli AFT) per `h(t|X)` con covariate (n. bidder attivi, sunk cost aggregato, prezzo/valore, orario). Deviazione chiave da modellare (Augenblick): l'hazard rate empirico scende sotto la predizione standard man mano che l'asta prosegue (effetto sunk-cost), riducendo il rendimento per puntata da alto a ~"1824 cent per ogni puntata da 75 cent" nelle fasi avanzate del dataset Swoopo.
**3.4 Processo puntuale per l'arrivo delle puntate**
Modella gli arrivi come processo di Poisson non-omogeneo (o Hawkes per il clustering "una puntata chiama l'altra"): intensità `λ(t | X)`. La probabilità che nessuno punti entro il timer residuo `τ` è `P_no_bid = exp(−∫_{t}^{t+τ} λ(s) ds)`, che alimenta direttamente `P_win`.
**3.5 Formulazione come Markov Decision Process (per RL)**
- **Stato** `s`: [prezzo corrente, tempo residuo nel timer, n. bidder distinti attivi, n. puntate totali, intervalli tra puntate recenti, profilo aggressività avversari, presenza autobid, valore V e prezzo listino, categoria/articolo, orario/giorno, budget residuo per asta, sunk cost proprio, latenza di rete misurata, jitter].
- **Azioni** `a`: {NO-OP (non puntare), BID (punta ora), BID@T−δ (schedula puntata a timer−δ), BUY-NOW (esci con hedge), LEAVE (abbandona)}.
- **Reward** `r`: vittoria `+(V prezzo_finale costi_totali_puntate fee shipping)`; ogni puntata `c`; buy-now `(prezzo_listino V)` limitato; abbandono `0`. Reward basato su V reale, non sul listino Bidoo.
- **Sconto** `γ` vicino a 1 entro l'asta (orizzonte breve), con reward terminale dominante.
- **Obiettivo**: massimizzare `E[Σ γ^t r_t]` sotto i vincoli del risk manager.
Riferimenti: MDP di bidding in RTB (Cai et al., WSDM 2017, "formulate the bid decision process as a reinforcement learning problem"); optimal stopping / war of attrition; bid shading dalla teoria delle aste.
### 4. PIPELINE ML/RL
Struttura `ml/` con questi componenti:
**4.1 Modelli supervisionati (feature tabellari + sequenza)**
- `final_price_predictor`: regressione del prezzo finale (LightGBM/XGBoost/CatBoost).
- `end_within_n_seconds`: classificatore **calibrato** P(asta termina entro N s | stato) — con calibrazione (Platt scaling/isotonic) perché `P_win` deve essere una probabilità onesta.
- `active_bidders_estimator`: stima del numero di bidder attivi residui (variabile latente chiave: le asimmetrie su `n` guidano il profitto del banco, Byers et al. 2010).
- `opponent_classifier`: profilazione bidder da bid history in classi {aggressivo, sniper, autobid, principiante} — feature: timing delle puntate (chi punta a inizio/fine countdown è più sofisticato, Wang & Xu 2012), frequenza, streak.
- Modelli di sequenza (LSTM/GRU/TCN/Transformer) sulla sequenza temporale delle puntate per catturare dinamica e clustering.
**4.2 Reinforcement Learning**
- **Offline RL prima di tutto**: addestra su log storici con **CQL** (Conservative Q-Learning, Kumar et al. 2020) o **IQL** (Implicit Q-Learning, Kostrikov et al. 2021) per evitare overestimation di azioni out-of-distribution. La loss CQL penalizza le Q-value delle azioni OOD (`log Σ_a exp Q(s,a) E_{aD}[Q(s,a)]`). Cruciale perché non possiamo esplorare liberamente col denaro reale.
- **Contextual bandit / Thompson sampling** (Agrawal-Goyal 2013) come policy leggera e interpretabile per la decisione BID/NO-OP a livello di singola opportunità (context = feature dello stato; ricompensa = EV realizzato). Buon trade-off exploration/exploitation con regret basso.
- **Simulatore gym-like** (`gymnasium.Env`) `PennyAuctionEnv`: avversari sintetici calibrati sui dati reali (mix di profili aggressivo/sniper/autobid/principiante con hazard e sunk-cost calibrati su Swoopo e sui dati Bidoo raccolti). Serve per (a) addestrare/valutare policy online in sicurezza, (b) stress test.
- **On-policy** (PPO) o **off-policy** (SAC per azioni continue tipo δ di timing; DQN per azioni discrete) SOLO dentro il simulatore, mai live all'inizio.
- Librerie: `stable-baselines3` + `gymnasium`; per offline RL `d3rlpy` (CQL/IQL/BCQ).
**4.3 Feature engineering** (`ml/features.py`)
Articolo/categoria; valore retail Bidoo vs prezzo di mercato reale (scraping Amazon/Idealo o input manuale); orario e giorno della settimana; prezzo corrente e rapporto prezzo/V; n. puntate totali; n. bidder distinti; intervalli tra puntate (media/varianza/ultimo); aggressività dei bidder presenti; presenza/intensità autobid; storico dello stesso articolo e di aste simili; latenza di rete e jitter; tempo residuo nel timer; posizione temporale della puntata nel countdown; sunk cost proprio e aggregato.
**4.4 Valutazione e retraining continuo**
- Metriche: **ROI per puntata**, ROI per asta, win rate, costo medio per vittoria, sconto reale vs prezzo di mercato, max drawdown, Sharpe delle sessioni, calibrazione (Brier score, reliability diagram) di `P_win`.
- Valutazione offline: **off-policy evaluation** (importance sampling, doubly robust) prima di ogni promozione di policy.
- A/B tra policy in shadow; promozione live solo se supera le soglie §8.
- Retraining schedulato (es. settimanale) + drift detection sulle feature.
- Tracking con `mlflow`; tuning con `optuna`.
### 5. RACCOLTA DATI E SCHEMA DB
**5.1 Ingestion** (`ingestion/`)
- `Playwright` (async) come default per rendering JS e lettura DOM; `httpx` per eventuali endpoint HTTP solo se verificati manualmente. Gestione sessione/login via cookie storage; rispettare rate limiting (backoff esponenziale + jitter, User-Agent realistico); gestire eventuale Cloudflare/anti-bot con attese e sessione browser persistente.
- **Campionamento sub-secondo** durante le fasi calde (timer < 20 s): polling adattivo (più frequente vicino allo zero). Registrare timestamp locale ad alta risoluzione (`time.perf_counter_ns`).
- **Sincronizzazione temporale**: sincronizzare l'orologio via NTP; stimare l'offset con l'orologio del server Bidoo (response header `Date` o timer visualizzato) e la latenza RTT; salvare offset e RTT con ogni osservazione.
- **Misura latenza end-to-end** della puntata: tempo dal comando di puntata alla conferma di registrazione; salvare per la compensazione (§6).
- Comportamento rispettoso: throttling, niente scraping distruttivo, solo dati necessari; documentare che il monitoraggio automatico viola i ToS.
**5.2 Schema DB** (SQLite in dev, PostgreSQL/Supabase in prod; SQLAlchemy + Alembic)
- `items`: item_id, title, category, retail_price_bidoo, market_price_est, market_price_source, updated_at.
- `auctions`: auction_id, item_id, type (normal/reservation/bidpack/beginner), start_time, end_time, final_price, winner_user_id, bid_increment, bid_fee_ref, shipping, transaction_fee, status.
- `bids`: bid_id, auction_id, user_id, price_after, timer_before, timer_after, server_ts, local_ts, clock_offset_ms, rtt_ms, is_autobid_flag_est.
- `bidders`: user_id, first_seen, n_auctions, n_bids, win_count, aggressiveness_score, profile_class.
- `sessions`: session_id, start, end, ip/proxy, cookies_ref, notes.
- `bot_decisions`: decision_id, auction_id, ts, state_snapshot (JSON), action, ev_estimate, p_win, model_version, shadow_or_live, executed, outcome, **reason_detail** (motivazione testuale dettagliata).
- `network_metrics`: ts, rtt_ms, jitter_ms, clock_offset_ms.
**5.3 Volumi e quanti dati servono**
Per addestrare modelli supervisionati affidabili servono migliaia di aste chiuse con storico completo. Riferimenti di scala: il dataset Swoopo pubblico contiene 121.419 aste "Outcomes" (8 set 200812 dic 2009) e ~2,5 milioni di puntate "Traces"; Augenblick usa "an auction-level dataset of nearly 100 million bids on 166,000 auctions and a bid-level dataset of 13 million bids on 18,000 auctions from more than 129,000 users". Raccomanda di raccogliere ≥ 48 settimane di dati continui e ≥ qualche migliaio di aste chiuse per categoria prima di fidarsi delle stime; fino ad allora restare in shadow.
**5.4 Fonti pubbliche da integrare**
- **Swoopo dataset** (Byers/Mitzenmacher/Zervas, people.bu.edu/zg/swoopo.html): colonne outcomes (auction_id, product_id, item, retail, price, finalprice, bidincrement, bidfee, winner, placedbids, freebids, endtime, `flg_click_only`=NailBiter, `flg_beginnerauction`, `flg_fixedprice`) + traces con tempo e utente per ogni bid. Usare per pre-training/calibrazione del simulatore.
- Repo pubblici (solo come riferimento tecnico, NON copiare la parte multi-account): HunterStile/Bidoo-Bot (Selenium, Asta.py), qix/penny-bot, plutokid/bid-o-matic.
### 6. STRATEGIA DI ESECUZIONE E COMPENSAZIONE DELLA LATENZA
- Puntare il più tardi possibile (sniping): la letteratura eBay (Roth-Ockenfels 2002 AER) mostra che il late bidding è razionale come best response a bidder incrementali; nelle penny auction puntare tardi minimizza le puntate sprecate.
- **Compensazione latenza**: `t_send = t_expiry (latency_est + safety_margin)`, dove `latency_est` = RTT/2 + tempo di elaborazione client + jitter buffer, stimato online dai `network_metrics`. `safety_margin` calibrato per una probabilità target di arrivo-in-tempo (es. 99° percentile della latenza osservata).
- **Evitare puntate sprecate**: se tra la decisione e l'invio arriva la puntata di un altro (il pulsante "Punta" ricompare), rivalutare: spesso conviene NON re-puntare subito. Logica di cancellazione/riprogrammazione.
- Gestione errori: timeout, disconnessioni, rifiuto della puntata, saldo insufficiente → stati espliciti e retry idempotenti; ogni tentativo loggato.
### 7. ARCHITETTURA SOFTWARE, REPO E STACK
```
bidoo-rl/
README.md # avvertenze ToS/legali + realtà economica (EV negativo) in apertura
pyproject.toml
config/ (risk.yaml, bid_packs.yaml, mechanics.yaml, feeds.yaml)
ingestion/ (playwright_feed.py, http_feed.py, feed_base.py, clock_sync.py, rate_limit.py)
storage/ (models.py [SQLAlchemy], migrations/ [Alembic], repo.py)
features/ (build_features.py, opponent_profiling.py)
models/ (theory.py, supervised.py, survival.py, sequence.py)
ml/ (offline_rl.py [d3rlpy CQL/IQL], bandit.py [Thompson], env.py [PennyAuctionEnv], train.py, evaluate.py, ope.py)
policy/ (decision_engine.py, risk_manager.py, executor.py, latency.py)
backtest/ (simulator.py, replay.py)
monitoring/ (dashboard.py [Streamlit], metrics.py, alerts.py)
cli.py # comandi: collect, train, backtest, shadow, live (gated)
tests/ # pytest, coverage su risk_manager, executor, theory
```
Stack: Python 3.11+, `playwright`, `asyncio`, `httpx`, `pandas`/`polars`, `numpy`, `scikit-learn`, `lightgbm`/`xgboost`/`catboost`, `lifelines` (Kaplan-Meier/Cox), `torch`, `stable-baselines3`, `gymnasium`, `d3rlpy` (CQL/IQL), `optuna`, `mlflow`, `SQLAlchemy`+`alembic`, `pydantic` (config), `streamlit` (dashboard), `pytest`.
### 8. METRICHE DI SUCCESSO E CRITERI DI STOP
- **Gate shadow → live** (tutti richiesti su ≥ 4 settimane shadow): ROI per puntata atteso > 0 con estremo inferiore dell'intervallo di confidenza > 0; calibrazione di `P_win` con Brier score sotto soglia prefissata; drawdown simulato entro limite; OPE positiva e robusta.
- **Criteri di STOP/kill definitivo** (se veri, dichiarare il progetto non profittevole e fermarsi): dopo N aste live il ROI cumulato per puntata è ≤ 0 con IC che esclude valori positivi rilevanti; oppure la varianza dei rendimenti rende il rischio di rovina inaccettabile dato il bankroll; oppure il null-model teorico (profitto atteso = 0) non viene battuto in modo statisticamente significativo. La posizione di default, coerente con Augenblick/Wang-Xu/Hinnosaar, è che un edge consistente sia improbabile; il sistema deve essere pronto a concludere "non conviene" e a limitarsi al ruolo analitico.
- Nicchie dove un margine può esistere (da testare, non garantite): aste a bassa partecipazione/orari morti, aste per principianti/con limitazioni, articoli con V ben noto e liquidabile, uso disciplinato del buy-now come hedge, sfruttamento di avversari sunk-cost/irrazionali.
### 9. LOG ED ESPORTAZIONI
- Tutte le esportazioni analitiche in **CSV con delimitatore punto e virgola (`;`)** e colonna `reason_detail` con motivazione dettagliata per ogni decisione (EV stimato, P_win, n_bidder stimati, hazard, latenza, vincolo di rischio applicato, azione scelta e perché).
- Logging strutturato (JSON) + `bot_decisions` su DB; dashboard con ROI, win rate, calibrazione, drawdown, distribuzione dei rendimenti.
### 10. PIANO DI LAVORO A FASI (milestone per Claude Code)
- **M1 — Scaffolding & risk core**: repo, config, `RiskManager`, kill-switch, modalità shadow, test. (Nessuna rete.)
- **M2 — Ingestion & storage**: Playwright feed sul DOM, clock sync, schema DB, raccolta aste chiuse + live in shadow.
- **M3 — Dati storici & simulatore**: import Swoopo dataset, `PennyAuctionEnv` con avversari calibrati, backtester.
- **M4 — Modelli supervisionati**: final price, end-within-N, active bidders, opponent profiling, survival (Cox/GBM), calibrazione.
- **M5 — Policy & offline RL**: `theory.py` (EV, hazard, MDP), bandit Thompson, CQL/IQL offline, OPE.
- **M6 — Valutazione shadow**: girare in shadow ≥ 4 settimane, misurare metriche §8, A/B policy.
- **M7 — Esecuzione & latenza (gated)**: `executor` + compensazione latenza, test in sandbox; live solo se i gate §8 sono superati e con importi minimi.
- **M8 — Monitoraggio & retraining**: dashboard, drift detection, retraining schedulato, criteri di stop automatici.
Consegna codice pulito, tipizzato (type hints), testato (pytest), con docstring e un README che apra con le avvertenze legali/ToS e la realtà economica (EV negativo atteso).
---
## Details (contesto e giustificazione delle scelte del prompt)
**Perché il null-model è centrale.** Tre lavori indipendenti convergono: Augenblick (2016) dimostra che "in any equilibria that does not end immediately, players bid probabilistically such that the expected profit from every bid is zero"; Platt-Price-Tappen (2013) mostrano che con bidder risk-neutral le pay-to-bid producono lo stesso ricavo atteso delle aste standard, ma con lievi preferenze risk-loving il ricavo del venditore eccede V ("a mild form of gambling"); Hinnosaar (2016) prova che con agenti razionali risk-neutral le penny auction non possono spiegare l'alto profitto medio del venditore. La profittabilità del banco viene quindi da deviazioni comportamentali: sunk-cost fallacy (Augenblick: la probabilità di abbandono decresce all'aumentare del sunk cost) e asimmetrie informative sul numero di bidder (Byers-Mitzenmacher-Zervas 2010). Un bot può sperare in un edge SOLO sfruttando l'irrazionalità altrui, non un vantaggio strutturale — da qui il design scettico.
**Perché offline RL e non RL online diretto.** Non si può esplorare liberamente con denaro reale; CQL/IQL imparano da log storici penalizzando le azioni out-of-distribution, e l'OPE valida una policy prima di rischiare. Il simulatore con avversari calibrati (sui dati Swoopo pubblici e sui dati Bidoo raccolti) è il banco di prova sicuro.
**Perché il timing è decisivo.** Roth-Ockenfels documentano che lo sniping (puntare all'ultimo istante) è una best response razionale in aste a chiusura "hard"; unito alla compensazione della latenza misurata, è la leva tecnica principale per non sprecare puntate. Wang & Xu (2012) osservano che i bidder che puntano a inizio/fine countdown sono i più sofisticati: utile per la profilazione avversari.
**Incertezze tecniche reali.** Il meccanismo real-time di Bidoo e gli eventuali endpoint JSON non sono confermabili da fonti pubbliche; l'unico bot noto usa Selenium con click sul DOM. Il prompt impone quindi Playwright/DOM come default e vieta endpoint inventati.
## Recommendations
1. **Costruire M1M6 (analisi/shadow) prima di tutto** e trattare l'esecuzione live come opzionale e sconsigliata finché i dati non provano un edge statisticamente significativo. Benchmark che cambia la decisione: ROI/puntata con estremo inferiore dell'IC > 0 su ≥ 4 settimane shadow.
2. **Verificare manualmente il meccanismo real-time di Bidoo (DevTools/Network)** prima di scrivere qualsiasi `HttpFeed`; default su Playwright/DOM. Se non emerge un endpoint di stato affidabile, restare su DOM.
3. **Aggiornare `bid_packs.yaml` con i prezzi reali osservati** al momento dell'uso: le fonti pubbliche divergono (€0,07–€0,50/puntata) e la pagina ufficiale non è verificabile automaticamente.
4. **Fermarsi (stop-loss di progetto) se il null-model teorico non viene battuto**: se dopo il periodo di validazione l'EV per puntata non è positivo con significatività, dichiarare il sistema strumento analitico (non di puntata) e disattivare il live.
5. **Non implementare mai multi-account/farming**: viola i ToS e concentra il rischio di chiusura dell'account (inclusa perdita delle puntate acquistate).
## Caveats
- **Prezzi pacchetti, valore V e timer esatti non confermabili da fonte ufficiale**: tutti parametrizzati in config, da ricalibrare all'uso.
- **Meccanismo real-time ed endpoint di Bidoo: sconosciuti/da verificare**; il prompt evita esplicitamente endpoint inventati.
- **Automazione e multi-account violano i Termini e Condizioni di Bidoo** (divieto di software di monitoraggio dell'offerta, un solo account): rischio concreto di blocco account e perdita puntate.
- **Evidenza accademica netta**: EV negativo per i bidder in media (margine banco > 50%; oltre il 75% dei bidder abbandona entro 50 puntate; profitto consistente improbabile). Le recensioni pubbliche (Trustpilot/Opinioni) contengono numerose accuse di "bot" che manterrebbero alte le puntate: sono affermazioni non verificate degli utenti, non fatti accertati, ma vanno tenute presenti come ipotesi da testare (comportamento anomalo degli avversari) e come rischio reputazionale/operativo.
- **Quadro legale in evoluzione**: le penny auction in Italia sono in una zona grigia; l'AGCM è già intervenuta contro operatori del settore (2015) e potrebbe intervenire in futuro; nessuna azione specifica contro Bidoo risulta ad oggi, ma ciò non costituisce garanzia.
+52 -24
View File
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Engine;
using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Utilities;
@@ -162,7 +163,7 @@ namespace AutoBidder.Services
// Il dossier di un'asta ancora viva va chiuso dicendo che è incompleto: se
// l'asta era conclusa il riepilogo l'ha già scritto OnAuctionCompleted, e questa
// chiamata non trova più nulla da chiudere.
AuctionDossier.Abandon(auctionId, "rimossa dal monitor");
AuctionRecorder.Abandon(auctionId, "rimossa dal monitor");
// Spegnimento e pulizia fuori dal lock e fuori dal thread dell'interfaccia:
// StopAsync attende la fine dei due anelli, che possono essere in mezzo a una
@@ -455,7 +456,7 @@ namespace AutoBidder.Services
// Il dossier registra tutto ciò che passa di qui: è materiale che esiste
// solo adesso.
var pollSettings = Settings;
AuctionDossier.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls);
AuctionRecorder.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls);
if (state.RecentBidsHistory != null && state.RecentBidsHistory.Count > 0)
{
@@ -508,16 +509,15 @@ namespace AutoBidder.Services
/// Verdetto sulle strategie. Il runner decide QUANDO puntare, questo metodo SE.
/// Viene chiamato una volta per ciclo, ~60 ms prima dell'istante di fuoco.
/// </summary>
public bool EvaluateBid(AuctionInfo auction, AuctionState state)
public bool EvaluateBid(AuctionInfo auction, AuctionState state, bool shadow = false)
{
var settings = Settings;
auction.PendingDecisionId = null;
if (state.IsMyBid) return false;
// L'asta dev'essere davvero aperta. Questo controllo è sempre attivo e non
// costa nulla: usa l'ultima risposta ricevuta, che nella finestra critica ha
// al massimo qualche centinaio di millisecondi. Una verifica dedicata via rete
// costerebbe invece un intero giro proprio nell'istante peggiore.
// costa nulla: usa l'ultima risposta ricevuta.
if (state.Status != AuctionStatus.Running && state.Status != AuctionStatus.Pending)
{
auction.AddLog($"⛔ Asta non aperta ({state.Status}): puntata annullata",
@@ -525,22 +525,38 @@ namespace AutoBidder.Services
return false;
}
if (!ShouldBid(auction, state, settings))
var mode = shadow ? "shadow" : "live";
if (!ShouldBid(auction, state, settings, out var limitReason))
{
AuctionRecorder.RecordDecision(auction, state, "NO-OP", null, null, limitReason ?? "limiti dell'asta", mode, null, "paletti", null);
return false;
}
var session = _apiClient.GetSession();
var decision = _bidStrategy.ShouldPlaceBid(auction, state, settings, session?.Username ?? "");
var decision = _bidStrategy.ShouldPlaceBid(auction, state, settings, session?.Username ?? "", GetAuctions(), shadow);
if (!decision.ShouldBid)
{
auction.AddLog($"⛔ Strategia blocca: {decision.Reason}",
auction.AddLog($"⛔ {(shadow ? "Shadow, non avrei puntato" : "Strategia blocca")}: {decision.Reason}",
AuctionLogLevel.Strategy, AuctionLogCategory.Strategy);
LogBlockThrottled(auction, "STRATEGY", $"[STRATEGY] {auction.Name}: {decision.Reason}");
if (!shadow) LogBlockThrottled(auction, "STRATEGY", $"[STRATEGY] {auction.Name}: {decision.Reason}");
AuctionRecorder.RecordDecision(auction, state, "NO-OP", decision.Probability, decision.ExpectedValue,
decision.Reason, mode, decision.Regime, decision.Policy, null, decision.ContextKey, decision.AltAction, decision.AltP);
return false;
}
var id = AuctionRecorder.RecordDecision(auction, state, "BID", decision.Probability, decision.ExpectedValue,
decision.Reason, mode, decision.Regime, decision.Policy, null, decision.ContextKey, decision.AltAction, decision.AltP);
if (shadow)
{
auction.AddLog($"[SHADOW] Avrei puntato a {state.Price:F2} € — {decision.Reason}",
AuctionLogLevel.Strategy, AuctionLogCategory.Strategy);
return false;
}
auction.PendingDecisionId = id;
auction.AddLog("✓ Tutti i controlli superati → PUNTATA!",
AuctionLogLevel.Bid, AuctionLogCategory.BidAttempt);
return true;
@@ -555,7 +571,7 @@ namespace AutoBidder.Services
// La coppia anticipo pianificato/effettivo è il dato per cui esiste il dossier:
// è l'unico modo, rileggendo a freddo, per capire se il valore impostato era
// quello giusto o se la puntata arrivava sistematicamente troppo presto o tardi.
AuctionDossier.For(auction.AuctionId)?.MyBid(
AuctionRecorder.For(auction.AuctionId)?.MyBid(
price: state.Price,
plannedLeadMs: leadMs,
actualLeadMs: actualLeadMs,
@@ -563,7 +579,9 @@ namespace AutoBidder.Services
success: result?.Success ?? false,
error: result?.Error,
bidsUsed: auction.BidsUsedOnThisAuction,
remainingBids: auction.RemainingBids);
remainingBids: auction.RemainingBids,
rttMs: result?.LatencyMs ?? 0,
decisionId: auction.PendingDecisionId);
// L'anticipo effettivo dice se il valore impostato e' quello giusto: da solo
// nel log non serviva a nulla, sommato su molte puntate diventa una taratura.
@@ -641,6 +659,14 @@ namespace AutoBidder.Services
? DateTimeOffset.FromUnixTimeSeconds(state.ExpiryUnixSeconds).ToLocalTime().DateTime
: DateTime.Now;
if (won)
{
var valoreReale = BidStrategyService.ValueOf(auction) ?? 0;
var impostazioniFine = SettingsManager.Load();
var netto = Ml.Theory.RealizedWinProfit(valoreReale, state.Price, 0, impostazioniFine.TransactionFeeEuro, auction.ShippingCost ?? 0);
RiskManager.NoteWin(auction.AuctionId, netto, $"vinta a {state.Price:F2} €, valore reale {valoreReale:F2} €");
}
auction.AddLog($"[ASTA TERMINATA] {statusMsg}");
OnLog?.Invoke($"[FINE] [{auction.AuctionId}] Asta {statusMsg}");
@@ -741,6 +767,7 @@ namespace AutoBidder.Services
// Registra metriche
bool isCollision = !result.Success && IsLateBidError(result.Error);
_bidStrategy.RecordBidAttempt(auction, result.Success);
if (result.Success) RiskManager.NoteBidSpent(auction.AuctionId, auction.BidCost);
// Il modello di latenza impara da ogni puntata: quanto ci ha messo, e se è
// arrivata tardi. Una tardiva alza l'anticipo subito; molte in tempo lo
@@ -831,9 +858,10 @@ namespace AutoBidder.Services
}
}
private bool ShouldBid(AuctionInfo auction, AuctionState state, AppSettings? settings = null)
private bool ShouldBid(AuctionInfo auction, AuctionState state, AppSettings? settings, out string? reason)
{
settings ??= Utilities.SettingsManager.Load();
reason = null;
// CONTROLLO 0: Verifica convenienza (se abilitato e dati disponibili)
if (settings.ValueCheckEnabled &&
@@ -849,7 +877,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Risparmio {auction.CalculatedValue.SavingsPercentage.Value:F1}% < {settings.MinSavingsPercentage:F1}% richiesto",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Value);
LogBlockThrottled(auction, "VALUE", $"[VALUE] {auction.Name}: risparmio {auction.CalculatedValue.SavingsPercentage.Value:F1}% insufficiente");
return false;
reason = $"risparmio {auction.CalculatedValue.SavingsPercentage.Value:F1}% sotto il minimo {settings.MinSavingsPercentage:F1}%"; return false;
}
}
else
@@ -867,7 +895,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Puntate residue ({session.RemainingBids}) ≤ limite ({settings.MinimumRemainingBids})",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "LIMIT", $"[LIMIT] {auction.Name}: puntate residue ({session.RemainingBids}) <= limite ({settings.MinimumRemainingBids})");
return false;
reason = $"puntate residue {session.RemainingBids} ≤ limite {settings.MinimumRemainingBids}"; return false;
}
else if (session != null)
{
@@ -881,7 +909,7 @@ namespace AutoBidder.Services
{
auction.AddLog($"✓ Sono già vincitore corrente - skip",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.BidAttempt);
return false;
reason = "già in testa"; return false;
}
// CONTROLLO 3: Limite puntate per questa asta
@@ -895,7 +923,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Limite puntate raggiunto ({usedBids}/{maxBids})",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "MAXBIDS", $"[LIMIT] {auction.Name}: puntate {usedBids}/{maxBids} - STOP");
return false;
reason = $"limite puntate dell'asta raggiunto ({usedBids}/{maxBids})"; return false;
}
if (maxBids > 0)
@@ -911,7 +939,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Prezzo €{state.Price:F2} < Min €{auction.MinPrice:F2}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Price);
LogBlockThrottled(auction, "PRICE_LOW", $"[PRICE] {auction.Name}: €{state.Price:F2} < Min €{auction.MinPrice:F2} - NON PUNTA");
return false;
reason = $"prezzo {state.Price:F2} € sotto il minimo {auction.MinPrice:F2} €"; return false;
}
if (auction.MaxPrice > 0 && state.Price > auction.MaxPrice)
@@ -919,7 +947,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ Prezzo €{state.Price:F2} > Max €{auction.MaxPrice:F2}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Price);
LogBlockThrottled(auction, "PRICE_HIGH", $"[PRICE] {auction.Name}: €{state.Price:F2} > Max €{auction.MaxPrice:F2} - NON PUNTA");
return false;
reason = $"prezzo {state.Price:F2} € oltre il massimo {auction.MaxPrice:F2} €"; return false;
}
auction.AddLog($"✓ Prezzo OK (€{state.Price:F2} in [{auction.MinPrice:F2}, {(auction.MaxPrice > 0 ? auction.MaxPrice.ToString("F2") : "")}])",
@@ -950,7 +978,7 @@ namespace AutoBidder.Services
auction.AddLog($"⛔ {budget.Reason}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "BUDGET", $"[BUDGET] {auction.Name}: {budget.Reason}");
return false;
reason = budget.Reason; return false;
}
}
@@ -962,7 +990,7 @@ namespace AutoBidder.Services
{
auction.AddLog($"Cooldown: {timeSinceLastClick.TotalMilliseconds:F0}ms < 800ms",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Ticker);
return false;
reason = $"cooldown {timeSinceLastClick.TotalMilliseconds:F0} ms"; return false;
}
}
@@ -1012,7 +1040,7 @@ namespace AutoBidder.Services
{
auction.ResetCount++;
AuctionDossier.For(auction.AuctionId)?.Reset(auction.ResetCount, state.Price, state.LastBidder);
AuctionRecorder.For(auction.AuctionId)?.Reset(auction.ResetCount, state.Price, state.LastBidder);
// Log quando c'è una nuova puntata (se logging attivo)
var settings = SettingsManager.Load();
@@ -1075,14 +1103,14 @@ namespace AutoBidder.Services
bids.Select(b => $"{b.Timestamp}_{b.Username}_{b.Price:F2}"));
var added = 0;
var dossier = AuctionDossier.For(auction.AuctionId);
var dossier = AuctionRecorder.For(auction.AuctionId);
// Le puntate nostre vanno marcate qui, dove il nome di sessione c'è: il
// parser lo fa solo se glielo si passa, e nel percorso dei poll non gli
// arriva. Senza, nessun dossier ha mai portato una puntata con mine:true,
// e l'analisi non poteva dire cosa fosse successo alle nostre.
var me = _apiClient.GetSession()?.Username;
if (!string.IsNullOrEmpty(me)) AuctionDossier.CurrentUsername = me;
if (!string.IsNullOrEmpty(me)) AuctionRecorder.CurrentUsername = me;
foreach (var bid in newBids)
{
+125 -73
View File
@@ -1,59 +1,82 @@
using System;
using System.Collections.Generic;
using AutoBidder.Ml;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Decide se puntare. Quattro passi, sempre nello stesso ordine — vedi
/// Decide se puntare. Cinque passi, sempre nello stesso ordine — vedi
/// <c>Ml/LEGGIMI.md</c> per il disegno completo e i numeri che lo giustificano.
///
/// <list type="number">
/// <item><b>Rischio</b> — il gestore del rischio: kill-switch, HALT, stop-loss,
/// drawdown, tetto del giorno, aste in gioco insieme. Solo dal vivo: in shadow la
/// decisione si registra com'è, per poterla giudicare.</item>
/// <item><b>Paletti</b> — regole dell'utente che non si discutono: tetti di
/// puntate e di spesa, fascia oraria sospesa. Non imparano niente: sono il
/// perimetro dentro cui tutto il resto è libero di muoversi.</item>
/// puntate, fascia oraria sospesa.</item>
/// <item><b>Duello</b> — riconoscimento dell'autopuntata avversaria dal ritmo
/// delle risposte. È il segnale più forte che esista sul momento, e informa il
/// regime prima ancora del modello.</item>
/// delle risposte.</item>
/// <item><b>Valore atteso</b> — il modello appreso dice con che probabilità una
/// puntata fatta adesso resterebbe senza risposta; moltiplicata per il margine e
/// al netto del costo dà quanto vale, in euro, puntare in questo istante.</item>
/// puntata fatta adesso resterebbe senza risposta; <see cref="Theory"/> la
/// trasforma in euro con valore reale del bene, fee, spedizione e copertura.</item>
/// <item><b>Regime</b> — una macchina a stati per asta che trasforma la sequenza
/// dei valori attesi in una condotta: quando lasciar sfogare gli altri, quando
/// sondare, quando tornare a giocare. Con isteresi, perché ogni rientro
/// sbagliato costa una puntata.</item>
/// sondare, quando tornare a giocare.</item>
/// </list>
///
/// <para>Le vecchie euristiche a soglia fissa — calore, ritiro morbido, avversari
/// aggressivi, anti-bot, puntata probabilistica, velocità del prezzo — non ci sono
/// più: rigiocate sui dossier non hanno mai bloccato una puntata sbagliata senza
/// bloccarne anche di giuste, e i loro numeri magici andavano tarati a mano. Il
/// modello e il regime imparano dai dati storici e dalla sessione in corso.</para>
///
/// <para><i>Quando</i> puntare — l'anticipo — non si decide qui ma nel cecchino,
/// con <see cref="Ml.LatencyModel"/>: anche quello si adatta alla rete della
/// sessione, dentro i paletti dell'utente.</para>
/// con <see cref="LatencyModel"/>.</para>
/// </summary>
public class BidStrategyService
{
private int _sessionTotalBids = 0;
private DateTime _sessionStartedAt = DateTime.UtcNow;
private readonly Random _rng = new();
public BidDecision ShouldPlaceBid(AuctionInfo auction, AuctionState state, AppSettings settings, string currentUsername)
/// <summary>
/// Il valore reale V del bene di quest'asta: quello scritto dall'utente sul
/// prodotto, se c'è; altrimenti il «Compralo Ora». Vedi <see cref="Theory"/>: il
/// listino di Bidoo è gonfiato, e un valore atteso calcolato su di esso mente.
/// </summary>
public static double? ValueOf(AuctionInfo auction)
{
var decision = new BidDecision { ShouldBid = true };
if (auction == null) return null;
var scheda = WatchedProductsStore.FindByName(auction.Name);
return scheda?.MarketValue is > 0 ? scheda.MarketValue : auction.BuyNowPrice;
}
// ── 1. Paletti ──────────────────────────────────────────────────
public BidDecision ShouldPlaceBid(
AuctionInfo auction,
AuctionState state,
AppSettings settings,
string currentUsername,
IReadOnlyList<AuctionInfo>? allAuctions = null,
bool shadow = false)
{
var decision = new BidDecision { ShouldBid = true, Policy = "ev-regime" };
// Fascia oraria sospesa. L'asta NON viene fermata: resta Attiva, continua a
// essere seguita e riprende a puntare da sola alla fine della fascia. Su 6060
// aste concluse la stessa asta costa quasi il doppio alle 0 e alle 9 rispetto
// alle 10-13: vedi BiddingHours.
// ── 1. Rischio ─────────────────────────────────────────────────
if (!shadow)
{
var risk = RiskManager.Authorize(auction, settings, allAuctions);
if (!risk.Allowed)
{
decision.ShouldBid = false;
decision.Reason = risk.Reason;
decision.Policy = "rischio";
return decision;
}
}
// ── 2. Paletti ─────────────────────────────────────────────────
if (QuietHoursApply(auction, settings) &&
BiddingHours.IsQuiet(DateTime.Now, settings.QuietHoursStart, settings.QuietHoursEnd))
{
decision.ShouldBid = false;
decision.Reason = BiddingHours.Spiegazione(settings.QuietHoursStart, settings.QuietHoursEnd);
decision.Policy = "paletti";
return decision;
}
@@ -64,17 +87,12 @@ namespace AutoBidder.Services
{
decision.ShouldBid = false;
decision.Reason = bankrollCheck.Reason;
decision.Policy = "paletti";
return decision;
}
}
// ── 2. Duello con l'autopuntata ────────────────────────────────
//
// L'autopuntata del sito scatta a 2 secondi e si riarma ogni volta che il suo
// padrone perde la testa: risponde sempre, in ~6 secondi, e non si stanca.
// Nelle prove reali il motore ci ha lasciato 191 puntate su tre aste per zero
// vittorie. Il riconoscimento vive in AutoBidDuel; qui si applica e si passa
// l'informazione al regime, che da quel momento pretende più pazienza.
// ── 3. Duello con l'autopuntata ────────────────────────────────
if (auction.AutoBidDuelDetected)
{
auction.Regime.AutopuntataRiconosciuta();
@@ -83,50 +101,75 @@ namespace AutoBidder.Services
{
decision.ShouldBid = false;
decision.Reason = AutoBidDuel.Spiegazione(auction.AutoResponsesInARow);
decision.Policy = "duello";
decision.Regime = auction.Regime.StatoAttuale.ToString();
return decision;
}
}
// ── 3. Valore atteso appreso ────────────────────────────────────
// ── 4. Valore atteso ───────────────────────────────────────────
//
// Il modello parla sempre; decide solo quando ha appreso abbastanza aste. La
// probabilità e il valore atteso finiscono comunque sull'asta, per il registro
// e per l'interfaccia.
// probabilità e il valore atteso finiscono comunque sull'asta e sulla decisione.
double? ev = null;
string? spiegazioneEv = null;
var pronto = false;
var value = ValueOf(auction);
if (settings.LearningGateEnabled && auction.BuyNowPrice is > 0)
if (settings.LearningGateEnabled && value is > 0)
{
var prob = Ml.LearningService.PredictUnanswered(auction, state, DateTime.Now, currentUsername);
if (prob is { } p)
var ctx = LearningService.BuildContext(auction, state, DateTime.Now, currentUsername);
var prob = ctx is { } c0 ? LearningService.PredictUnanswered(c0) : (double?)null;
if (prob is { } p && ctx is { } c)
{
var costo = settings.AverageBidCostEuro * Math.Max(0.1, settings.LearningEvMultiplier);
var margine = auction.BuyNowPrice.Value - state.Price - 0.01;
var valore = p * margine - costo;
var speso = (auction.BidsUsedOnThisAuction ?? 0) * auction.BidCost;
var situazione = new Theory.BidSituation(
PWin: p,
Value: value.Value,
CurrentPrice: state.Price,
BidCost: costo,
TransactionFee: settings.TransactionFeeEuro,
Shipping: auction.ShippingCost ?? 0,
ListPrice: auction.BuyNowPrice,
BidsSpentEuro: speso,
HedgeEnabled: settings.BuyNowHedgeEnabled);
var valore = Theory.BidExpectedValue(situazione);
auction.LearnedUnansweredProbability = p;
auction.LearnedExpectedValue = valore;
pronto = Ml.LearningService.IsReady(settings);
pronto = LearningService.IsReady(settings);
ev = valore;
decision.Probability = p;
decision.ExpectedValue = valore;
var margine = value.Value - state.Price - Theory.PriceIncrement - settings.TransactionFeeEuro - (auction.ShippingCost ?? 0);
spiegazioneEv =
$"valore atteso negativo: P(senza risposta) {p:P2} × margine {margine:F2} € " +
$" puntata {costo:F2} € = {valore:+0.000;-0.000} €";
$" puntata {costo:F2} €{(Theory.LossBranchCost(situazione) == 0 ? " (coperta)" : "")} = {valore:+0.000;-0.000} €";
Ml.LearningService.RecordDecision(auction.Name, state.Price, p, valore,
LearningService.RecordDecision(auction.Name, state.Price, p, valore,
blocked: pronto && valore < 0, ready: pronto);
// La seconda policy, sempre in shadow: il bandit dice la sua sullo
// stesso istante, e alla chiusura entrambe vengono giudicate sugli esiti.
var key = ThompsonBandit.KeyOf(c);
var (altBid, altP) = LearningService.Bandit.Decide(key, situazione, _rng);
decision.ContextKey = key;
decision.AltAction = altBid ? "BID" : "NO-OP";
decision.AltP = altP;
}
}
// ── 4. Regime di concorrenza ───────────────────────────────────
//
// Finché il modello non è pronto il regime non riceve segnali: resta Calmo e
// l'unica cosa che lo muove è il duello riconosciuto sopra.
// ── 5. Regime di concorrenza ───────────────────────────────────
if (pronto && ev is { } valoreAtteso)
{
var statoPrima = auction.Regime.StatoAttuale;
var consentito = auction.Regime.Osserva(valoreAtteso >= 0);
decision.Regime = auction.Regime.StatoAttuale.ToString();
if (auction.Regime.StatoAttuale != statoPrima)
{
@@ -137,11 +180,21 @@ namespace AutoBidder.Services
if (!consentito)
{
decision.ShouldBid = false;
decision.Reason = auction.Regime.StatoAttuale == Ml.CompetitionRegime.Stato.Calmo
decision.Reason = auction.Regime.StatoAttuale == CompetitionRegime.Stato.Calmo
? spiegazioneEv
: auction.Regime.Spiegazione();
return decision;
}
decision.Reason = $"valore atteso {valoreAtteso:+0.000;-0.000} € (P {ev:P2}), regime {auction.Regime.StatoAttuale}";
}
else if (ev is { } evNonPronto)
{
decision.Reason = $"modello non ancora pronto ({LearningService.AuctionsLearned}/{settings.LearningMinAuctions} aste): parere EV {evNonPronto:+0.000;-0.000} €, si punta";
}
else
{
decision.Reason = "nessuna stima disponibile (valore ignoto o nessuna puntata recente): si punta";
}
return decision;
@@ -151,7 +204,6 @@ namespace AutoBidder.Services
{
var result = new BankrollCheckResult { CanBid = true };
// Limite puntate per asta
var maxPerAuction = auction.MaxBidsOverride ?? settings.MaxBidsPerAuction;
if (maxPerAuction > 0 && auction.SessionBidCount >= maxPerAuction)
{
@@ -160,7 +212,6 @@ namespace AutoBidder.Services
return result;
}
// Limite puntate per sessione
if (settings.MaxBidsPerSession > 0 && _sessionTotalBids >= settings.MaxBidsPerSession)
{
result.CanBid = false;
@@ -168,35 +219,18 @@ namespace AutoBidder.Services
return result;
}
// Budget giornaliero
if (settings.DailyBudgetEuro > 0)
{
var spent = _sessionTotalBids * settings.AverageBidCostEuro;
if (spent >= settings.DailyBudgetEuro)
{
result.CanBid = false;
result.Reason = $"Budget giornaliero esaurito (€{spent:F2}/€{settings.DailyBudgetEuro:F2})";
return result;
}
}
return result;
}
/// <summary>
/// La sospensione a fascia oraria vale per quest'asta?
///
/// <para>Tre livelli, dal più specifico al più generale: l'asta scavalca il
/// prodotto, il prodotto scavalca le impostazioni generali. Un <c>null</c> non
/// significa "no", significa "non ho un'opinione": è la differenza che permette di
/// spegnere il filtro su una singola asta senza toccare il resto.</para>
/// La sospensione a fascia oraria vale per quest'asta? Tre livelli, dal più
/// specifico al più generale: l'asta scavalca il prodotto, il prodotto scavalca
/// le impostazioni generali.
/// </summary>
public static bool QuietHoursApply(AuctionInfo auction, AppSettings settings)
{
if (auction?.QuietHoursEnabledOverride is { } perAsta) return perAsta;
// Le aste del monitor non portano con sé l'id prodotto di Bidoo: la scheda si
// ritrova dal nome, che è la via prevista da WatchedProductsStore.
var scheda = WatchedProductsStore.FindByName(auction?.Name);
if (scheda?.QuietHoursEnabled is { } perProdotto) return perProdotto;
@@ -212,10 +246,6 @@ namespace AutoBidder.Services
if (success)
{
auction.SuccessfulBidCount++;
// Da qui si misura quanto tarda la risposta avversaria: e' l'unico punto
// attraversato sia dal motore dal vivo sia dalla rigiocata sui dossier,
// quindi duello e regime vedono le stesse cose in entrambi.
auction.LastMyBidAtUtc = DateTime.UtcNow;
auction.Regime.PuntataPiazzata();
}
@@ -247,10 +277,32 @@ namespace AutoBidder.Services
}
}
/// <summary>Il verdetto della strategia, con i numeri che l'hanno prodotto.</summary>
public class BidDecision
{
public bool ShouldBid { get; set; }
public string? Reason { get; set; }
/// <summary>P(senza risposta) stimata dal modello, se c'era.</summary>
public double? Probability { get; set; }
/// <summary>Valore atteso in euro della puntata, se c'era.</summary>
public double? ExpectedValue { get; set; }
/// <summary>Regime di concorrenza al momento della decisione.</summary>
public string? Regime { get; set; }
/// <summary>Quale parte ha deciso: rischio, paletti, duello, ev-regime.</summary>
public string Policy { get; set; } = "";
/// <summary>Chiave del contesto per il bandit, se il modello ha parlato.</summary>
public string? ContextKey { get; set; }
/// <summary>Cosa avrebbe fatto il bandit Thompson sullo stesso istante.</summary>
public string? AltAction { get; set; }
/// <summary>La probabilità estratta dal bandit.</summary>
public double? AltP { get; set; }
}
public class BankrollCheckResult
+278
View File
@@ -0,0 +1,278 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Il gestore del rischio: i paletti duri che nessuna strategia può scavalcare.
/// Ogni puntata dal vivo passa da <see cref="Authorize"/> prima di partire.
///
/// <para><b>Quattro vincoli, tutti in euro o in numero, tutti dell'utente.</b>
/// Il tetto di spesa giornaliero, lo stop-loss giornaliero (quando la perdita netta
/// del giorno supera la soglia si entra in HALT), il drawdown massimo dal picco del
/// saldo cumulato, e il numero massimo di aste su cui puntare insieme. Sopra a tutti,
/// il <b>kill-switch</b>: un file sentinella in <c>%LocalAppData%\AutoBidder</c> che,
/// finché esiste, ferma ogni puntata dal vivo — lo si può creare anche da fuori,
/// senza aprire l'applicazione.</para>
///
/// <para><b>HALT è persistente.</b> Si scrive nel database e sopravvive al riavvio:
/// una regola di sicurezza che si azzera chiudendo e riaprendo il programma non è una
/// regola. Si toglie a mano, dalla scheda Impostazioni, e la rimozione finisce nel
/// registro.</para>
///
/// <para>La contabilità sta nella tabella <c>risk_ledger</c>: una riga per puntata
/// riuscita (uscita) e una per vincita (entrata, al valore reale del bene meno i
/// costi). I totali del giorno si tengono in memoria e si rileggono a ogni cambio di
/// giorno, così la verifica per puntata non interroga il disco.</para>
/// </summary>
public static class RiskManager
{
public readonly record struct Verdict(bool Allowed, string? Reason);
private static readonly object Sync = new();
private static AuctionDatabase Db => AuctionDatabase.Instance;
private static DateTime _ledgerDay = DateTime.MinValue;
private static double _todaySpent; // euro usciti oggi (puntate)
private static double _todayGained; // euro entrati oggi (vincite)
private static double _cumulative; // saldo cumulato di sempre
private static double _peak; // massimo del saldo cumulato
private static bool _ledgerLoaded;
private static string? _haltReason;
private static bool _haltLoaded;
private static DateTime _killSwitchCheckedAt = DateTime.MinValue;
private static bool _killSwitchState;
// ── Kill-switch ──────────────────────────────────────────────────
/// <summary>Il file sentinella: finché esiste, nessuna puntata dal vivo.</summary>
public static string KillSwitchFile => Path.Combine(AppPaths.Root, "KILL_SWITCH");
/// <summary>Vero se il file c'è. Si rilegge al massimo una volta al secondo.</summary>
public static bool KillSwitchActive
{
get
{
lock (Sync)
{
if ((DateTime.UtcNow - _killSwitchCheckedAt).TotalSeconds >= 1)
{
_killSwitchState = File.Exists(KillSwitchFile);
_killSwitchCheckedAt = DateTime.UtcNow;
}
return _killSwitchState;
}
}
}
public static void SetKillSwitch(bool on)
{
lock (Sync)
{
try
{
if (on) File.WriteAllText(KillSwitchFile, $"creato il {DateTime.Now:yyyy-MM-dd HH:mm:ss}\n");
else if (File.Exists(KillSwitchFile)) File.Delete(KillSwitchFile);
}
catch { }
_killSwitchState = on;
_killSwitchCheckedAt = DateTime.UtcNow;
}
}
// ── HALT ─────────────────────────────────────────────────────────
/// <summary>Il motivo dell'HALT in corso, o null.</summary>
public static string? HaltReason
{
get
{
lock (Sync)
{
if (!_haltLoaded)
{
_haltLoaded = true;
try { _haltReason = Db.ScalarString("SELECT value FROM meta WHERE key = 'risk_halt'"); }
catch { _haltReason = null; }
}
return _haltReason;
}
}
}
public static bool Halted => !string.IsNullOrEmpty(HaltReason);
public static void Halt(string reason)
{
lock (Sync)
{
_haltLoaded = true;
_haltReason = $"{DateTime.Now:dd/MM HH:mm} — {reason}";
Db.Enqueue("INSERT INTO meta(key, value) VALUES('risk_halt', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", _haltReason);
Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, note) VALUES(?1, 'halt', 0, ?2)", AuctionDatabase.Now(), reason);
}
}
public static void Resume()
{
lock (Sync)
{
_haltLoaded = true;
_haltReason = null;
Db.Enqueue("DELETE FROM meta WHERE key = 'risk_halt'");
Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, note) VALUES(?1, 'resume', 0, 'HALT tolto a mano')", AuctionDatabase.Now());
}
}
// ── Contabilità ──────────────────────────────────────────────────
private static void EnsureLedger()
{
var today = DateTime.Today;
if (_ledgerLoaded && _ledgerDay == today) return;
_ledgerDay = today;
_ledgerLoaded = true;
_todaySpent = 0;
_todayGained = 0;
_cumulative = 0;
_peak = 0;
try
{
var since = AuctionDatabase.Iso(today); // mezzanotte locale, scritta in UTC
foreach (var r in Db.Query("SELECT kind, amount_euro FROM risk_ledger WHERE ts >= ?1 AND kind IN ('bid', 'win')", since))
{
if (r.Str("kind") == "bid") _todaySpent += -r.Dbl("amount_euro");
else _todayGained += r.Dbl("amount_euro");
}
// Il picco del saldo si ricostruisce scorrendo la storia in ordine.
foreach (var r in Db.Query("SELECT amount_euro FROM risk_ledger WHERE kind IN ('bid', 'win') ORDER BY id"))
{
_cumulative += r.Dbl("amount_euro");
if (_cumulative > _peak) _peak = _cumulative;
}
}
catch { /* senza contabilità si applicano solo i vincoli che non ne hanno bisogno */ }
}
/// <summary>Una puntata riuscita: esce il suo costo.</summary>
public static void NoteBidSpent(string auctionId, double bidCost)
{
if (bidCost <= 0) return;
lock (Sync)
{
EnsureLedger();
_todaySpent += bidCost;
_cumulative -= bidCost;
Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, auction_id) VALUES(?1, 'bid', ?2, ?3)",
AuctionDatabase.Now(), -bidCost, auctionId);
}
}
/// <summary>Una vincita: entra il valore reale del bene meno prezzo, fee e spedizione (le puntate sono già uscite).</summary>
public static void NoteWin(string auctionId, double netGain, string note)
{
lock (Sync)
{
EnsureLedger();
_todayGained += netGain;
_cumulative += netGain;
if (_cumulative > _peak) _peak = _cumulative;
Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, auction_id, note) VALUES(?1, 'win', ?2, ?3, ?4)",
AuctionDatabase.Now(), netGain, auctionId, note);
}
}
/// <summary>I numeri del giorno e di sempre, per la scheda.</summary>
public static (double TodaySpent, double TodayGained, double TodayNet, double Cumulative, double Peak, double Drawdown) Snapshot()
{
lock (Sync)
{
EnsureLedger();
return (_todaySpent, _todayGained, _todayGained - _todaySpent, _cumulative, _peak, _peak - _cumulative);
}
}
// ── Autorizzazione ───────────────────────────────────────────────
/// <summary>
/// Può partire una puntata dal vivo su quest'asta? Controlla, nell'ordine: il
/// kill-switch, l'HALT, lo stop-loss del giorno, il drawdown, il tetto di spesa
/// del giorno, le aste in gioco insieme. Le prime due sono assolute; le altre
/// valgono solo se impostate (0 = nessun limite).
/// </summary>
public static Verdict Authorize(AuctionInfo auction, AppSettings settings, IReadOnlyList<AuctionInfo>? allAuctions = null)
{
if (KillSwitchActive)
return new Verdict(false, "kill-switch attivo: nessuna puntata dal vivo finché esiste il file KILL_SWITCH");
if (Halted)
return new Verdict(false, $"HALT del gestore del rischio: {HaltReason}. Si toglie dalle Impostazioni");
lock (Sync)
{
EnsureLedger();
if (settings.DailyStopLossEuro > 0 && _todaySpent - _todayGained >= settings.DailyStopLossEuro)
{
var reason = $"stop-loss giornaliero raggiunto: perdita netta di oggi {_todaySpent - _todayGained:F2} € ≥ {settings.DailyStopLossEuro:F2} €";
HaltUnlocked(reason);
return new Verdict(false, reason);
}
if (settings.MaxDrawdownEuro > 0 && _peak - _cumulative >= settings.MaxDrawdownEuro)
{
var reason = $"drawdown massimo raggiunto: {_peak - _cumulative:F2} € sotto il picco (limite {settings.MaxDrawdownEuro:F2} €)";
HaltUnlocked(reason);
return new Verdict(false, reason);
}
if (settings.DailyBudgetEuro > 0 && _todaySpent + Math.Max(0, auction.BidCost) > settings.DailyBudgetEuro)
return new Verdict(false, $"tetto di spesa del giorno: spesi {_todaySpent:F2} € su {settings.DailyBudgetEuro:F2} €");
}
if (settings.MaxConcurrentActiveAuctions > 0 && allAuctions != null)
{
// Aste su cui si sta già spendendo in questa sessione, quest'asta esclusa.
var inGioco = allAuctions.Count(a =>
a.State == RunState.Active && a.SessionBidCount > 0 &&
!string.Equals(a.AuctionId, auction.AuctionId, StringComparison.Ordinal));
if (auction.SessionBidCount == 0 && inGioco >= settings.MaxConcurrentActiveAuctions)
return new Verdict(false, $"già {inGioco} aste in gioco insieme (limite {settings.MaxConcurrentActiveAuctions})");
}
return new Verdict(true, null);
}
private static void HaltUnlocked(string reason)
{
_haltLoaded = true;
_haltReason = $"{DateTime.Now:dd/MM HH:mm} — {reason}";
Db.Enqueue("INSERT INTO meta(key, value) VALUES('risk_halt', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", _haltReason);
Db.Enqueue("INSERT INTO risk_ledger(ts, kind, amount_euro, note) VALUES(?1, 'halt', 0, ?2)", AuctionDatabase.Now(), reason);
}
/// <summary>Solo per i test: dimentica contabilità e HALT in memoria.</summary>
public static void ResetForTests()
{
lock (Sync)
{
_ledgerLoaded = false;
_haltLoaded = false;
_haltReason = null;
_killSwitchCheckedAt = DateTime.MinValue;
}
}
}
}
-302
View File
@@ -1,302 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoBidder.Models;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il dossier è l'unico posto in cui certi dati esistono: Bidoo non espone né i tempi delle
/// puntate né l'andamento del prezzo di un'asta conclusa. Le proprietà da difendere sono
/// quindi tre — che il file <b>si scriva</b>, che ogni riga sia <b>JSON valido da sola</b>
/// (è quello che permette di leggerlo a pezzi e di sopravvivere a un'interruzione), e che il
/// riepilogo finale dica se la storia è completa.
/// </summary>
public class AuctionDossierTests
{
private static AppSettings Settings(bool rawPolls = true) => new()
{
WriteAuctionDossiers = true,
DossierIncludeRawPolls = rawPolls
};
private static AuctionInfo NewAuction() => new()
{
AuctionId = "test-" + Guid.NewGuid().ToString("N")[..8],
Name = "Cuffie Sony WH-1000XM5",
BidBeforeDeadlineMs = 800,
BuyNowPrice = 299,
ShippingCost = 6.90
};
private static AuctionState State(double price, double timer, int ping = 70) => new()
{
Price = price,
Timer = timer,
PollingLatencyMs = ping,
Status = AuctionStatus.Running,
LastBidder = "marco_82",
ExpiryUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (long)timer
};
private static AuctionDetailRecord Detail(AuctionInfo auction) => new()
{
AuctionId = auction.AuctionId,
Name = auction.Name,
Outcome = "Persa",
FinalPrice = 4.87,
BuyNowPrice = auction.BuyNowPrice,
ShippingCost = auction.ShippingCost,
MyBids = 9,
EndedAt = DateTime.Now,
FirstSeenAt = DateTime.Now.AddMinutes(-35),
ObservedMinutes = 35,
ConfiguredLeadMs = 800
};
private static string[] LinesOf(AuctionDossier dossier)
{
FileLogWriter.FlushAll();
return File.ReadAllLines(dossier.Path).Where(l => l.Length > 0).ToArray();
}
[Fact]
public void Aprire_il_dossier_scrive_lintestazione()
{
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
var header = JsonDocument.Parse(LinesOf(dossier)[0]).RootElement;
Assert.Equal("header", header.GetProperty("type").GetString());
Assert.Equal(auction.AuctionId, header.GetProperty("auctionId").GetString());
// Il valore del prodotto deve stare nel file: senza, il prezzo finale non dice
// se è stato un affare.
Assert.Equal(299, header.GetProperty("product").GetProperty("buyNowPrice").GetDouble());
// E con che anticipo la si stava seguendo: due aste con anticipi diversi non sono
// confrontabili fra loro.
Assert.Equal(800, header.GetProperty("config").GetProperty("bidBeforeDeadlineMs").GetInt32());
AuctionDossier.Abandon(auction.AuctionId, "fine test");
}
// ── 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]
public void Ogni_riga_e_json_valido_per_conto_suo()
{
// È la ragione del formato: un file troncato a metà perde l'ultima riga, non tutto.
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
dossier.Poll(State(1.24, 15), includeRaw: true);
dossier.Reset(1, 1.25, "marco_82");
dossier.MyBid(1.25, 800, 762.4, 68, success: true, error: null, bidsUsed: 4, remainingBids: 255);
dossier.Log("info", "Status", "asta in corso");
var lines = LinesOf(dossier);
Assert.All(lines, line => JsonDocument.Parse(line));
Assert.Equal(5, lines.Length); // intestazione + quattro eventi
AuctionDossier.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void La_mia_puntata_registra_anticipo_voluto_e_ottenuto()
{
// È la coppia su cui si tara l'anticipo: da sola nessuna delle due dice nulla.
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
dossier.MyBid(1.25, plannedLeadMs: 800, actualLeadMs: 762.4, pingMs: 68,
success: true, error: null, bidsUsed: 4, remainingBids: 255);
var bid = JsonDocument.Parse(LinesOf(dossier)[1]).RootElement;
Assert.Equal("my_bid", bid.GetProperty("type").GetString());
Assert.Equal(800, bid.GetProperty("plannedLeadMs").GetInt32());
Assert.Equal(762.4, bid.GetProperty("actualLeadMs").GetDouble(), 1);
Assert.Equal(-37.6, bid.GetProperty("leadErrorMs").GetDouble(), 1);
Assert.Equal(68, bid.GetProperty("pingMs").GetInt32());
Assert.Equal("ok", bid.GetProperty("result").GetString());
AuctionDossier.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Senza_poll_grezzi_il_ping_si_misura_lo_stesso()
{
// Spegnere i poll grezzi riduce la dimensione del file, non la qualità delle
// statistiche di rete: quelle si accumulano comunque.
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings(rawPolls: false))!;
dossier.Poll(State(1.24, 15, ping: 60), includeRaw: false);
dossier.Poll(State(1.30, 12, ping: 80), includeRaw: false);
Assert.Single(LinesOf(dossier)); // solo l'intestazione
AuctionDossier.Close(auction, Detail(auction), null);
var summary = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
var network = summary.GetProperty("network");
Assert.Equal(2, network.GetProperty("samples").GetInt32());
Assert.Equal(70, network.GetProperty("avgPingMs").GetDouble());
Assert.Equal(60, network.GetProperty("minPingMs").GetInt32());
Assert.Equal(80, network.GetProperty("maxPingMs").GetInt32());
}
[Fact]
public void Il_riepilogo_dice_se_la_storia_e_completa()
{
var auction = NewAuction();
auction.ObservedFromStart = true;
auction.ObservedToEnd = true;
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
AuctionDossier.Close(auction, Detail(auction), null);
var summary = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
Assert.Equal("summary", summary.GetProperty("type").GetString());
Assert.True(summary.GetProperty("coverage").GetProperty("complete").GetBoolean());
// Costo reale se avessi vinto: prezzo + puntate + spedizione.
Assert.True(summary.GetProperty("value").GetProperty("totalCostIfWon").GetDouble() > 4.87);
}
[Fact]
public void Dopo_il_riepilogo_non_si_scrive_piu_nulla()
{
// Una riga in coda al riepilogo farebbe dubitare di tutto ciò che c'è sopra.
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
AuctionDossier.Close(auction, Detail(auction), null);
var before = LinesOf(dossier).Length;
dossier.Log("info", "Status", "questa riga non deve comparire");
Assert.Equal(before, LinesOf(dossier).Length);
}
[Fact]
public void Unasta_tolta_prima_della_fine_lo_dichiara()
{
// Una serie di prezzi che si interrompe sembra un'asta finita a quel prezzo: va
// detto che i dati sono incompleti.
var auction = NewAuction();
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
AuctionDossier.Abandon(auction.AuctionId, "rimossa dal monitor");
var last = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
Assert.Equal("abandoned", last.GetProperty("type").GetString());
Assert.Contains("incompleti", last.GetProperty("note").GetString());
}
[Fact]
public void Riaprire_la_stessa_asta_riprende_lo_stesso_file()
{
// Un riavvio a metà asta non deve spezzare la storia in due file.
var auction = NewAuction();
var first = AuctionDossier.OpenFor(auction, Settings())!;
var path = first.Path;
AuctionDossier.Abandon(auction.AuctionId, "simulazione riavvio");
var second = AuctionDossier.OpenFor(auction, Settings())!;
Assert.Equal(path, second.Path);
var lines = LinesOf(second);
Assert.Equal("resumed", JsonDocument.Parse(lines[^1]).RootElement.GetProperty("type").GetString());
AuctionDossier.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Con_i_dossier_spenti_non_si_scrive_nessun_file()
{
var auction = NewAuction();
Assert.Null(AuctionDossier.OpenFor(auction, new AppSettings { WriteAuctionDossiers = false }));
}
}
+254
View File
@@ -0,0 +1,254 @@
using System;
using System.Linq;
using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il registratore è l'unico posto in cui certi dati esistono: Bidoo non espone né i
/// tempi delle puntate né l'andamento del prezzo di un'asta conclusa. Le proprietà da
/// difendere: l'apertura crea la riga dell'asta e del prodotto, ogni evento finisce
/// nella sua tabella, il riepilogo chiude la riga, l'abbandono la marca, e le
/// interrogazioni uguali non si scrivono due volte.
/// </summary>
[Collection("Database")]
public class AuctionRecorderTests
{
private static AuctionDatabase Db => AuctionDatabase.Instance;
private static AppSettings Settings(bool rawPolls = true) => new()
{
WriteAuctionDossiers = true,
DossierIncludeRawPolls = rawPolls
};
private static AuctionInfo NewAuction() => new()
{
AuctionId = "rec-" + Guid.NewGuid().ToString("N")[..10],
Name = "Cuffie Sony WH-1000XM5",
BidBeforeDeadlineMs = 800,
BuyNowPrice = 299,
ShippingCost = 6.90
};
private static AuctionState State(double price, double timer, int ping = 70, string bidder = "marco_82") => new()
{
Price = price,
Timer = timer,
PollingLatencyMs = ping,
Status = AuctionStatus.Running,
LastBidder = bidder,
ExpiryUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (long)timer,
ServerUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
private static AuctionDetailRecord Detail(AuctionInfo auction) => new()
{
AuctionId = auction.AuctionId,
Name = auction.Name,
ProductKey = ProductKeyHelper.GenerateProductKey(auction.Name),
Outcome = "Persa",
Winner = "marco_82",
FinalPrice = 4.87,
BuyNowPrice = auction.BuyNowPrice,
ShippingCost = auction.ShippingCost,
MyBids = 9,
EndedAt = DateTime.Now,
FirstSeenAt = DateTime.Now.AddMinutes(-35),
ObservedMinutes = 35,
ConfiguredLeadMs = 800,
DistinctBidders = 2,
TotalObservedBids = 12,
Resets = 11
};
[Fact]
public void Aprire_crea_la_riga_dellasta_e_del_prodotto()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
Db.Flush();
var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auction.AuctionId)!;
Assert.Equal("open", row.Str("status"));
Assert.Equal(auction.Name, row.Str("name"));
Assert.Equal(299, row.Dbl("buy_now_price"));
Assert.Equal(800, row.Int("configured_lead_ms"));
Assert.True(row.Long("item_id") > 0);
var item = Db.QueryOne("SELECT * FROM items WHERE item_id = ?1", row.Long("item_id"))!;
Assert.Equal(ProductKeyHelper.GenerateProductKey(auction.Name), item.Str("product_key"));
AuctionRecorder.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Ogni_evento_finisce_nella_sua_tabella()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
rec.Poll(State(1.24, 15), includeRaw: true);
rec.ForeignBid(new BidHistoryEntry { Username = "marco_82", Price = 1.25m, BidType = "Manuale", Timestamp = 1_700_000_000 }, 1.25);
rec.Reset(1, 1.25, "marco_82");
rec.MyBid(1.26, 800, 762.4, 68, success: true, error: null, bidsUsed: 4, remainingBids: 255, rttMs: 90);
rec.Log("info", "Status", "asta in corso");
Db.Flush();
Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM polls WHERE auction_id = ?1", auction.AuctionId));
Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM bids WHERE auction_id = ?1", auction.AuctionId));
Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM resets WHERE auction_id = ?1", auction.AuctionId));
Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM my_bids WHERE auction_id = ?1", auction.AuctionId));
Assert.True(Db.ScalarLong("SELECT COUNT(*) AS n FROM auction_log WHERE auction_id = ?1", auction.AuctionId) >= 2);
var bid = Db.QueryOne("SELECT * FROM bids WHERE auction_id = ?1", auction.AuctionId)!;
Assert.Equal("Manuale", bid.Str("bid_type"));
Assert.Equal(1_700_000_000, bid.Long("server_ts"));
Assert.Equal(15, bid.Dbl("timer_before"));
var mine = Db.QueryOne("SELECT * FROM my_bids WHERE auction_id = ?1", auction.AuctionId)!;
Assert.Equal(800, mine.Int("planned_lead_ms"));
Assert.Equal(762.4, mine.Dbl("actual_lead_ms"), 1);
Assert.Equal(90, mine.Int("rtt_ms"));
AuctionRecorder.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Una_puntata_ricostruita_non_sovrascrive_il_tipo_vero()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
rec.ForeignBid(new BidHistoryEntry { Username = "anna", Price = 2.00m, BidType = "Auto", Timestamp = 1_700_000_100 }, 2.00);
rec.ForeignBid(new BidHistoryEntry { Username = "anna", Price = 2.00m, BidType = BidHistoryEntry.TipoSconosciuto }, 2.00);
Db.Flush();
var rows = Db.Query("SELECT * FROM bids WHERE auction_id = ?1", auction.AuctionId);
Assert.Single(rows);
Assert.Equal("Auto", rows[0].Str("bid_type"));
AuctionRecorder.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Le_interrogazioni_uguali_non_si_scrivono_due_volte()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
for (var i = 0; i < 10; i++) rec.Poll(State(1.24, 15.9 - i * 0.05), includeRaw: true);
rec.Poll(State(1.25, 15), includeRaw: true); // cambia il prezzo: si scrive
Db.Flush();
Assert.Equal(2, Db.ScalarLong("SELECT COUNT(*) AS n FROM polls WHERE auction_id = ?1", auction.AuctionId));
AuctionRecorder.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Il_riepilogo_chiude_la_riga_e_aggrega_i_puntatori()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
rec.ForeignBid(new BidHistoryEntry { Username = "marco_82", Price = 1.25m, BidType = "Manuale", Timestamp = 1_700_000_000 }, 1.25);
rec.ForeignBid(new BidHistoryEntry { Username = "anna", Price = 1.26m, BidType = "Auto", Timestamp = 1_700_000_006 }, 1.26);
rec.ForeignBid(new BidHistoryEntry { Username = "marco_82", Price = 1.27m, BidType = "Manuale", Timestamp = 1_700_000_012 }, 1.27);
AuctionRecorder.Close(auction, Detail(auction), State(4.87, 0));
Db.Flush();
var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auction.AuctionId)!;
Assert.Equal("closed", row.Str("status"));
Assert.Equal("Persa", row.Str("outcome"));
Assert.Equal(4.87, row.Dbl("final_price"));
Assert.Equal(9, row.Int("my_bids"));
Assert.Null(AuctionRecorder.For(auction.AuctionId));
var marco = Db.QueryOne("SELECT * FROM bidders WHERE username = 'marco_82'")!;
Assert.True(marco.Int("n_bids") >= 2);
Assert.True(marco.Int("n_manual") >= 2);
Assert.True(marco.Int("win_count") >= 1);
// Lo storico compatto vede la stessa riga.
var record = CompletedAuctionsStore.LoadAll().FirstOrDefault(r => r.AuctionId == auction.AuctionId);
Assert.NotNull(record);
Assert.Equal("Persa", record!.Outcome);
// E la scheda dettagliata pure.
var detail = AuctionDetailStore.Load(auction.AuctionId);
Assert.NotNull(detail);
Assert.Equal(2, detail!.DistinctBidders);
}
[Fact]
public void Le_decisioni_ricevono_lesito_alla_chiusura()
{
var auction = NewAuction();
var rec = AuctionRecorder.OpenFor(auction, Settings())!;
// Shadow: avrei puntato a 4.86 e nessuno ha più puntato -> avrei vinto.
var vincente = AuctionRecorder.RecordDecision(auction, State(4.86, 0.5), "BID", 0.4, 0.5, "ev positivo", "shadow", "Calmo", "ev-regime", 500);
// Shadow: non avrei puntato a 3.00 e poi il prezzo è salito -> giusto non puntare.
var giusta = AuctionRecorder.RecordDecision(auction, State(3.00, 0.5), "NO-OP", 0.01, -0.1, "ev negativo", "shadow", "Sfogo", "ev-regime", 500);
// Live: ho puntato a 4.85 (prezzo dopo la mia puntata 4.86) e nessuno ha rilanciato -> vinta.
var coperta = AuctionRecorder.RecordDecision(auction, State(4.85, 0.5), "BID", 0.4, 0.5, "ev positivo", "live", "Calmo", "ev-regime", 500);
rec.MyBid(4.85, 500, 480, 60, success: true, error: null, bidsUsed: 1, remainingBids: 10, rttMs: 80, decisionId: coperta);
var detail = Detail(auction);
detail.FinalPrice = 4.86;
AuctionRecorder.Close(auction, detail, State(4.86, 0));
Db.Flush();
Assert.Equal("would_win", Db.ScalarString("SELECT outcome FROM bot_decisions WHERE decision_id = ?1", vincente));
Assert.Equal("right_skip", Db.ScalarString("SELECT outcome FROM bot_decisions WHERE decision_id = ?1", giusta));
Assert.Equal("won", Db.ScalarString("SELECT outcome FROM bot_decisions WHERE decision_id = ?1", coperta));
Assert.Equal(1, Db.ScalarLong("SELECT executed FROM bot_decisions WHERE decision_id = ?1", coperta));
}
[Fact]
public void Labbandono_marca_la_riga_senza_riepilogo()
{
var auction = NewAuction();
AuctionRecorder.OpenFor(auction, Settings());
AuctionRecorder.Abandon(auction.AuctionId, "rimossa dal monitor");
Db.Flush();
var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1", auction.AuctionId)!;
Assert.Equal("abandoned", row.Str("status"));
Assert.Null(row.StrOrNull("outcome"));
Assert.Equal("rimossa dal monitor", row.Str("abandon_reason"));
// Non è nello storico delle concluse.
Assert.DoesNotContain(CompletedAuctionsStore.LoadAll(), r => r.AuctionId == auction.AuctionId);
}
[Fact]
public void Riaprire_la_stessa_asta_riusa_la_riga()
{
var auction = NewAuction();
var first = AuctionRecorder.OpenFor(auction, Settings())!;
first.Poll(State(1.00, 10), includeRaw: true);
AuctionRecorder.Abandon(auction.AuctionId, "simulazione riavvio");
var second = AuctionRecorder.OpenFor(auction, Settings())!;
second.Poll(State(1.01, 10), includeRaw: true);
Db.Flush();
Assert.Equal(1, Db.ScalarLong("SELECT COUNT(*) AS n FROM auctions WHERE auction_id = ?1", auction.AuctionId));
Assert.Equal(2, Db.ScalarLong("SELECT COUNT(*) AS n FROM polls WHERE auction_id = ?1", auction.AuctionId));
AuctionRecorder.Abandon(auction.AuctionId, "fine test");
}
[Fact]
public void Con_la_registrazione_spenta_non_si_apre_nulla()
{
var auction = NewAuction();
Assert.Null(AuctionRecorder.OpenFor(auction, new AppSettings { WriteAuctionDossiers = false }));
}
}
+9 -7
View File
@@ -18,6 +18,7 @@ namespace AutoBidder.Tests;
/// AppendMany deve dare lo stesso risultato di tanti Append, e la copia in memoria non deve
/// mai servire un contenuto che sul disco non c'e' piu'.</para>
/// </summary>
[Collection("Database")]
public class CompletedAuctionsStoreTests
{
private static CompletedAuctionRecord R(string id, double price = 1.00, string? nome = null) =>
@@ -102,18 +103,19 @@ public class CompletedAuctionsStoreTests
}
[Fact]
public void La_copia_in_memoria_si_accorge_se_il_file_cambia_da_fuori()
public void La_copia_in_memoria_si_accorge_delle_scritture_fatte_da_altri()
{
Svuota();
CompletedAuctionsStore.Append(R("uno"));
Assert.Single(CompletedAuctionsStore.LoadAll());
// Ripristino di un backup o modifica a mano: la copia non deve continuare a
// servire il contenuto vecchio. La data si forza per non dipendere dalla
// granularita' con cui il file system aggiorna l'ora di scrittura.
File.WriteAllText(CompletedAuctionsStore.StorePath,
JsonSerializer.Serialize(new List<CompletedAuctionRecord> { R("due"), R("tre") }));
File.SetLastWriteTimeUtc(CompletedAuctionsStore.StorePath, DateTime.UtcNow.AddSeconds(5));
// Una scrittura che non passa da questo archivio (il registratore, una
// riparazione): la copia non deve continuare a servire il contenuto vecchio.
var db = AutoBidder.Data.AuctionDatabase.Instance;
db.ExecuteNow("DELETE FROM auctions WHERE auction_id = 'uno'");
var now = AutoBidder.Data.AuctionDatabase.Now();
db.ExecuteNow("INSERT INTO auctions(auction_id, name, added_at, first_seen_at, opened_at, end_time, outcome, status) VALUES('due', 'Asta due', ?1, ?1, ?1, ?1, 'Persa', 'closed')", now);
db.ExecuteNow("INSERT INTO auctions(auction_id, name, added_at, first_seen_at, opened_at, end_time, outcome, status) VALUES('tre', 'Asta tre', ?1, ?1, ?1, ?1, 'Persa', 'closed')", now);
var dopo = CompletedAuctionsStore.LoadAll();
-102
View File
@@ -1,102 +0,0 @@
using System;
using System.IO;
using System.Linq;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// La scheda dettagliata di un'asta si legge dal dossier: intestazione in testa e
/// riepilogo in coda, senza scorrere il resto. Un dossier senza riepilogo non e' una
/// scheda: e' un'asta ancora in corso o interrotta, e non deve entrare nelle statistiche.
/// </summary>
public class DossierSummaryTests
{
private const string Header =
"""{"type":"header","schema":"autobidder.auction.v1","auctionId":"12345","me":"io","name":"Buono 10","productKey":"buono 10","url":"https://it.bidoo.com/x","addedAt":"2026-09-01T10:00:00+02:00","product":{"buyNowPrice":11,"bidCostEuro":0.2}}""";
private const string Summary =
"""{"type":"summary","closedAt":"10:30:00.000","endedAt":"2026-09-01T10:30:00+02:00","outcome":"Vinta","wonByMe":true,"winner":"io","finalPrice":0.42,"coverage":{"observedFromStart":true,"observedToEnd":true,"complete":true,"firstSeenAt":"2026-09-01T10:00:00+02:00","observedMinutes":30.5},"participation":{"myBids":3,"totalObservedBids":42,"distinctBidders":4,"resets":41,"topBidderShare":0.5,"bidsByUser":{"io":3,"tizio":21,"caio":18}},"value":{"buyNowPrice":11,"shippingCost":1.5,"bidCostEuro":0.2},"network":{"avgPingMs":61,"polls":1200,"pollErrors":2},"engine":{"configuredLeadMs":500,"timerExpiredCount":1,"successfulBids":3,"failedBids":0},"priceSeries":[{"t":0,"price":0.01},{"t":1800,"price":0.42}],"priceVelocityPerMinute":0.0138}""";
private static string Temp(params string[] lines)
{
var path = Path.Combine(Path.GetTempPath(), $"dossier-{Guid.NewGuid():N}.jsonl");
File.WriteAllText(path, string.Join("\n", lines) + "\n");
return path;
}
[Fact]
public void Ricompone_la_scheda_da_intestazione_e_riepilogo()
{
var path = Temp(Header, """{"t":1,"type":"poll","price":0.01}""", Summary);
try
{
var r = AuctionDetailStore.ReadDossier(path);
Assert.NotNull(r);
Assert.Equal("12345", r!.AuctionId);
Assert.Equal("Buono 10", r.Name);
Assert.Equal("buono 10", r.ProductKey);
Assert.True(r.WonByMe);
Assert.Equal(0.42, r.FinalPrice, 3);
Assert.Equal(11, r.BuyNowPrice);
Assert.Equal(1.5, r.ShippingCost);
Assert.Equal(3, r.MyBids);
Assert.Equal(42, r.TotalObservedBids);
Assert.Equal(4, r.DistinctBidders);
Assert.Equal(41, r.Resets);
Assert.Equal(21, r.BidsByUser["tizio"]);
Assert.True(r.IsComplete);
Assert.Equal(30.5, r.ObservedMinutes, 2);
Assert.Equal(61, r.AveragePingMs);
Assert.Equal(1200, r.PollCount);
Assert.Equal(500, r.ConfiguredLeadMs);
Assert.Equal(2, r.PriceSeries.Count);
Assert.Equal(path, r.DossierPath);
Assert.Equal(2026, r.EndedAt.Year);
Assert.Equal(9, r.EndedAt.Month);
}
finally { File.Delete(path); }
}
[Fact]
public void Un_dossier_senza_riepilogo_non_e_una_scheda()
{
var path = Temp(Header, """{"t":1,"type":"poll","price":0.01}""", """{"t":2,"type":"reset","price":0.02}""");
try
{
Assert.Null(AuctionDetailStore.ReadDossier(path));
}
finally { File.Delete(path); }
}
[Fact]
public void Legge_solo_la_coda_anche_su_un_file_grande()
{
// Un dossier vero pesa decine di megabyte: qui un megabyte di poll finti in mezzo.
var filler = new string[20_000];
for (var i = 0; i < filler.Length; i++)
filler[i] = $$$"""{"t":{{{i}}},"type":"poll","price":0.01,"timer":9.5,"bidder":"tizio","status":"Running"}""";
var path = Temp(new[] { Header }.Concat(filler).Append(Summary).ToArray());
try
{
var r = AuctionDetailStore.ReadDossier(path);
Assert.NotNull(r);
Assert.Equal(42, r!.TotalObservedBids);
}
finally { File.Delete(path); }
}
[Fact]
public void Un_file_che_non_e_un_dossier_da_null_senza_eccezioni()
{
var path = Temp("questo non e' json", "{}");
try
{
Assert.Null(AuctionDetailStore.ReadDossier(path));
}
finally { File.Delete(path); }
}
}
+8 -7
View File
@@ -12,8 +12,8 @@ namespace AutoBidder.Tests;
/// variabili d'ambiente e la condizione minima.
///
/// <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_DB</c> — file del database SQLite;</item>
/// <item><c>AUTOBIDDER_ML_MAX</c> — quante aste al massimo (0 = tutte);</item>
/// <item><c>AUTOBIDDER_ML_OUT</c> — dove scrivere il rapporto;</item>
/// <item><c>AUTOBIDDER_ML_ME</c> — il nostro nome utente, per i dossier vecchi.</item>
/// </list>
@@ -25,20 +25,21 @@ public class MlModelBacktest
public MlModelBacktest(ITestOutputHelper output) => _output = output;
[Fact]
public void Valuta_il_modello_sui_dossier_reali()
public void Valuta_il_modello_sulle_aste_reali()
{
var folder = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_DIR");
var dbPath = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_DB");
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
if (string.IsNullOrWhiteSpace(dbPath) || !File.Exists(dbPath))
{
_output.WriteLine("AUTOBIDDER_ML_DIR non impostata o inesistente: valutazione saltata.");
_output.WriteLine("AUTOBIDDER_ML_DB non impostata o inesistente: valutazione saltata.");
return;
}
var max = int.TryParse(Environment.GetEnvironmentVariable("AUTOBIDDER_ML_MAX"), out var m) ? m : 0;
var me = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_ME");
var report = LearningEvaluator.Run(folder, max, me, _output.WriteLine);
using var db = AutoBidder.Data.AuctionDatabase.Open(dbPath);
var report = LearningEvaluator.Run(db, max, me, _output.WriteLine);
_output.WriteLine(report.Text);
var outFile = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_OUT");
+225
View File
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Ml;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// La rete neurale impara una funzione che un modello lineare non può (XOR), regredisce
/// una funzione continua, si salva e si ricarica identica.
/// </summary>
public class NeuralNetTests
{
[Fact]
public void Impara_lo_xor()
{
var net = new NeuralNet(new[] { 2, 8, 1 }, seed: 3) { LearningRate = 0.02 };
var data = new[]
{
(new[] { 0.0, 0.0 }, false), (new[] { 0.0, 1.0 }, true),
(new[] { 1.0, 0.0 }, true), (new[] { 1.0, 1.0 }, false)
};
var rng = new Random(1);
for (var i = 0; i < 6000; i++)
{
var (x, y) = data[rng.Next(4)];
net.Update(x, y);
}
Assert.True(net.Predict(new[] { 0.0, 0.0 }) < 0.2);
Assert.True(net.Predict(new[] { 0.0, 1.0 }) > 0.8);
Assert.True(net.Predict(new[] { 1.0, 0.0 }) > 0.8);
Assert.True(net.Predict(new[] { 1.0, 1.0 }) < 0.2);
}
[Fact]
public void Regredisce_una_funzione_continua()
{
var net = new NeuralNet(new[] { 1, 16, 1 }, seed: 5) { LearningRate = 0.005 };
var rng = new Random(2);
for (var i = 0; i < 8000; i++)
{
var x = rng.NextDouble() * 2 - 1;
net.UpdateRegression(new[] { x }, new[] { x * x });
}
var err = 0.0;
for (var x = -1.0; x <= 1.0; x += 0.1) err += Math.Abs(net.Forward(new[] { x })[0] - x * x);
Assert.True(err / 21 < 0.05, $"errore medio {err / 21:F3}");
}
[Fact]
public void Si_salva_e_si_ricarica_identica()
{
var net = new NeuralNet(new[] { 3, 4, 1 }, seed: 9);
for (var i = 0; i < 100; i++) net.Update(new[] { 0.1 * i, 0.5, 1.0 }, i % 3 == 0);
var back = NeuralNet.FromJson(net.ToJson(), new[] { 3, 4, 1 });
Assert.Equal(net.Updates, back.Updates);
Assert.Equal(net.Predict(new[] { 0.3, 0.5, 1.0 }), back.Predict(new[] { 0.3, 0.5, 1.0 }), 12);
}
[Fact]
public void Una_forma_diversa_riparte_da_capo_senza_eccezioni()
{
var net = new NeuralNet(new[] { 3, 4, 1 });
var back = NeuralNet.FromJson(net.ToJson(), new[] { 5, 4, 1 });
Assert.Equal(0, back.Updates);
Assert.Equal(5, back.InputSize);
}
[Fact]
public void Aggiornare_una_sola_uscita_non_tocca_le_altre_in_modo_diretto()
{
var net = new NeuralNet(new[] { 2, 4, 2 }, seed: 11) { LearningRate = 0.01 };
var x = new[] { 0.5, -0.5 };
for (var i = 0; i < 500; i++) net.UpdateOutput(x, 1, 3.0);
var q = net.Forward(x);
Assert.True(Math.Abs(q[1] - 3.0) < 0.3, $"q1 = {q[1]:F2}");
}
}
/// <summary>
/// Il bandit: parte dal prior, converge all'evidenza, esplora dove ha visto poco, e
/// decide con la regola del valore atteso sul p estratto.
/// </summary>
public class ThompsonBanditTests
{
private static BidContext Ctx(double gap = 5, double ratio = 0.05, int hour = 12, int bidders = 2) =>
new(gap, 0.3, bidders, ratio, hour, DayOfWeek.Monday, 10, false, 1, 50, false, "p");
[Fact]
public void Senza_osservazioni_la_media_e_il_prior()
{
var b = new ThompsonBandit { BaseRate = 0.05 };
Assert.Equal(0.05, b.Mean("qualunque"), 6);
Assert.Equal(0, b.Evidence("qualunque"), 6);
}
[Fact]
public void Con_le_osservazioni_la_media_si_sposta_verso_la_frequenza()
{
var b = new ThompsonBandit { BaseRate = 0.05, PriorStrength = 4 };
var key = ThompsonBandit.KeyOf(Ctx());
for (var i = 0; i < 100; i++) b.Observe(key, i % 2 == 0);
Assert.True(Math.Abs(b.Mean(key) - 0.5) < 0.05);
Assert.Equal(100, b.Evidence(key), 6);
}
[Fact]
public void Lestrazione_varia_dove_si_e_visto_poco_e_si_stringe_con_i_dati()
{
var b = new ThompsonBandit { BaseRate = 0.3, PriorStrength = 2 };
var rng = new Random(4);
var poco = Enumerable.Range(0, 400).Select(_ => b.Sample("poco", rng)).ToList();
for (var i = 0; i < 2000; i++) b.Observe("tanto", i % 10 < 3);
var tanto = Enumerable.Range(0, 400).Select(_ => b.Sample("tanto", rng)).ToList();
Assert.True(Std(poco) > 3 * Std(tanto), $"poco {Std(poco):F3} vs tanto {Std(tanto):F3}");
Assert.True(Math.Abs(tanto.Average() - 0.3) < 0.03);
}
[Fact]
public void Decide_con_il_valore_atteso_sul_p_estratto()
{
var b = new ThompsonBandit { BaseRate = 0.5, PriorStrength = 1000 }; // praticamente fisso a 0,5
var rng = new Random(1);
var conviene = new Theory.BidSituation(0, 11, 0.50, 0.20, 1.0, 0, null, 0, false);
var (bid, p) = b.Decide("k", conviene, rng);
Assert.True(bid);
Assert.True(Math.Abs(p - 0.5) < 0.1);
var nonConviene = conviene with { Value = 1.0, CurrentPrice = 0.95 };
Assert.False(b.Decide("k", nonConviene, rng).Bid);
}
[Fact]
public void Si_salva_e_si_ricarica()
{
var b = new ThompsonBandit { BaseRate = 0.1 };
for (var i = 0; i < 20; i++) b.Observe("k", i % 4 == 0);
var back = ThompsonBandit.FromJson(b.ToJson());
Assert.Equal(b.Mean("k"), back.Mean("k"), 9);
Assert.Equal(20, back.Observations);
}
private static double Std(List<double> xs)
{
var m = xs.Average();
return Math.Sqrt(xs.Average(x => (x - m) * (x - m)));
}
}
/// <summary>
/// Il simulatore produce aste con la forma giusta — finiscono, il prezzo in centesimi è
/// il numero di puntate, chi non punta mai non vince mai, chi punta sempre spende — e
/// l'agente Q impara a non buttare via soldi.
/// </summary>
public class SimulatorTests
{
private static PennyAuctionEnv.Calibration Cal() => new() { Value = 11, BidCost = 0.20, MeanBidders = 3, MedianFinalPriceRatio = 0.05 };
[Fact]
public void Le_aste_finiscono_e_il_prezzo_conta_le_puntate()
{
var env = new PennyAuctionEnv(Cal(), seed: 3);
var obs = env.Reset();
var steps = 0;
PennyAuctionEnv.StepResult r;
do { r = env.Step(null); steps++; } while (!r.Done && steps < 5000);
Assert.True(r.Done);
Assert.False(r.Won);
Assert.Equal(r.Next.TotalBids, (int)Math.Round(r.Next.Price * 100));
}
[Fact]
public void Chi_non_punta_mai_non_vince_mai_e_non_spende()
{
var mai = QLearningAgent.Evaluate("mai", Cal(), (in PennyAuctionEnv.Observation _) => null, 200);
Assert.Equal(0, mai.Wins);
Assert.Equal(0, mai.Spent);
}
[Fact]
public void Chi_punta_sempre_vince_qualcosa_ma_spende_molto()
{
var sempre = QLearningAgent.Evaluate("sempre", Cal(), (in PennyAuctionEnv.Observation o) => o.ImLeading ? null : 0.5, 200);
Assert.True(sempre.Wins > 0);
Assert.True(sempre.Bids > sempre.Wins);
Assert.True(sempre.Spent > 0);
}
[Fact]
public void Una_regola_prudente_spende_meno_di_chi_punta_sempre()
{
// Punta solo quando gli avversari attivi sono pochi e il prezzo è basso.
var prudente = QLearningAgent.Evaluate("prudente", Cal(),
(in PennyAuctionEnv.Observation o) => !o.ImLeading && o.ActiveOpponents <= 1 && o.PriceRatio < 0.3 ? 0.5 : null, 200);
var sempre = QLearningAgent.Evaluate("sempre", Cal(), (in PennyAuctionEnv.Observation o) => o.ImLeading ? null : 0.5, 200);
Assert.True(prudente.Spent < sempre.Spent);
}
[Fact]
public void Lagente_q_impara_a_fare_meglio_del_caso()
{
var cal = Cal();
var agent = new QLearningAgent(seed: 5);
for (var i = 0; i < 600; i++) agent.TrainEpisode(new PennyAuctionEnv(cal, 100 + i), 0.5);
var q = QLearningAgent.Evaluate("q", cal, (in PennyAuctionEnv.Observation o) => agent.Act(o) == 1 ? 0.5 : null, 300);
var caso = QLearningAgent.Evaluate("caso", cal, (in PennyAuctionEnv.Observation o) =>
new Random(o.TotalBids * 31 + o.MyBids).NextDouble() < 0.5 ? 0.5 : null, 300);
Assert.True(q.Net > caso.Net, $"q {q.Net:F2} vs caso {caso.Net:F2}");
Assert.True(agent.Episodes == 600);
}
}
+6 -5
View File
@@ -18,7 +18,7 @@ namespace AutoBidder.Tests;
///
/// <para>Si lancia con <c>backtest.ps1</c>, che imposta le variabili qui sotto:</para>
/// <list type="bullet">
/// <item><c>AUTOBIDDER_BACKTEST_DIR</c> — cartella dei dossier;</item>
/// <item><c>AUTOBIDDER_BACKTEST_DB</c> — file del database SQLite;</item>
/// <item><c>AUTOBIDDER_BACKTEST_MAX</c> — quanti file al massimo (0 = tutti);</item>
/// <item><c>AUTOBIDDER_BACKTEST_OUT</c> — dove scrivere il riepilogo.</item>
/// </list>
@@ -39,11 +39,11 @@ public class RealDossierBacktest
[Fact]
public void Rigioca_i_dossier_reali()
{
var folder = Environment.GetEnvironmentVariable("AUTOBIDDER_BACKTEST_DIR");
var dbPath = Environment.GetEnvironmentVariable("AUTOBIDDER_BACKTEST_DB");
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
if (string.IsNullOrWhiteSpace(dbPath) || !File.Exists(dbPath))
{
_output.WriteLine("AUTOBIDDER_BACKTEST_DIR non impostata o inesistente: rigiocata saltata.");
_output.WriteLine("AUTOBIDDER_BACKTEST_DB non impostata o inesistente: rigiocata saltata.");
return;
}
@@ -53,7 +53,8 @@ public class RealDossierBacktest
// vuole mettere alla prova, non una configurazione inventata per l'occasione.
var settings = new AppSettings();
var aggregates = BacktestReport.Run(folder, Leads, settings, username: "", maxFiles: max);
using var db = AutoBidder.Data.AuctionDatabase.Open(dbPath);
var aggregates = BacktestReport.Run(db, Leads, settings, username: "", maxAuctions: max);
var text = BacktestReport.Format(aggregates);
_output.WriteLine(text);
-53
View File
@@ -1,53 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using AutoBidder.Utilities;
using Xunit;
using Xunit.Abstractions;
namespace AutoBidder.Tests;
/// <summary>
/// Legge le schede da una cartella vera di dossier, se indicata con
/// AUTOBIDDER_DOSSIER_DIR. Serve a verificare due promesse: che il lettore ricomponga
/// le schede da migliaia di file in pochi secondi (legge solo testa e coda), e che
/// la seconda lettura, dalla cache, sia istantanea.
/// </summary>
public class RealDossierSummaries
{
private readonly ITestOutputHelper _output;
public RealDossierSummaries(ITestOutputHelper output) => _output = output;
[Fact]
public void Legge_le_schede_dai_dossier_veri()
{
var folder = Environment.GetEnvironmentVariable("AUTOBIDDER_DOSSIER_DIR");
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
{
_output.WriteLine("AUTOBIDDER_DOSSIER_DIR non impostata: prova saltata.");
return;
}
var files = Directory.GetFiles(folder, "*.jsonl");
var sw = Stopwatch.StartNew();
var records = files
.Select(AuctionDetailStore.ReadDossier)
.Where(r => r != null)
.Select(r => r!)
.ToList();
sw.Stop();
_output.WriteLine($"{files.Length} dossier, {records.Count} con riepilogo, letti in {sw.Elapsed.TotalSeconds:F2} s");
_output.WriteLine($"vinte da me: {records.Count(r => r.WonByMe)}, con mie puntate: {records.Count(r => r.MyBids > 0)}, complete: {records.Count(r => r.IsComplete)}");
_output.WriteLine($"prima chiusura: {records.Min(r => r.EndedAt):yyyy-MM-dd}, ultima: {records.Max(r => r.EndedAt):yyyy-MM-dd}");
_output.WriteLine($"senza prodotto: {records.Count(r => string.IsNullOrEmpty(r.ProductKey))}, senza valore: {records.Count(r => r.BuyNowPrice is null or 0)}, senza serie prezzi: {records.Count(r => r.PriceSeries.Count == 0)}");
Assert.True(records.Count > 0);
Assert.All(records, r => Assert.False(string.IsNullOrEmpty(r.AuctionId)));
Assert.True(sw.Elapsed.TotalSeconds < 60, "la lettura di testa e coda non deve scorrere i file");
}
}
+138
View File
@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutoBidder.Data;
using AutoBidder.Models;
using AutoBidder.Services;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il gestore del rischio: il kill-switch ferma tutto, lo stop-loss porta in HALT e
/// l'HALT resta finché non lo si toglie a mano, il tetto del giorno conta le puntate
/// del giorno, le aste in gioco insieme si contano davvero.
/// </summary>
[Collection("Database")]
public class RiskManagerTests
{
private static AuctionInfo Auction(string id, RunState state = RunState.Active, int bids = 0) => new()
{
AuctionId = id, Name = "Prova", State = state, SessionBidCount = bids, BidCost = 0.20
};
private static void Pulito()
{
RiskManager.SetKillSwitch(false);
AuctionDatabase.Instance.ExecuteNow("DELETE FROM risk_ledger");
AuctionDatabase.Instance.ExecuteNow("DELETE FROM meta WHERE key = 'risk_halt'");
RiskManager.ResetForTests();
}
[Fact]
public void Il_kill_switch_ferma_ogni_puntata()
{
Pulito();
RiskManager.SetKillSwitch(true);
try
{
var v = RiskManager.Authorize(Auction("a"), new AppSettings());
Assert.False(v.Allowed);
Assert.Contains("kill-switch", v.Reason);
Assert.True(File.Exists(RiskManager.KillSwitchFile));
}
finally { RiskManager.SetKillSwitch(false); }
Assert.True(RiskManager.Authorize(Auction("a"), new AppSettings()).Allowed);
}
[Fact]
public void Lo_stop_loss_porta_in_halt_e_l_halt_resta()
{
Pulito();
var settings = new AppSettings { DailyStopLossEuro = 1.00 };
for (var i = 0; i < 4; i++) RiskManager.NoteBidSpent("a", 0.20);
Assert.True(RiskManager.Authorize(Auction("a"), settings).Allowed); // 0,80 sotto la soglia
// Con 1,00 € spesi la perdita netta eguaglia la soglia: scatta.
RiskManager.NoteBidSpent("a", 0.20);
var v = RiskManager.Authorize(Auction("a"), settings);
Assert.False(v.Allowed);
Assert.True(RiskManager.Halted);
// Anche senza limite impostato, l'HALT resta.
Assert.False(RiskManager.Authorize(Auction("a"), new AppSettings()).Allowed);
// Sopravvive a un «riavvio» della memoria: è nel database.
AuctionDatabase.Instance.Flush();
RiskManager.ResetForTests();
Assert.True(RiskManager.Halted);
RiskManager.Resume();
Assert.False(RiskManager.Halted);
Assert.True(RiskManager.Authorize(Auction("a"), new AppSettings()).Allowed);
}
[Fact]
public void Una_vincita_riduce_la_perdita_netta_del_giorno()
{
Pulito();
var settings = new AppSettings { DailyStopLossEuro = 1.00 };
for (var i = 0; i < 5; i++) RiskManager.NoteBidSpent("a", 0.20);
RiskManager.NoteWin("a", 3.00, "vinta");
Assert.True(RiskManager.Authorize(Auction("a"), settings).Allowed);
var s = RiskManager.Snapshot();
Assert.Equal(1.00, s.TodaySpent, 6);
Assert.Equal(3.00, s.TodayGained, 6);
Assert.Equal(2.00, s.Cumulative, 6);
}
[Fact]
public void Il_drawdown_si_misura_dal_picco()
{
Pulito();
var settings = new AppSettings { MaxDrawdownEuro = 0.50 };
RiskManager.NoteWin("a", 5.00, "vinta"); // picco 5.00
for (var i = 0; i < 2; i++) RiskManager.NoteBidSpent("b", 0.20); // 4.60: drawdown 0.40
Assert.True(RiskManager.Authorize(Auction("b"), settings).Allowed);
RiskManager.NoteBidSpent("b", 0.20); // 4.40: drawdown 0.60
Assert.False(RiskManager.Authorize(Auction("b"), settings).Allowed);
Assert.True(RiskManager.Halted);
RiskManager.Resume();
}
[Fact]
public void Il_tetto_del_giorno_conta_la_puntata_che_sta_per_partire()
{
Pulito();
var settings = new AppSettings { DailyBudgetEuro = 0.50 };
RiskManager.NoteBidSpent("a", 0.20);
RiskManager.NoteBidSpent("a", 0.20);
Assert.False(RiskManager.Authorize(Auction("a"), settings).Allowed); // 0.40 + 0.20 > 0.50
Assert.False(RiskManager.Halted); // il tetto non e' un HALT: domani si riparte
}
[Fact]
public void Le_aste_in_gioco_insieme_hanno_un_tetto()
{
Pulito();
var settings = new AppSettings { MaxConcurrentActiveAuctions = 2 };
var tutte = new List<AuctionInfo>
{
Auction("a", bids: 3), Auction("b", bids: 1), Auction("c"), Auction("d", RunState.Watch, bids: 9)
};
// c non ha ancora puntato e ce ne sono già due in gioco: no.
Assert.False(RiskManager.Authorize(tutte[2], settings, tutte).Allowed);
// a è già in gioco: continua.
Assert.True(RiskManager.Authorize(tutte[0], settings, tutte).Allowed);
}
}
+159
View File
@@ -0,0 +1,159 @@
using System;
using System.IO;
using System.Linq;
using AutoBidder.Data;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il wrapper su winsqlite3 e il database: si apre, si scrive in coda, si rilegge dopo
/// Flush, i blocchi sono atomici, le date tornano in UTC, e la copia con VACUUM INTO
/// produce un archivio leggibile.
/// </summary>
public class SqliteTests
{
private static string TempDb() =>
Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", $"db-{Guid.NewGuid():N}.sqlite");
[Fact]
public void La_libreria_di_sistema_risponde()
{
var v = SqliteConnection.LibraryVersion;
Assert.Matches(@"^\d+\.\d+", v);
}
[Fact]
public void Scrive_in_coda_e_rilegge_dopo_flush()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "io");
db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "tu");
db.Flush();
Assert.Equal(2, db.Count("sessions"));
var rows = db.Query("SELECT username FROM sessions ORDER BY session_id");
Assert.Equal(new[] { "io", "tu" }, rows.Select(r => r.Str("username")).ToArray());
Assert.Equal(0, db.WritesFailed);
}
[Fact]
public void Un_blocco_e_tutto_o_niente()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
db.EnqueueBatch(new (string, object?[])[]
{
("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", new object?[] { AuctionDatabase.Now(), "a" }),
("INSERT INTO tabella_che_non_esiste(x) VALUES(1)", Array.Empty<object?>()),
("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", new object?[] { AuctionDatabase.Now(), "b" })
});
db.Flush();
Assert.Equal(0, db.Count("sessions"));
Assert.True(db.WritesFailed > 0);
}
[Fact]
public void Le_date_tornano_in_utc()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
var local = new DateTime(2026, 9, 8, 10, 30, 0, DateTimeKind.Local);
db.ExecuteNow("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", local, "x");
var row = db.QueryOne("SELECT started_at FROM sessions")!;
var back = row.Date("started_at")!.Value;
Assert.Equal(DateTimeKind.Utc, back.Kind);
Assert.Equal(local.ToUniversalTime(), back);
}
[Fact]
public void I_tipi_si_leggono_con_tolleranza()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
db.ExecuteNow("INSERT INTO items(product_key, title, retail_price_bidoo, first_seen_at, updated_at) VALUES('k', 'T', 11.5, ?1, ?1)", AuctionDatabase.Now());
var row = db.QueryOne("SELECT * FROM items")!;
Assert.Equal("k", row.Str("product_key"));
Assert.Equal(11.5, row.Dbl("retail_price_bidoo"));
Assert.Null(row.DblOrNull("market_price_est"));
Assert.Equal(1, row.Long("item_id"));
Assert.True(row.Has("shipping"));
Assert.False(row.Has("colonna_inventata"));
}
[Fact]
public void La_cancellazione_di_unasta_trascina_le_sue_righe()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
var now = AuctionDatabase.Now();
db.Enqueue("INSERT INTO auctions(auction_id, added_at, first_seen_at, opened_at) VALUES('a1', ?1, ?1, ?1)", now);
db.Enqueue("INSERT INTO bids(auction_id, username, price_after) VALUES('a1', 'tizio', 0.01)");
db.Enqueue("INSERT INTO polls(auction_id, t, price) VALUES('a1', 0, 0.01)");
db.ExecuteNow("DELETE FROM auctions WHERE auction_id = 'a1'");
Assert.Equal(0, db.Count("bids"));
Assert.Equal(0, db.Count("polls"));
}
[Fact]
public void Una_scrittura_sbagliata_non_ferma_le_altre()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "prima");
db.Enqueue("INSERT INTO nessuna_tabella(x) VALUES(1)");
db.Enqueue("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "dopo");
db.Flush();
Assert.Equal(2, db.Count("sessions"));
Assert.Equal(1, db.WritesFailed);
}
[Fact]
public void La_copia_di_sicurezza_e_un_archivio_leggibile()
{
var path = TempDb();
var backup = path + ".copia.sqlite";
using (var db = AuctionDatabase.Open(path))
{
db.ExecuteNow("INSERT INTO sessions(started_at, username) VALUES(?1, ?2)", AuctionDatabase.Now(), "io");
db.BackupTo(backup);
}
Assert.True(File.Exists(backup));
using var copy = AuctionDatabase.Open(backup);
Assert.Equal(1, copy.Count("sessions"));
}
[Fact]
public void Lo_svuotamento_lascia_prodotti_e_sessioni()
{
var path = TempDb();
using var db = AuctionDatabase.Open(path);
var now = AuctionDatabase.Now();
db.Enqueue("INSERT INTO items(product_key, title, first_seen_at, updated_at) VALUES('k', 'T', ?1, ?1)", now);
db.Enqueue("INSERT INTO auctions(auction_id, added_at, first_seen_at, opened_at) VALUES('a1', ?1, ?1, ?1)", now);
db.Enqueue("INSERT INTO sessions(started_at) VALUES(?1)", now);
db.Flush();
db.WipeObservations();
Assert.Equal(0, db.Count("auctions"));
Assert.Equal(1, db.Count("items"));
Assert.Equal(1, db.Count("sessions"));
}
}
+2 -1
View File
@@ -9,6 +9,7 @@ namespace AutoBidder.Tests;
/// L'azzeramento tocca solo le voci scelte, e riconosce gli archivi mensili delle
/// versioni precedenti dal nome senza confonderli con il resto della cartella.
/// </summary>
[Collection("Database")]
public class StatsWipeTests
{
[Fact]
@@ -41,7 +42,7 @@ public class StatsWipeTests
Assert.True(File.Exists(altro));
Assert.Null(report.BackupPath);
Assert.Empty(report.Errors);
Assert.Contains(report.Steps, s => s.StartsWith("archivi mensili"));
Assert.Contains(report.Steps, s => s.StartsWith("archivi delle versioni precedenti"));
}
finally
{
+2 -1
View File
@@ -26,7 +26,8 @@ internal static class TestEnvironment
AppPaths.Configure(
dataFolder: Path.Combine(sandbox, "Dati"),
statsFolder: Path.Combine(sandbox, "Statistiche"));
statsFolder: Path.Combine(sandbox, "Statistiche"),
databaseFile: Path.Combine(sandbox, "autobidder.sqlite"));
AppPaths.EnsureFolders();
}
+127
View File
@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Ml;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Le formule del modello matematico devono dare i numeri che si calcolano a mano,
/// e la copertura «Compralo Ora» deve azzerare il costo della sconfitta solo quando
/// le puntate spese superano la perdita coperta.
/// </summary>
public class TheoryTests
{
private static Theory.BidSituation S(double p, double value = 11, double price = 0.50, double c = 0.20,
double fee = 1.0, double ship = 0, double? list = null, double spent = 0, bool hedge = false) =>
new(p, value, price, c, fee, ship, list, spent, hedge);
[Fact]
public void Senza_copertura_la_puntata_si_paga_in_entrambi_i_rami()
{
// P=0.5: vinco 11 0.51 1 0.20 = 9.29; perdo 0.20.
var ev = Theory.BidExpectedValue(S(0.5));
Assert.Equal(0.5 * 9.29 - 0.5 * 0.20, ev, 6);
}
[Fact]
public void Con_p_zero_si_perde_esattamente_il_costo_della_puntata()
{
Assert.Equal(-0.20, Theory.BidExpectedValue(S(0)), 6);
}
[Fact]
public void Il_pareggio_e_dove_il_valore_atteso_e_zero()
{
var s = S(0.123);
var pStar = Theory.BreakEvenPWin(s);
var atBreakEven = s with { PWin = pStar };
Assert.Equal(0, Theory.BidExpectedValue(atBreakEven), 6);
}
[Fact]
public void Se_non_conviene_neanche_vincendo_il_pareggio_e_uno()
{
var s = S(0.9, value: 1.0, price: 0.90, fee: 1.0);
Assert.Equal(1, Theory.BreakEvenPWin(s));
Assert.False(Theory.ShouldBid(s));
}
[Fact]
public void La_copertura_azzera_il_costo_di_sconfitta_solo_oltre_la_perdita_coperta()
{
// Listino 11, valore reale 8.80: perdita coperta 2.20 = 11 puntate da 0.20.
var prima = S(0.1, value: 8.80, list: 11, spent: 1.00, hedge: true);
Assert.Equal(0.20, Theory.LossBranchCost(prima));
var dopo = S(0.1, value: 8.80, list: 11, spent: 2.20, hedge: true);
Assert.Equal(0, Theory.LossBranchCost(dopo));
}
[Fact]
public void Per_un_buono_al_nominale_la_copertura_e_totale_dal_primo_centesimo()
{
var s = S(0.01, value: 11, list: 11, spent: 0, hedge: true);
Assert.Equal(0, Theory.LossBranchCost(s));
Assert.True(Theory.BidExpectedValue(s) > 0);
}
[Fact]
public void Con_la_copertura_spenta_il_listino_non_conta()
{
var s = S(0.1, value: 8.80, list: 11, spent: 5, hedge: false);
Assert.Equal(0.20, Theory.LossBranchCost(s));
}
[Fact]
public void Il_numero_critico_di_puntatori_e_valore_su_costo()
{
Assert.Equal(55, Theory.CriticalBidders(11, 0.20), 6);
}
[Fact]
public void La_probabilita_di_vittoria_a_un_anticipo_e_la_frequenza_dei_cicli_sopravvissuti()
{
var cycles = new List<Theory.CycleObservation>();
// 30 cicli fermati a 5 s (puntate presto), 15 scesi fino a 1 s, 5 che hanno chiuso l'asta.
for (var i = 0; i < 30; i++) cycles.Add(new(5.0, false));
for (var i = 0; i < 15; i++) cycles.Add(new(1.0, false));
for (var i = 0; i < 5; i++) cycles.Add(new(0, true));
// A 2 s di anticipo: raggiunto da 15 + 5 = 20 cicli, sopravvissuti 5.
Assert.Equal(0.25, Theory.WinProbabilityAtLead(cycles, 2.0)!.Value, 6);
// A 6 s: raggiunto da tutti e 50, sopravvissuti 5.
Assert.Equal(0.10, Theory.WinProbabilityAtLead(cycles, 6.0)!.Value, 6);
// Con pochi cicli non si dice nulla.
Assert.Null(Theory.WinProbabilityAtLead(cycles.Take(10), 2.0));
}
[Fact]
public void Kaplan_meier_scende_a_ogni_puntata_e_non_alle_censure()
{
var cycles = new[]
{
new Theory.CycleObservation(8, false),
new Theory.CycleObservation(8, false),
new Theory.CycleObservation(3, false),
new Theory.CycleObservation(0, true)
};
var km = Theory.KaplanMeier(cycles);
Assert.Equal(2, km.Count);
Assert.Equal(8, km[0].TimerSeconds);
Assert.Equal(0.5, km[0].Survival, 6); // 2 su 4 puntano a 8 s
Assert.Equal(0.25, km[1].Survival, 6); // poi 1 su 2 a 3 s
}
[Fact]
public void Poisson_da_la_probabilita_che_nessuno_punti()
{
Assert.Equal(Math.Exp(-0.5 * 2), Theory.NoBidProbability(0.5, 2), 9);
Assert.Equal(1, Theory.NoBidProbability(0, 2));
Assert.Equal(0.25, Theory.ArrivalRate(new[] { 4.0, 4.0, 4.0 })!.Value, 6);
Assert.Null(Theory.ArrivalRate(Array.Empty<double>()));
}
}
+31 -2
View File
@@ -123,6 +123,17 @@ namespace AutoBidder.Utilities
/// <summary>Profilo del browser integrato (cache WebView2).</summary>
public static string BrowserProfile => Path.Combine(Root, "WebView2");
/// <summary>Percorso del database scelto dall'utente; vuoto = predefinito.</summary>
private static string _databaseFileOverride = "";
/// <summary>
/// L'archivio SQLite con aste, puntate, decisioni e misure. Sta nella radice
/// fissa, non in Documenti: una cartella sincronizzata (OneDrive) è il modo
/// classico di corrompere un database aperto.
/// </summary>
public static string DatabaseFile =>
_databaseFileOverride.Length > 0 ? _databaseFileOverride : Path.Combine(Root, "Database", "autobidder.sqlite");
/// <summary>Percorso dati scelto dall'utente; vuoto = predefinito.</summary>
private static string _dataFolderOverride = "";
@@ -200,11 +211,29 @@ namespace AutoBidder.Utilities
/// ignorato in favore del predefinito: è preferibile scrivere nel posto sbagliato
/// che non scrivere affatto e perdere lo storico.
/// </summary>
public static void Configure(string? dataFolder, string? statsFolder, string? logFolder = null)
public static void Configure(string? dataFolder, string? statsFolder, string? logFolder = null, string? databaseFile = null)
{
_dataFolderOverride = Sanitize(dataFolder);
_statsFolderOverride = Sanitize(statsFolder);
_logFolderOverride = Sanitize(logFolder);
_databaseFileOverride = SanitizeFile(databaseFile);
}
private static string SanitizeFile(string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate)) return "";
try
{
var full = Path.GetFullPath(candidate.Trim());
var folder = Path.GetDirectoryName(full);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
return full;
}
catch
{
return "";
}
}
private static string Sanitize(string? candidate)
@@ -231,7 +260,7 @@ namespace AutoBidder.Utilities
TryCreate(DataFolder);
TryCreate(StatsFolder);
TryCreate(LogFolder);
TryCreate(AuctionLogFolder);
TryCreate(Path.GetDirectoryName(DatabaseFile) ?? Root);
}
private static void TryCreate(string path)
+80 -215
View File
@@ -1,257 +1,122 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using AutoBidder.Data;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
/// <summary>
/// Le schede dettagliate delle aste concluse, lette dai dossier.
/// Le schede dettagliate delle aste concluse, lette dalla tabella <c>auctions</c>.
///
/// <para>Fino a ieri ogni asta chiusa veniva scritta due volte: una riga nel dossier
/// (il riepilogo in coda al file) e la stessa riga, con gli stessi numeri, in un
/// archivio mensile <c>aste-AAAA-MM.jsonl</c>. Cento megabyte di copia, e due fonti
/// che potevano divergere. Ora la fonte è una: il dossier. Questa classe ne legge
/// intestazione e riepilogo — la prima e l'ultima riga — e ricompone la scheda.</para>
///
/// <para>Un dossier pesa anche decine di megabyte, ma qui non lo si scorre: si legge
/// la testa e la coda del file, poche decine di kilobyte. Seimila dossier si
/// rileggono in un paio di secondi, e comunque solo la prima volta — la cache
/// ricorda ogni file per dimensione e data, e rilegge soltanto quelli cambiati.</para>
/// <para>La scheda è la stessa riga dello storico compatto, vista per intero: serie
/// dei prezzi, puntate per utente, copertura dell'osservazione, rete, motore. Non
/// c'è più nulla da ricomporre da file diversi.</para>
/// </summary>
public static class AuctionDetailStore
{
private static readonly object Sync = new();
private static AuctionDatabase Db => AuctionDatabase.Instance;
/// <summary>Quanto si legge dalla coda: il riepilogo con 600 punti di prezzo e centinaia di utenti sta in ~40 KB.</summary>
private const int TailBytes = 256 * 1024;
private const int HeadBytes = 8 * 1024;
private const string Select =
"SELECT * FROM auctions WHERE outcome IS NOT NULL ORDER BY end_time DESC";
private sealed record CacheEntry(long Length, DateTime LastWrite, AuctionDetailRecord? Record);
private static readonly Dictionary<string, CacheEntry> Cache = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Tutte le schede delle aste con dossier concluso, dalla più recente.</summary>
/// <summary>Tutte le schede, dalla più recente.</summary>
public static List<AuctionDetailRecord> LoadAll()
{
var result = new List<AuctionDetailRecord>();
try
{
var folder = AppPaths.AuctionLogFolder;
if (!Directory.Exists(folder)) return result;
lock (Sync)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var file in Directory.EnumerateFiles(folder, "*.jsonl"))
{
seen.Add(file);
FileInfo info;
try { info = new FileInfo(file); } catch { continue; }
if (Cache.TryGetValue(file, out var hit) &&
hit.Length == info.Length && hit.LastWrite == info.LastWriteTimeUtc)
{
if (hit.Record != null) result.Add(hit.Record);
continue;
}
var record = ReadDossier(file);
Cache[file] = new CacheEntry(info.Length, info.LastWriteTimeUtc, record);
if (record != null) result.Add(record);
}
// File spariti: via dalla cache, o resterebbero per sempre.
foreach (var stale in Cache.Keys.Where(k => !seen.Contains(k)).ToList())
Cache.Remove(stale);
}
}
catch { }
return result.OrderByDescending(r => r.EndedAt).ToList();
Db.Flush();
return Db.Query(Select).Select(FromRow).ToList();
}
public static int Count() => LoadAll().Count;
/// <summary>
/// La scheda di un singolo dossier, o <c>null</c> se il file non ha un riepilogo
/// (asta ancora in corso, o abbandonata a metà).
/// </summary>
public static AuctionDetailRecord? ReadDossier(string path)
/// <summary>La scheda di una sola asta, o null.</summary>
public static AuctionDetailRecord? Load(string auctionId)
{
try
{
string? headerLine, lastLine;
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
headerLine = ReadFirstLine(fs);
lastLine = ReadLastLine(fs);
}
if (headerLine == null || lastLine == null) return null;
if (!lastLine.Contains("\"type\":\"summary\"", StringComparison.Ordinal)) return null;
using var header = JsonDocument.Parse(headerLine);
using var summary = JsonDocument.Parse(lastLine);
return Compose(header.RootElement, summary.RootElement, path);
}
catch
{
return null;
}
Db.Flush();
var row = Db.QueryOne("SELECT * FROM auctions WHERE auction_id = ?1 AND outcome IS NOT NULL", auctionId);
return row == null ? null : FromRow(row);
}
// ── Lettura di testa e coda ──────────────────────────────────────
public static int Count() => (int)Db.ScalarLong("SELECT COUNT(*) AS n FROM auctions WHERE outcome IS NOT NULL");
private static string? ReadFirstLine(FileStream fs)
/// <summary>Le aste di cui esistono eventi registrati (interrogazioni o puntate).</summary>
public static HashSet<string> WithEvents()
{
fs.Seek(0, SeekOrigin.Begin);
var buffer = new byte[Math.Min(HeadBytes, fs.Length)];
var read = fs.Read(buffer, 0, buffer.Length);
var text = Encoding.UTF8.GetString(buffer, 0, read).TrimStart('');
var nl = text.IndexOf('\n');
var line = nl >= 0 ? text[..nl] : text;
return line.Contains("\"type\":\"header\"", StringComparison.Ordinal) ? line.TrimEnd('\r') : null;
Db.Flush();
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var r in Db.Query("SELECT DISTINCT auction_id FROM polls UNION SELECT DISTINCT auction_id FROM bids"))
set.Add(r.Str("auction_id"));
return set;
}
private static string? ReadLastLine(FileStream fs)
{
var take = (int)Math.Min(TailBytes, fs.Length);
fs.Seek(-take, SeekOrigin.End);
var buffer = new byte[take];
var read = fs.Read(buffer, 0, take);
var text = Encoding.UTF8.GetString(buffer, 0, read).TrimEnd('\r', '\n', ' ');
var nl = text.LastIndexOf('\n');
// Se non c'è un a-capo nella coda letta, la riga è più lunga della finestra
// (o il file è di una riga sola): si tiene solo se comincia con la graffa.
var line = nl >= 0 ? text[(nl + 1)..] : text;
line = line.TrimEnd('\r');
return line.StartsWith('{') ? line : null;
}
// ── Ricomposizione ───────────────────────────────────────────────
private static AuctionDetailRecord Compose(JsonElement h, JsonElement s, string path)
internal static AuctionDetailRecord FromRow(SqliteRow row)
{
var r = new AuctionDetailRecord
{
AuctionId = Str(h, "auctionId"),
Name = Str(h, "name"),
ProductKey = Str(h, "productKey"),
Url = Str(h, "url"),
DossierPath = path,
AuctionId = row.Str("auction_id"),
Name = row.Str("name"),
ProductKey = row.Str("product_key"),
Url = row.Str("url"),
Outcome = Str(s, "outcome"),
WonByMe = Bool(s, "wonByMe"),
Winner = Str(s, "winner"),
FinalPrice = Num(s, "finalPrice"),
EndedAt = Date(s, "endedAt") ?? Date(s, "closedAt") ?? DateTime.MinValue,
PriceVelocityPerMinute = Num(s, "priceVelocityPerMinute")
Outcome = row.Str("outcome"),
WonByMe = row.Bool("won_by_me"),
Winner = row.Str("winner_user"),
FinalPrice = row.Dbl("final_price"),
BuyNowPrice = row.DblOrNull("buy_now_price"),
ShippingCost = row.DblOrNull("shipping"),
FirstSeenAt = row.Date("first_seen_at") ?? row.Date("added_at") ?? DateTime.MinValue,
EndedAt = row.Date("end_time") ?? row.Date("closed_at") ?? DateTime.MinValue,
ObservedMinutes = row.Dbl("observed_minutes"),
ObservedFromStart = row.Bool("observed_from_start"),
ObservedToEnd = row.Bool("observed_to_end"),
MyBids = row.Int("my_bids"),
TotalObservedBids = row.Int("total_observed_bids"),
DistinctBidders = row.Int("distinct_bidders"),
Resets = row.Int("resets"),
TopBidderShare = row.Dbl("top_bidder_share"),
PriceVelocityPerMinute = row.Dbl("price_velocity_per_minute"),
AveragePingMs = row.Dbl("avg_ping_ms"),
PollCount = row.Long("polls"),
PollErrors = row.Long("poll_errors"),
ConfiguredLeadMs = row.Int("configured_lead_ms"),
TimerExpiredCount = row.Int("timer_expired"),
SuccessfulBids = row.Int("successful_bids"),
FailedBids = row.Int("failed_bids"),
BidCostEuro = row.DblOrNull("bid_fee") is { } fee && fee > 0 ? fee : 0.20
};
if (h.TryGetProperty("product", out var product))
{
r.BuyNowPrice = NullableNum(product, "buyNowPrice");
r.BidCostEuro = Num(product, "bidCostEuro", 0.20);
}
if (r.ObservedMinutes <= 0 && r.EndedAt > r.FirstSeenAt)
r.ObservedMinutes = Math.Max(0, (r.EndedAt - r.FirstSeenAt).TotalMinutes);
if (s.TryGetProperty("coverage", out var cov))
try
{
r.ObservedFromStart = Bool(cov, "observedFromStart");
r.ObservedToEnd = Bool(cov, "observedToEnd");
r.FirstSeenAt = Date(cov, "firstSeenAt") ?? Date(h, "addedAt") ?? r.EndedAt;
r.ObservedMinutes = Num(cov, "observedMinutes");
}
else
{
r.FirstSeenAt = Date(h, "addedAt") ?? r.EndedAt;
}
if (s.TryGetProperty("participation", out var part))
{
r.MyBids = Int(part, "myBids");
r.TotalObservedBids = Int(part, "totalObservedBids");
r.DistinctBidders = Int(part, "distinctBidders");
r.Resets = Int(part, "resets");
r.TopBidderShare = Num(part, "topBidderShare");
if (part.TryGetProperty("bidsByUser", out var byUser) && byUser.ValueKind == JsonValueKind.Object)
var series = row.StrOrNull("price_series_json");
if (!string.IsNullOrEmpty(series))
{
foreach (var p in byUser.EnumerateObject())
if (p.Value.TryGetInt32(out var n)) r.BidsByUser[p.Name] = n;
using var doc = JsonDocument.Parse(series);
foreach (var p in doc.RootElement.EnumerateArray())
{
r.PriceSeries.Add(new PricePoint
{
T = p.TryGetProperty("t", out var t) ? t.GetDouble() : 0,
Price = p.TryGetProperty("price", out var pr) ? pr.GetDouble() : 0
});
}
}
var byUser = row.StrOrNull("bids_by_user_json");
if (!string.IsNullOrEmpty(byUser))
{
var dict = JsonSerializer.Deserialize<Dictionary<string, int>>(byUser);
if (dict != null)
foreach (var kv in dict) r.BidsByUser[kv.Key] = kv.Value;
}
}
if (s.TryGetProperty("value", out var value))
{
r.BuyNowPrice = NullableNum(value, "buyNowPrice") ?? r.BuyNowPrice;
r.ShippingCost = NullableNum(value, "shippingCost");
var costo = NullableNum(value, "bidCostEuro");
if (costo is > 0) r.BidCostEuro = costo.Value;
}
if (s.TryGetProperty("network", out var net))
{
r.AveragePingMs = Num(net, "avgPingMs");
r.PollCount = Long(net, "polls");
r.PollErrors = Long(net, "pollErrors");
}
if (s.TryGetProperty("engine", out var eng))
{
r.ConfiguredLeadMs = Int(eng, "configuredLeadMs");
r.TimerExpiredCount = Int(eng, "timerExpiredCount");
r.SuccessfulBids = Int(eng, "successfulBids");
r.FailedBids = Int(eng, "failedBids");
}
if (s.TryGetProperty("priceSeries", out var series) && series.ValueKind == JsonValueKind.Array)
{
foreach (var p in series.EnumerateArray())
r.PriceSeries.Add(new PricePoint { T = Num(p, "t"), Price = Num(p, "price") });
}
catch { /* una colonna JSON rotta non deve togliere la scheda */ }
return r;
}
private static string Str(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
private static bool Bool(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
private static double Num(JsonElement e, string name, double fallback = 0) =>
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 int Int(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : 0;
private static long Long(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n) ? n : 0;
private static DateTime? Date(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String &&
DateTime.TryParse(v.GetString(), System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind, out var d)
? d
: null;
/// <summary>Solo per i test: dimentica i file già letti.</summary>
public static void ResetCacheForTests()
{
lock (Sync) Cache.Clear();
}
}
}
-571
View File
@@ -1,571 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
/// <summary>
/// Il dossier di una singola asta: un file per asta, scritto mentre l'asta è in corso.
///
/// <para><b>Formato JSON Lines.</b> La prima riga è l'intestazione (che asta è, con che
/// impostazioni la si sta seguendo, quanto vale il prodotto), poi una riga per ogni cosa
/// che succede — ogni interrogazione a Bidoo, ogni puntata di ogni utente con l'ora al
/// millisecondo, ogni mia puntata con anticipo pianificato ed effettivo, ogni riga di
/// registro — e l'ultima riga è il riepilogo. Si accoda senza rileggere, un'interruzione
/// perde al massimo l'ultima riga, e un'AI lo legge senza doverlo convertire.</para>
///
/// <para>Perché tanto dettaglio: questi dati <b>non esistono a posteriori</b>. Bidoo non
/// espone lo storico dei prezzi né i tempi delle puntate di un'asta conclusa. O li si
/// raccoglie mentre l'asta va avanti, o sono persi — e sono esattamente quelli su cui si
/// capisce se l'anticipo impostato è giusto e come si comportano gli avversari.</para>
///
/// <para>La scrittura passa da <see cref="FileLogWriter"/>, quindi non tocca mai il
/// disco sul thread che segue l'asta: si accoda una riga e si prosegue.</para>
/// </summary>
public sealed class AuctionDossier
{
/// <summary>Campioni di ping tenuti in memoria per il riepilogo finale.</summary>
private const int MaxPingSamples = 50_000;
private static readonly ConcurrentDictionary<string, AuctionDossier> Open =
new(StringComparer.Ordinal);
private static readonly JsonSerializerOptions Json = new()
{
WriteIndented = false,
// Senza, accenti e apostrofi dei nomi prodotto finiscono in \uXXXX e il file
// diventa illeggibile proprio per chi (o cosa) deve analizzarlo.
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly FileLogWriter _writer;
private readonly DateTime _startedAt;
private readonly List<int> _pings = new();
private readonly object _sync = new();
private long _polls;
private long _events;
private bool _closed;
// ── 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)
{
_writer = FileLogWriter.For(path);
_startedAt = startedAt;
}
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>
public long EventCount => _events;
// ── Apertura e chiusura ──────────────────────────────────────────
/// <summary>
/// Apre (o riapre) il dossier di quest'asta. Riaprendo l'applicazione mentre l'asta
/// è ancora viva si <b>riprende lo stesso file</b>: un'asta seguita a cavallo di un
/// riavvio deve restare una storia sola.
/// </summary>
public static AuctionDossier? OpenFor(AuctionInfo auction, AppSettings settings)
{
if (auction == null || !settings.WriteAuctionDossiers) return null;
return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings));
}
/// <summary>Il dossier già aperto per quest'asta, se c'è.</summary>
public static AuctionDossier? For(string auctionId) =>
auctionId != null && Open.TryGetValue(auctionId, out var dossier) ? dossier : null;
/// <summary>
/// Fa confluire nel dossier tutte le righe di registro delle aste. Da chiamare una
/// volta all'avvio: le righe nascono in decine di punti del motore, e agganciarsi
/// alla sorgente è l'unico modo per non doverle inseguire una per una.
/// </summary>
public static void CaptureAuctionLogs()
{
AuctionInfo.LogSink = (auction, entry) =>
For(auction.AuctionId)?.Log(
entry.Level.ToString().ToLowerInvariant(),
entry.Category.ToString(),
entry.Message);
}
/// <summary>
/// Chiude il dossier di un'asta tolta dal monitor prima della fine.
///
/// <para>Senza riepilogo, e detto esplicitamente: quel file racconta una storia
/// interrotta, e chi lo analizzerà deve saperlo — una serie di prezzi che si ferma a
/// metà sembra un'asta finita a quel prezzo.</para>
/// </summary>
public static void Abandon(string auctionId, string reason)
{
if (auctionId == null) return;
if (!Open.TryRemove(auctionId, out var dossier)) return;
dossier.Write(new
{
t = dossier.Elapsed(),
type = "abandoned",
at = Now(),
reason,
note = "asta tolta dal monitor prima della conclusione: dati incompleti"
});
FileLogWriter.FlushAll();
}
/// <summary>Svuota sul disco i dossier aperti. Alla chiusura, o prima di esportare.</summary>
public static void FlushAll() => FileLogWriter.FlushAll();
/// <summary>Quanti dossier sono aperti adesso.</summary>
public static int OpenCount => Open.Count;
private static AuctionDossier Create(AuctionInfo auction, AppSettings settings)
{
AppPaths.EnsureFolders();
var path = ResolvePath(auction);
var isNew = !File.Exists(path);
var dossier = new AuctionDossier(path, DateTime.Now);
if (isNew) dossier.WriteHeader(auction, settings);
else dossier.Write(new { type = "resumed", at = Now(), note = "applicazione riavviata, asta ancora in corso" });
return dossier;
}
/// <summary>
/// Percorso del file. Se ne esiste già uno per quest'asta si riusa quello, comunque
/// si chiami: il nome porta la data, e un'asta cominciata ieri non deve spezzarsi in
/// due file solo perché è mezzanotte.
/// </summary>
private static string ResolvePath(AuctionInfo auction)
{
try
{
var existing = Directory
.GetFiles(AppPaths.AuctionLogFolder, $"*_{auction.AuctionId}.jsonl")
.FirstOrDefault();
if (existing != null) return existing;
}
catch { /* cartella non ancora disponibile: si crea il file nuovo */ }
var slug = Slug(auction.Name);
var name = $"{DateTime.Now:yyyy-MM-dd_HHmm}_{slug}_{auction.AuctionId}.jsonl";
return System.IO.Path.Combine(AppPaths.AuctionLogFolder, name);
}
private void WriteHeader(AuctionInfo auction, AppSettings settings)
{
Write(new
{
type = "header",
schema = "autobidder.auction.v1",
app = AppInfo.Version,
auctionId = auction.AuctionId,
me = string.IsNullOrEmpty(CurrentUsername) ? null : CurrentUsername,
name = auction.Name,
productKey = ProductKeyHelper.GenerateProductKey(auction.Name),
url = auction.OriginalUrl,
openedAt = Now(),
addedAt = auction.AddedAt.ToLocalTime().ToString("o"),
// Valore del prodotto: senza questo, prezzo finale e convenienza non
// significano nulla quando si rileggeranno i dati.
product = new
{
buyNowPrice = auction.BuyNowPrice,
shippingCost = auction.ShippingCost,
bidCostEuro = auction.BidCost,
hasWinLimit = auction.HasWinLimit,
winLimit = auction.WinLimitDescription
},
// Con che regole la si sta seguendo: due aste identiche con anticipi
// diversi non sono confrontabili, e senza questo non si saprebbe.
config = new
{
state = auction.State.ToString(),
bidBeforeDeadlineMs = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs,
minPrice = auction.MinPrice,
maxPrice = auction.MaxPrice,
maxBids = auction.MaxClicks,
minResets = auction.MinResets,
maxResets = auction.MaxResets,
bidLeadIsManual = auction.BidLeadIsManual,
adaptiveLead = settings.AdaptiveLeadEnabled,
leadMinMs = settings.LeadMinMs,
leadMaxMs = settings.LeadMaxMs,
valueCheckEnabled = settings.ValueCheckEnabled,
minSavingsPercentage = settings.MinSavingsPercentage,
rawPollsIncluded = settings.DossierIncludeRawPolls
}
});
}
/// <summary>
/// Chiude il dossier con il riepilogo. Da qui in avanti il file non cresce più:
/// è la riga che dice a chi analizza che la storia è completa.
/// </summary>
public static void Close(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
if (auction == null) return;
if (!Open.TryRemove(auction.AuctionId, out var dossier)) return;
dossier.WriteSummary(auction, detail, finalState);
}
private void WriteSummary(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
lock (_sync)
{
if (_closed) return;
_closed = true;
}
var pings = PingStats();
Write(new
{
type = "summary",
closedAt = Now(),
endedAt = detail.EndedAt.ToLocalTime().ToString("o"),
outcome = detail.Outcome,
wonByMe = detail.WonByMe,
winner = detail.Winner,
finalPrice = detail.FinalPrice,
// Le due bandiere che dicono quanto vale questo dossier per l'analisi.
coverage = new
{
observedFromStart = auction.ObservedFromStart,
observedToEnd = auction.ObservedToEnd,
complete = auction.ObservedFromStart && auction.ObservedToEnd,
firstSeenAt = detail.FirstSeenAt.ToLocalTime().ToString("o"),
observedMinutes = Math.Round(detail.ObservedMinutes, 2)
},
participation = new
{
myBids = detail.MyBids,
totalObservedBids = detail.TotalObservedBids,
distinctBidders = detail.DistinctBidders,
resets = detail.Resets,
topBidderShare = Math.Round(detail.TopBidderShare, 2),
bidsByUser = detail.BidsByUser
},
value = new
{
buyNowPrice = detail.BuyNowPrice,
shippingCost = detail.ShippingCost,
bidCostEuro = auction.BidCost,
totalCostIfWon = Math.Round(detail.TotalCostIfWon(auction.BidCost), 2),
finalPriceRatio = detail.FinalPriceRatio.HasValue
? Math.Round(detail.FinalPriceRatio.Value, 4)
: (double?)null
},
network = new
{
avgPingMs = pings.Avg,
minPingMs = pings.Min,
maxPingMs = pings.Max,
medianPingMs = pings.Median,
p95PingMs = pings.P95,
samples = pings.Count,
polls = _polls,
pollErrors = detail.PollErrors
},
engine = new
{
configuredLeadMs = detail.ConfiguredLeadMs,
timerExpiredCount = auction.TimerExpiredCount,
successfulBids = auction.SuccessfulBidCount,
failedBids = auction.FailedBidCount
},
priceSeries = detail.PriceSeries.Select(p => new { t = Math.Round(p.T, 2), price = p.Price }),
priceVelocityPerMinute = Math.Round(detail.PriceVelocityPerMinute, 4),
finalStatus = finalState?.Status.ToString(),
eventsWritten = _events
}, isSummary: true);
FileLogWriter.FlushAll();
}
// ── Eventi ───────────────────────────────────────────────────────
/// <summary>
/// Una singola interrogazione a Bidoo. È l'evento più frequente — nella finestra
/// critica quattro al secondo — ed è quello che permette di ricostruire a posteriori
/// dove stava davvero il timer nell'istante in cui si è deciso di puntare.
/// </summary>
public void Poll(AuctionState state, bool includeRaw)
{
_polls++;
lock (_sync)
{
if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs);
// 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;
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
{
t = Elapsed(),
type = "poll",
at = Now(),
price = state.Price,
timer = Math.Round(state.Timer, 2),
status = state.Status.ToString(),
lastBidder = string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder,
mine = state.IsMyBid,
pingMs = state.PollingLatencyMs,
expiryUnix = state.ExpiryUnixSeconds,
serverUnix = state.ServerUnixSeconds
});
}
/// <summary>Una puntata altrui, come l'ha riportata Bidoo.</summary>
public void ForeignBid(BidHistoryEntry entry, double price)
{
Write(new
{
t = Elapsed(),
type = "bid",
at = Now(),
user = entry.Username,
// L'ora dichiarata da Bidoo, distinta da quella in cui l'abbiamo saputo:
// fra le due può passare un poll intero, e la differenza conta quando si
// ricostruisce chi ha puntato per primo.
bidAt = entry.Timestamp > 0
? DateTimeOffset.FromUnixTimeSeconds(entry.Timestamp).ToLocalTime().ToString("o")
: null,
bidType = entry.BidType,
mine = entry.IsMyBid,
price
});
}
/// <summary>
/// Una mia puntata, con tutto ciò che serve a giudicarla: quanto prima della
/// scadenza volevo essere, quanto prima ci sono arrivato davvero, e con che ping.
/// È la coppia pianificato/effettivo il dato che permette di tarare l'anticipo.
/// </summary>
public void MyBid(
double price,
int plannedLeadMs,
double actualLeadMs,
int pingMs,
bool success,
string? error,
int? bidsUsed,
int? remainingBids)
{
Write(new
{
t = Elapsed(),
type = "my_bid",
at = Now(),
price,
plannedLeadMs,
actualLeadMs = Math.Round(actualLeadMs, 1),
leadErrorMs = Math.Round(actualLeadMs - plannedLeadMs, 1),
pingMs,
avgPingMs = PingStats().Avg,
result = success ? "ok" : "fail",
error,
bidsUsed,
remainingBids
});
}
/// <summary>Il timer è stato azzerato da una puntata: l'asta continua.</summary>
public void Reset(int resetCount, double price, string? bidder)
{
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
{
t = Elapsed(),
type = "reset",
at = Now(),
resetCount,
price,
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>
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
{
t = Elapsed(),
type = "log",
at = Now(),
level,
category,
msg = message
});
}
/// <summary>Cambio di modo: Ferma, Osserva, Attiva.</summary>
public void StateChanged(string from, string to)
{
Write(new
{
t = Elapsed(),
type = "state",
at = Now(),
from,
to
});
}
// ── Interno ──────────────────────────────────────────────────────
private void Write(object payload, bool isSummary = false)
{
// Dopo il riepilogo il file è una storia chiusa: una riga in coda al riepilogo
// farebbe dubitare di tutto ciò che c'è sopra.
if (_closed && !isSummary) return;
try
{
_writer.Write(JsonSerializer.Serialize(payload, Json));
_events++;
}
catch { /* una riga persa non vale l'interruzione di un'asta */ }
}
private double Elapsed() => Math.Round((DateTime.Now - _startedAt).TotalSeconds, 3);
private static string Now() => DateTime.Now.ToString("HH:mm:ss.fff");
private (int Count, double Avg, int Min, int Max, int Median, int P95) PingStats()
{
int[] copy;
lock (_sync) copy = _pings.Where(p => p > 0).ToArray();
if (copy.Length == 0) return (0, 0, 0, 0, 0, 0);
Array.Sort(copy);
return (
copy.Length,
Math.Round(copy.Average(), 1),
copy[0],
copy[^1],
copy[copy.Length / 2],
copy[Math.Min(copy.Length - 1, (int)(copy.Length * 0.95))]);
}
/// <summary>Nome file leggibile: niente caratteri vietati, niente nomi chilometrici.</summary>
private static string Slug(string? name)
{
if (string.IsNullOrWhiteSpace(name)) return "asta";
var clean = Regex.Replace(name.Trim(), @"[^\p{L}\p{Nd}]+", "-").Trim('-');
foreach (var invalid in System.IO.Path.GetInvalidFileNameChars())
clean = clean.Replace(invalid, '-');
if (clean.Length == 0) return "asta";
return clean.Length <= 48 ? clean : clean[..48].TrimEnd('-');
}
}
}
+84 -112
View File
@@ -5,6 +5,7 @@ using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using AutoBidder.Data;
using AutoBidder.Models;
namespace AutoBidder.Utilities
@@ -36,13 +37,13 @@ namespace AutoBidder.Utilities
/// <summary>Solo aste seguite dall'inizio alla fine: le più preziose per l'analisi.</summary>
public bool OnlyComplete { get; set; }
/// <summary>Solo aste di cui esiste il dossier con tutti gli eventi.</summary>
/// <summary>Solo aste di cui esistono gli eventi registrati (interrogazioni e puntate).</summary>
public bool OnlyWithDossier { get; set; }
public double? MinFinalPrice { get; set; }
public double? MaxFinalPrice { get; set; }
/// <summary>Includi nel file anche gli eventi del dossier, non solo il riepilogo.</summary>
/// <summary>Includi nel file anche gli eventi registrati, non solo il riepilogo.</summary>
public bool IncludeEvents { get; set; } = true;
/// <summary>Tetto di aste esportate. 0 = nessuno.</summary>
@@ -60,7 +61,8 @@ namespace AutoBidder.Utilities
/// <summary>
/// Mette in un unico file le aste che rispondono ai criteri scelti, riepilogo e — dove
/// c — tutti gli eventi registrati nel dossier.
/// ci sono — tutti gli eventi registrati: interrogazioni, puntate, reset, nostre
/// puntate, decisioni del motore, registro.
///
/// <para>Il formato è JSON pensato per essere <b>letto da un'AI</b>: un blocco
/// <c>meta</c> che spiega cosa sono i campi e con che filtri è stato prodotto, e poi le
@@ -77,15 +79,17 @@ namespace AutoBidder.Utilities
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private static AuctionDatabase Db => AuctionDatabase.Instance;
/// <summary>Le aste che passerebbero il filtro, per mostrarne il conto prima di esportare.</summary>
public static List<AuctionDetailRecord> Select(AuctionExportFilter filter)
{
filter ??= new AuctionExportFilter();
var details = MergedRecords();
var dossiers = DossierIndex();
var details = AuctionDetailStore.LoadAll();
var withEvents = filter.OnlyWithDossier ? AuctionDetailStore.WithEvents() : null;
var selected = details.Where(r => Matches(r, filter, dossiers)).ToList();
var selected = details.Where(r => Matches(r, filter, withEvents)).ToList();
return filter.MaxAuctions > 0 ? selected.Take(filter.MaxAuctions).ToList() : selected;
}
@@ -95,10 +99,8 @@ namespace AutoBidder.Utilities
{
try
{
// Ciò che è ancora in coda deve finire sul disco prima di rileggerlo:
// esportare un'asta appena conclusa senza le sue ultime righe sarebbe il
// modo più sottile per ritrovarsi con dati incompleti.
FileLogWriter.FlushAll();
// Ciò che è ancora in coda deve essere sul disco prima di rileggerlo.
Db.Flush();
filter ??= new AuctionExportFilter();
@@ -107,9 +109,7 @@ namespace AutoBidder.Utilities
if (selected.Count == 0)
return new AuctionExportResult(false, "", 0, 0, 0, "nessuna asta corrisponde ai filtri");
var dossiers = DossierIndex();
var withEvents = 0;
var auctions = new JsonArray();
foreach (var record in selected)
@@ -118,10 +118,9 @@ namespace AutoBidder.Utilities
node["totalCostIfWon"] = Math.Round(record.TotalCostIfWon(record.BidCostEuro), 2);
if (filter.IncludeEvents &&
dossiers.TryGetValue(record.AuctionId, out var dossierPath))
if (filter.IncludeEvents)
{
var events = ReadEvents(dossierPath);
var events = ReadEvents(record.AuctionId);
if (events != null)
{
@@ -161,7 +160,7 @@ namespace AutoBidder.Utilities
/// <summary>Elenco dei prodotti presenti nello storico, per il menu dei filtri.</summary>
public static List<string> KnownProducts() =>
MergedRecords()
AuctionDetailStore.LoadAll()
.Select(r => r.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
@@ -170,10 +169,6 @@ namespace AutoBidder.Utilities
// ── Interno ──────────────────────────────────────────────────────
/// <summary>
/// Spiega dentro il file cosa contiene e con che filtri è stato prodotto. È la
/// differenza fra un'esportazione riutilizzabile e un mucchio di numeri.
/// </summary>
private static JsonObject BuildMeta(
AuctionExportFilter filter,
List<AuctionDetailRecord> selected,
@@ -183,7 +178,7 @@ namespace AutoBidder.Utilities
return new JsonObject
{
["schema"] = "autobidder.export.v1",
["schema"] = "autobidder.export.v2",
["app"] = AppInfo.Version,
["generatedAt"] = DateTime.Now.ToString("o"),
["auctionCount"] = selected.Count,
@@ -201,7 +196,7 @@ namespace AutoBidder.Utilities
["includeClosed"] = filter.IncludeClosed,
["onlyWithMyBids"] = filter.OnlyWithMyBids,
["onlyComplete"] = filter.OnlyComplete,
["onlyWithDossier"] = filter.OnlyWithDossier,
["onlyWithEvents"] = filter.OnlyWithDossier,
["minFinalPrice"] = filter.MinFinalPrice,
["maxFinalPrice"] = filter.MaxFinalPrice
},
@@ -213,105 +208,82 @@ namespace AutoBidder.Utilities
["isComplete"] = "storia completa: sono le aste su cui l'analisi vale davvero",
["priceSeries"] = "prezzo campionato: t = secondi dal primo avvistamento",
["bidsByUser"] = "puntate osservate per utente",
["topBidderShare"] = "quota percentuale del puntatore più attivo",
["configuredLeadMs"] = "anticipo impostato sulla scadenza",
["topBidderShare"] = "quota del puntatore più attivo",
["configuredLeadMs"] = "anticipo in uso sulla scadenza",
["averagePingMs"] = "latenza media verso Bidoo su questa asta",
["buyNowPrice"] = "valore del prodotto (Compra Subito) al momento dell'asta",
["totalCostIfWon"] = "prezzo finale + puntate spese + spedizione",
["events[].type"] = "poll = interrogazione, bid = puntata altrui, " +
"my_bid = mia puntata (plannedLeadMs vs actualLeadMs), " +
"reset = timer azzerato, log = registro, summary = riepilogo"
["events[].type"] = "poll = interrogazione, bid = puntata di un utente (mine = nostra), " +
"my_bid = nostra puntata (plannedLeadMs vs actualLeadMs), " +
"reset = timer azzerato (minTimer = fin dove era sceso), " +
"decision = decisione del motore (action, pWin, ev, reason), log = registro"
}
};
}
/// <summary>
/// Schede dettagliate, completate con i riepiloghi delle aste che non ne hanno una
/// (quelle concluse prima che il dossier esistesse). Una vista sola su tutto lo
/// storico: filtrare su due archivi diversi darebbe risultati diversi a seconda
/// del filtro, che è il modo più sicuro per non fidarsi più dei propri dati.
/// </summary>
private static List<AuctionDetailRecord> MergedRecords()
{
var byId = new Dictionary<string, AuctionDetailRecord>(StringComparer.OrdinalIgnoreCase);
foreach (var detail in AuctionDetailStore.LoadAll())
byId[detail.AuctionId] = detail;
foreach (var summary in CompletedAuctionsStore.LoadAll())
{
if (byId.ContainsKey(summary.AuctionId)) continue;
byId[summary.AuctionId] = new AuctionDetailRecord
{
AuctionId = summary.AuctionId,
Name = summary.Name,
ProductKey = summary.ProductKey,
Url = summary.OriginalUrl,
Outcome = summary.Outcome,
WonByMe = summary.WonByMe,
Winner = summary.Winner,
FinalPrice = summary.FinalPrice,
BuyNowPrice = summary.BuyNowPrice,
MyBids = summary.MyBidsUsed,
Resets = summary.TotalResets,
FirstSeenAt = summary.AddedAt,
EndedAt = summary.EndedAt,
ObservedMinutes = Math.Max(0, (summary.EndedAt - summary.AddedAt).TotalMinutes)
};
}
return byId.Values.OrderByDescending(r => r.EndedAt).ToList();
}
/// <summary>Dossier presenti sul disco, per id asta.</summary>
private static Dictionary<string, string> DossierIndex()
{
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
if (!Directory.Exists(AppPaths.AuctionLogFolder)) return index;
foreach (var file in Directory.GetFiles(AppPaths.AuctionLogFolder, "*.jsonl"))
{
// Il nome finisce con _<idasta>.jsonl: è il legame con lo storico.
var name = System.IO.Path.GetFileNameWithoutExtension(file);
var separator = name.LastIndexOf('_');
if (separator < 0 || separator == name.Length - 1) continue;
index[name[(separator + 1)..]] = file;
}
}
catch { }
return index;
}
private static JsonArray? ReadEvents(string path)
/// <summary>Tutti gli eventi registrati di un'asta, in ordine di tempo.</summary>
private static JsonArray? ReadEvents(string auctionId)
{
try
{
var events = new JsonArray();
var events = new List<(double T, JsonObject Node)>();
foreach (var line in File.ReadLines(path))
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
foreach (var r in Db.Query("SELECT * FROM polls WHERE auction_id = ?1 ORDER BY t", auctionId))
events.Add((r.Dbl("t"), new JsonObject
{
var node = JsonNode.Parse(line);
if (node != null) events.Add(node);
}
catch
{
// Una riga illeggibile (interruzione a metà scrittura) non deve far
// perdere l'intero dossier: è il motivo per cui è un record per riga.
}
}
["t"] = r.Dbl("t"), ["type"] = "poll", ["at"] = r.StrOrNull("local_ts"),
["price"] = r.Dbl("price"), ["timer"] = r.Dbl("timer"), ["status"] = r.StrOrNull("status"),
["lastBidder"] = r.StrOrNull("last_bidder"), ["mine"] = r.Bool("is_mine"),
["pingMs"] = r.Int("ping_ms"), ["expiryUnix"] = r.Long("expiry_unix"), ["serverUnix"] = r.Long("server_unix")
}));
return events.Count > 0 ? events : null;
foreach (var r in Db.Query("SELECT * FROM bids WHERE auction_id = ?1 ORDER BY COALESCE(server_ts, 0), price_after", auctionId))
events.Add((r.Dbl("seen_t"), new JsonObject
{
["t"] = r.Dbl("seen_t"), ["type"] = "bid", ["at"] = r.StrOrNull("local_ts"), ["user"] = r.Str("username"),
["bidAt"] = r.LongOrNull("server_ts") is { } ts ? DateTimeOffset.FromUnixTimeSeconds(ts).ToLocalTime().ToString("o") : null,
["bidType"] = r.Str("bid_type"), ["mine"] = r.Bool("is_mine"), ["price"] = r.Dbl("price_after"),
["timerBefore"] = r.DblOrNull("timer_before")
}));
foreach (var r in Db.Query("SELECT * FROM resets WHERE auction_id = ?1 ORDER BY t", auctionId))
events.Add((r.Dbl("t"), new JsonObject
{
["t"] = r.Dbl("t"), ["type"] = "reset", ["at"] = r.StrOrNull("local_ts"), ["resetCount"] = r.Int("reset_count"),
["price"] = r.Dbl("price"), ["bidder"] = r.StrOrNull("bidder"),
["minTimer"] = r.DblOrNull("min_timer"), ["cycleSeconds"] = r.DblOrNull("cycle_seconds")
}));
foreach (var r in Db.Query("SELECT * FROM my_bids WHERE auction_id = ?1 ORDER BY t", auctionId))
events.Add((r.Dbl("t"), new JsonObject
{
["t"] = r.Dbl("t"), ["type"] = "my_bid", ["at"] = r.StrOrNull("local_ts"), ["price"] = r.Dbl("price"),
["plannedLeadMs"] = r.Int("planned_lead_ms"), ["actualLeadMs"] = r.Dbl("actual_lead_ms"),
["pingMs"] = r.Int("ping_ms"), ["rttMs"] = r.Int("rtt_ms"), ["result"] = r.Bool("success") ? "ok" : "fail",
["error"] = r.StrOrNull("error"), ["bidsUsed"] = r.IntOrNull("bids_used"), ["remainingBids"] = r.IntOrNull("remaining_bids")
}));
foreach (var r in Db.Query("SELECT * FROM bot_decisions WHERE auction_id = ?1 ORDER BY ts", auctionId))
events.Add((r.Dbl("t"), new JsonObject
{
["t"] = r.DblOrNull("t"), ["type"] = "decision", ["at"] = r.Str("ts"), ["price"] = r.DblOrNull("price"),
["timer"] = r.DblOrNull("timer"), ["action"] = r.Str("action"), ["pWin"] = r.DblOrNull("p_win"),
["ev"] = r.DblOrNull("ev_estimate"), ["mode"] = r.Str("mode"), ["regime"] = r.StrOrNull("regime"),
["executed"] = r.Bool("executed"), ["outcome"] = r.StrOrNull("outcome"), ["reason"] = r.StrOrNull("reason_detail")
}));
foreach (var r in Db.Query("SELECT * FROM auction_log WHERE auction_id = ?1 ORDER BY t", auctionId))
events.Add((r.Dbl("t"), new JsonObject
{
["t"] = r.Dbl("t"), ["type"] = "log", ["at"] = r.StrOrNull("local_ts"), ["level"] = r.StrOrNull("level"),
["category"] = r.StrOrNull("category"), ["msg"] = r.StrOrNull("msg")
}));
if (events.Count == 0) return null;
var array = new JsonArray();
foreach (var (_, node) in events.OrderBy(e => e.T)) array.Add(node);
return array;
}
catch
{
@@ -322,10 +294,10 @@ namespace AutoBidder.Utilities
private static bool Matches(
AuctionDetailRecord record,
AuctionExportFilter filter,
Dictionary<string, string> dossiers)
HashSet<string>? withEvents)
{
if (filter.From.HasValue && record.EndedAt < filter.From.Value.Date) return false;
if (filter.To.HasValue && record.EndedAt > filter.To.Value.Date.AddDays(1)) return false;
if (filter.From.HasValue && record.EndedAt.ToLocalTime() < filter.From.Value.Date) return false;
if (filter.To.HasValue && record.EndedAt.ToLocalTime() > filter.To.Value.Date.AddDays(1)) return false;
if (!string.IsNullOrWhiteSpace(filter.NameContains) &&
!record.Name.Contains(filter.NameContains.Trim(), StringComparison.OrdinalIgnoreCase))
@@ -345,7 +317,7 @@ namespace AutoBidder.Utilities
if (filter.OnlyWithMyBids && record.MyBids == 0) return false;
if (filter.OnlyComplete && !record.IsComplete) return false;
if (filter.OnlyWithDossier && !dossiers.ContainsKey(record.AuctionId)) return false;
if (withEvents != null && !withEvents.Contains(record.AuctionId)) return false;
if (filter.MinFinalPrice.HasValue && record.FinalPrice < filter.MinFinalPrice.Value) return false;
if (filter.MaxFinalPrice.HasValue && record.FinalPrice > filter.MaxFinalPrice.Value) return false;
+151 -229
View File
@@ -2,184 +2,206 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using AutoBidder.Data;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
/// <summary>
/// Storico delle aste concluse su file JSON dedicato (completed-auctions.json),
/// separato da impostazioni e aste attive. Fornisce salvataggio incrementale
/// e aggregazioni per la sezione Statistiche. Nessun database.
/// Lo storico delle aste concluse, letto e scritto nel database.
///
/// <para>Tiene la superficie di sempre — aggiungi, leggi tutte, sostituisci, copia di
/// sicurezza, svuota — perché la usano in dieci punti diversi, dalla griglia Storico
/// al recupero delle puntate dei vincitori. Dietro non c'è più un file JSON riscritto
/// per intero a ogni asta: ogni record è una riga della tabella <c>auctions</c>, la
/// stessa che il registratore riempie mentre l'asta è in corso. Un'asta è una riga,
/// non due archivi da tenere allineati.</para>
///
/// <para>I record restituiti sono copie: chi ne modifica uno deve salvarlo con
/// <see cref="Append"/> o <see cref="AppendMany"/>.</para>
/// </summary>
public static class CompletedAuctionsStore
{
private static readonly object _lock = new();
// Percorsi centralizzati in AppPaths: la cartella statistiche e' configurabile,
// quindi si legge a ogni uso invece di fissarla all'avvio.
private static string AppDataFolder => AppPaths.StatsFolder;
private static string FilePath => AppPaths.CompletedAuctionsFile;
private static AuctionDatabase Db => AuctionDatabase.Instance;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
// ── Copia in memoria ─────────────────────────────────────────────
//
// Il file cresce di un record per asta e arriva in fretta a qualche megabyte:
// a 5876 aste, leggerlo e deserializzarlo costa ~72 ms misurati. Senza copia
// in memoria quel prezzo si paga a ogni apertura delle statistiche e a ogni
// asta che finisce — e quella chiamata arriva da un gestore di eventi, cioe'
// dal thread dell'interfaccia, che resta fermo per tutto il tempo.
//
// L'invalidazione guarda la data di modifica del file, non un tempo di
// scadenza: cosi' la copia resta giusta anche se il file viene sostituito da
// fuori — ripristino di un backup, modifica a mano — che qui capita davvero.
//
// Il prezzo da sapere: i record restituiti sono gli STESSI oggetti che stanno
// nella copia. Chi ne modifica uno sta modificando anche la copia, prima e
// indipendentemente dal salvataggio. Repair e il recupero puntate contano su
// questo; chi non lo vuole si faccia una copia del record.
private static List<CompletedAuctionRecord>? _cache;
private static DateTime _cacheStamp;
private static long _cacheVersion = -1;
/// <summary>Percorso del file storico (per mostrarlo/aprirlo nella UI).</summary>
public static string StorePath => FilePath;
/// <summary>Il file del database (per mostrarlo o aprirne la cartella).</summary>
public static string StorePath => Db.Path;
/// <summary>
/// Aggiunge un'asta conclusa, oppure sostituisce quella con lo stesso id.
/// Un'asta puo' essere registrata piu' volte (fine vista dal motore, poi rimozione
/// dal monitor): l'ultima versione e' quella buona, non una riga in piu'.
/// </summary>
private const string Upsert =
"INSERT INTO auctions(auction_id, product_key, name, url, status, added_at, first_seen_at, opened_at, " +
" end_time, closed_at, final_price, winner_user, won_by_me, outcome, my_bids, resets, buy_now_price, " +
" winner_bids_paid, winner_bids_free) " +
"VALUES(?1, ?2, ?3, ?4, 'closed', ?5, ?5, ?5, ?6, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) " +
"ON CONFLICT(auction_id) DO UPDATE SET " +
" product_key = CASE WHEN excluded.product_key != '' THEN excluded.product_key ELSE product_key END, " +
" name = CASE WHEN excluded.name != '' THEN excluded.name ELSE name END, " +
" url = CASE WHEN excluded.url != '' THEN excluded.url ELSE url END, " +
" status = 'closed', end_time = excluded.end_time, closed_at = COALESCE(closed_at, excluded.closed_at), " +
" final_price = excluded.final_price, winner_user = excluded.winner_user, won_by_me = excluded.won_by_me, " +
" outcome = excluded.outcome, my_bids = excluded.my_bids, resets = MAX(resets, excluded.resets), " +
" buy_now_price = COALESCE(excluded.buy_now_price, buy_now_price), " +
" winner_bids_paid = COALESCE(excluded.winner_bids_paid, winner_bids_paid), " +
" winner_bids_free = COALESCE(excluded.winner_bids_free, winner_bids_free)";
private const string Select =
"SELECT auction_id, name, product_key, url, final_price, winner_user, won_by_me, outcome, my_bids, resets, " +
" buy_now_price, winner_bids_paid, winner_bids_free, added_at, end_time, closed_at " +
"FROM auctions WHERE outcome IS NOT NULL ORDER BY end_time DESC";
/// <summary>Aggiunge un'asta conclusa, oppure aggiorna quella con lo stesso id.</summary>
public static void Append(CompletedAuctionRecord record)
{
if (record == null) return;
AppendMany(new[] { record });
}
/// <summary>
/// Aggiunge o sostituisce piu' aste con <b>un solo</b> salvataggio.
///
/// <para>Serve a chi ne tocca tante di fila, come il recupero delle puntate dei
/// vincitori. Chiamare <see cref="Append"/> in un ciclo rilegge e riscrive tutto
/// il file a ogni giro: sulle 4675 aste di un recupero completo erano minuti di
/// solo rimescolamento JSON e svariati gigabyte scritti sul disco per nulla.</para>
/// </summary>
/// <summary>Aggiunge o aggiorna più aste in una transazione sola.</summary>
public static void AppendMany(IReadOnlyCollection<CompletedAuctionRecord> records)
{
if (records == null || records.Count == 0) return;
lock (_lock)
var ops = new List<(string, object?[])>(records.Count);
foreach (var r in records)
{
var list = LoadInternal();
// Indice per id: cercare ogni record con un FindIndex renderebbe
// quadratico proprio il caso per cui questo metodo esiste.
var byId = new Dictionary<string, int>(list.Count, StringComparer.Ordinal);
for (var i = 0; i < list.Count; i++)
{
var id = list[i].AuctionId;
if (!string.IsNullOrEmpty(id)) byId[id] = i;
}
foreach (var record in records)
{
if (record == null) continue;
var id = record.AuctionId;
if (!string.IsNullOrEmpty(id) && byId.TryGetValue(id, out var at))
{
list[at] = record;
}
else
{
if (!string.IsNullOrEmpty(id)) byId[id] = list.Count;
list.Add(record);
}
}
SaveInternal(list);
if (r == null) continue;
ops.Add((Upsert, Args(r)));
}
if (ops.Count == 0) return;
Db.EnqueueBatch(ops);
}
/// <summary>Tutte le aste concluse, dalla piu' recente.</summary>
private static object?[] Args(CompletedAuctionRecord r)
{
// Un record senza id non può essere una chiave primaria: gliene si dà uno
// proprio, così resta nello storico invece di sparire o di sovrascriverne un altro.
var id = string.IsNullOrEmpty(r.AuctionId) ? "senza-id-" + Guid.NewGuid().ToString("N") : r.AuctionId;
if (string.IsNullOrEmpty(r.AuctionId)) r.AuctionId = id;
return new object?[]
{
id, r.ProductKey ?? "", r.Name ?? "", r.OriginalUrl ?? "",
AuctionDatabase.Iso(r.AddedAt == default ? r.EndedAt : r.AddedAt),
AuctionDatabase.Iso(r.EndedAt),
r.FinalPrice, r.Winner ?? "", r.WonByMe, string.IsNullOrEmpty(r.Outcome) ? "Chiusa" : r.Outcome,
r.MyBidsUsed, r.TotalResets, r.BuyNowPrice, r.WinnerBidsPaid, r.WinnerBidsFree
};
}
/// <summary>Tutte le aste concluse, dalla più recente. Copie: modificarle non cambia l'archivio.</summary>
public static List<CompletedAuctionRecord> LoadAll()
{
Db.Flush();
lock (_lock)
{
return LoadInternal()
.OrderByDescending(r => r.EndedAt)
.ToList();
var version = Db.AuctionsVersion;
if (_cache != null && version == _cacheVersion) return _cache.Select(Clone).ToList();
var list = Db.Query(Select).Select(FromRow).ToList();
_cache = list;
_cacheVersion = version;
return list.Select(Clone).ToList();
}
}
internal static CompletedAuctionRecord FromRow(SqliteRow row) => new()
{
AuctionId = row.Str("auction_id"),
Name = row.Str("name"),
ProductKey = row.Str("product_key"),
OriginalUrl = row.Str("url"),
FinalPrice = row.Dbl("final_price"),
Winner = row.Str("winner_user"),
WonByMe = row.Bool("won_by_me"),
Outcome = row.Str("outcome"),
MyBidsUsed = row.Int("my_bids"),
TotalResets = row.Int("resets"),
BuyNowPrice = row.DblOrNull("buy_now_price"),
WinnerBidsPaid = row.IntOrNull("winner_bids_paid"),
WinnerBidsFree = row.IntOrNull("winner_bids_free"),
AddedAt = row.Date("added_at") ?? DateTime.MinValue,
EndedAt = row.Date("end_time") ?? row.Date("closed_at") ?? DateTime.MinValue
};
private static CompletedAuctionRecord Clone(CompletedAuctionRecord r) => new()
{
AuctionId = r.AuctionId, Name = r.Name, ProductKey = r.ProductKey, OriginalUrl = r.OriginalUrl,
FinalPrice = r.FinalPrice, Winner = r.Winner, WonByMe = r.WonByMe, Outcome = r.Outcome,
MyBidsUsed = r.MyBidsUsed, TotalResets = r.TotalResets, BuyNowPrice = r.BuyNowPrice,
WinnerBidsPaid = r.WinnerBidsPaid, WinnerBidsFree = r.WinnerBidsFree,
AddedAt = r.AddedAt, EndedAt = r.EndedAt
};
/// <summary>
/// Sostituisce l'intero storico. Usato dalla pulizia e dalla riparazione: sono le
/// due sole operazioni che riscrivono tutto invece di aggiungere una riga.
/// Tiene soltanto le aste indicate (aggiornandole) e cancella le altre, con tutto
/// ciò che ne dipende. È l'operazione della pulizia: si passa dalla copia di
/// sicurezza prima.
/// </summary>
public static void ReplaceAll(List<CompletedAuctionRecord> records)
{
if (records == null) return;
lock (_lock) SaveInternal(records);
var ops = new List<(string, object?[])>(records.Count * 2 + 4)
{
("CREATE TEMP TABLE IF NOT EXISTS keep_ids(id TEXT PRIMARY KEY)", Array.Empty<object?>()),
("DELETE FROM keep_ids", Array.Empty<object?>())
};
foreach (var r in records)
{
if (r == null) continue;
var args = Args(r);
ops.Add(("INSERT OR IGNORE INTO keep_ids(id) VALUES(?1)", new object?[] { args[0] }));
}
ops.Add(("DELETE FROM auctions WHERE outcome IS NOT NULL AND auction_id NOT IN (SELECT id FROM keep_ids)", Array.Empty<object?>()));
foreach (var r in records)
{
if (r == null) continue;
ops.Add((Upsert, Args(r)));
}
ops.Add(("DELETE FROM keep_ids", Array.Empty<object?>()));
Db.EnqueueBatch(ops);
Db.Flush();
}
/// <summary>
/// Ripara all'avvio i campi deducibili e salva solo se qualcosa e' cambiato davvero.
/// Restituisce il resoconto per il registro.
/// </summary>
/// <summary>Ripara all'avvio i campi deducibili e salva solo se qualcosa è cambiato.</summary>
public static StatsMaintenance.RepairReport RepairOnStartup()
{
lock (_lock)
{
var list = LoadInternal();
var report = StatsMaintenance.Repair(list);
// Riscrivere un file da qualche megabyte a ogni avvio, per nulla, e' uno
// spreco che si nota: si salva soltanto se c'e' stato un cambiamento.
if (report.AnythingDone) SaveInternal(list);
return report;
}
var list = LoadAll();
var report = StatsMaintenance.Repair(list);
if (report.AnythingDone) AppendMany(list);
return report;
}
/// <summary>
/// Copia lo storico in <c>Backups</c> con la data nel nome, e restituisce il
/// percorso. Serve prima di ogni operazione che riscrive tutto: la pulizia non e'
/// reversibile, e mesi di raccolta non possono dipendere da un clic sicuro di se'.
/// Copia l'intero database in <c>Backups</c> con la data nel nome, e restituisce
/// il percorso. Prima di ogni operazione che cancella.
/// </summary>
public static string BackupNow()
{
lock (_lock)
{
var cartella = AppPaths.BackupsFolder;
Directory.CreateDirectory(cartella);
var cartella = AppPaths.BackupsFolder;
Directory.CreateDirectory(cartella);
var nome = $"completed-auctions_{DateTime.Now:yyyyMMdd_HHmmss}.json";
var destinazione = Path.Combine(cartella, nome);
if (File.Exists(FilePath)) File.Copy(FilePath, destinazione, overwrite: true);
else File.WriteAllText(destinazione, "[]");
return destinazione;
}
var destinazione = Path.Combine(cartella, $"autobidder_{DateTime.Now:yyyyMMdd_HHmmss}.sqlite");
Db.BackupTo(destinazione);
return destinazione;
}
/// <summary>Svuota lo storico cancellando il file.</summary>
/// <summary>Svuota tutte le osservazioni (aste, puntate, decisioni, misure). I prodotti restano.</summary>
public static void Clear()
{
lock (_lock)
{
try
{
if (File.Exists(FilePath)) File.Delete(FilePath);
_cache = null;
}
catch (Exception ex)
{
Console.WriteLine($"[STATS] Errore pulizia storico: {ex.Message}");
_cache = null;
}
}
Db.WipeObservations();
lock (_lock) { _cache = null; _cacheVersion = -1; }
}
/// <summary>
@@ -214,16 +236,13 @@ namespace AutoBidder.Utilities
AverageMyBids = items.Count > 0 ? items.Average(x => x.MyBidsUsed) : 0,
// Le chiusure per intero, non solo min/media/max: la forma della
// distribuzione e' cio' su cui PriceAdvisor decide, e riassumerla
// in tre numeri butterebbe via proprio l'informazione che serve.
// distribuzione e' cio' su cui PriceAdvisor decide.
FinalPrices = withPrice.Select(x => x.FinalPrice).OrderBy(v => v).ToList(),
BuyNowPrice = withPrice
.Select(x => x.BuyNowPrice)
.LastOrDefault(v => v is > 0),
// Solo i conteggi che superano il controllo di coerenza: un dato
// storto nelle medie sposterebbe i limiti di prezzo senza che
// nessuno se ne accorga. Vedi AuctionIntegrity.
// Solo i conteggi che superano il controllo di coerenza. Vedi AuctionIntegrity.
WinnerBids = items
.Select(AuctionIntegrity.TrustedWinnerBids)
.Where(b => b.HasValue)
@@ -231,10 +250,6 @@ namespace AutoBidder.Utilities
.OrderBy(b => b)
.ToList(),
// Pagate e gratis separate. Serve perche' cambia la sostanza del
// conto: sui dati raccolti l'84% delle puntate dei vincitori sono
// gratuite, quindi il costo in denaro di una vittoria e' molto piu'
// basso di quanto direbbe il solo numero di puntate.
WinnerBidsPaidTotal = items
.Where(x => AuctionIntegrity.CheckWinnerBids(x).IsTrustworthy)
.Sum(x => x.WinnerBidsPaid ?? 0),
@@ -242,9 +257,6 @@ namespace AutoBidder.Utilities
.Where(x => AuctionIntegrity.CheckWinnerBids(x).IsTrustworthy)
.Sum(x => x.WinnerBidsFree ?? 0),
// Risparmio asta per asta: si puo' calcolare solo dove si conoscono
// sia il valore sia le puntate del vincitore, quindi la lista e'
// piu' corta del totale. E' voluto: meglio poche misure vere.
SavingsPercents = items
.Where(x => x.BuyNowPrice is > 0)
.Select(x => new { x, bids = AuctionIntegrity.TrustedWinnerBids(x) })
@@ -255,8 +267,6 @@ namespace AutoBidder.Utilities
.OrderBy(v => v)
.ToList(),
// Ogni quanto passa: serve a sapere se conviene aspettare la
// prossima invece di inseguire questa.
HoursBetweenAuctions = items.Count >= 2
? Math.Round((items.Max(x => x.EndedAt) - items.Min(x => x.EndedAt))
.TotalHours / (items.Count - 1), 2)
@@ -270,64 +280,6 @@ namespace AutoBidder.Utilities
.OrderByDescending(s => s.Count)
.ToList();
}
private static List<CompletedAuctionRecord> LoadInternal()
{
try
{
if (!File.Exists(FilePath))
{
_cache = null;
return new List<CompletedAuctionRecord>();
}
// Una lettura di data di modifica costa microsecondi; la lettura piu'
// la deserializzazione che evita ne costa decine di millisecondi.
var stamp = File.GetLastWriteTimeUtc(FilePath);
if (_cache != null && stamp == _cacheStamp) return _cache;
var json = File.ReadAllText(FilePath);
var list = string.IsNullOrWhiteSpace(json)
? new List<CompletedAuctionRecord>()
: JsonSerializer.Deserialize<List<CompletedAuctionRecord>>(json)
?? new List<CompletedAuctionRecord>();
_cache = list;
_cacheStamp = stamp;
return list;
}
catch (Exception ex)
{
Console.WriteLine($"[STATS] Errore lettura storico: {ex.Message}");
// Non si sa cosa ci sia sul disco: la prossima volta si rilegge.
_cache = null;
return new List<CompletedAuctionRecord>();
}
}
private static void SaveInternal(List<CompletedAuctionRecord> list)
{
try
{
if (!Directory.Exists(AppDataFolder))
Directory.CreateDirectory(AppDataFolder);
var json = JsonSerializer.Serialize(list, JsonOptions);
File.WriteAllText(FilePath, json);
// La copia in memoria e' esattamente quello che si e' appena scritto:
// tenerla evita di rileggere il file al primo uso successivo.
_cache = list;
_cacheStamp = File.GetLastWriteTimeUtc(FilePath);
}
catch (Exception ex)
{
Console.WriteLine($"[STATS] Errore salvataggio storico: {ex.Message}");
_cache = null;
}
}
}
/// <summary>Riepilogo per prodotto, ricavato dallo storico delle aste concluse.</summary>
@@ -356,21 +308,13 @@ namespace AutoBidder.Utilities
/// <summary>
/// Puntate spese dai vincitori, in ordine crescente. Ci sono solo le aste il cui
/// conteggio ha superato il controllo di coerenza, quindi la lista puo' essere piu'
/// corta di <see cref="Count"/>: e' voluto, meglio poche misure buone che molte
/// dubbie.
/// conteggio ha superato il controllo di coerenza.
/// </summary>
public List<int> WinnerBids { get; set; } = new();
/// <summary>Su quante aste il conteggio delle puntate e' utilizzabile.</summary>
public int WinnerBidsSample => WinnerBids.Count;
// ── Le puntate che servono per vincere: tutta la distribuzione ────
//
// Non solo la mediana: minimo e massimo dicono se il prodotto e' regolare o se
// ogni tanto degenera, e i quartili dicono quanto e' larga la forbice. Su un
// articolo dove si vince con 12 puntate o con 900 la mediana da sola inganna.
public int MinWinnerBids => WinnerBids.Count == 0 ? 0 : WinnerBids[0];
public int MaxWinnerBids => WinnerBids.Count == 0 ? 0 : WinnerBids[^1];
@@ -385,8 +329,6 @@ namespace AutoBidder.Utilities
public string MaxWinnerBidsDisplay => WinnerBids.Count == 0 ? "—" : MaxWinnerBids.ToString();
public string P75WinnerBidsDisplay => WinnerBids.Count == 0 ? "—" : P75WinnerBids.ToString("F0");
// ── Prezzi: quartili oltre a min/media/max ───────────────────────
public double MedianFinalPrice => Pct(FinalPrices, 0.50);
public double P25FinalPrice => Pct(FinalPrices, 0.25);
public double P75FinalPrice => Pct(FinalPrices, 0.75);
@@ -395,12 +337,6 @@ namespace AutoBidder.Utilities
public string MedianFinalPriceDisplay => FinalPrices.Count == 0 ? "—" : $"{MedianFinalPrice:F2} €";
public string P75FinalPriceDisplay => FinalPrices.Count == 0 ? "—" : $"{P75FinalPrice:F2} €";
// ── Risparmio: non solo il tipico, ma il migliore e il peggiore ──
//
// Si calcola asta per asta, non sui riassunti: il risparmio della vittoria
// piu' economica non e' il valore meno il prezzo minimo, perche' l'asta piu'
// economica non e' detto sia anche quella con meno puntate.
/// <summary>Risparmio percentuale di ogni asta di cui si conosce tutto, in ordine crescente.</summary>
public List<double> SavingsPercents { get; set; } = new();
@@ -440,12 +376,7 @@ namespace AutoBidder.Utilities
/// <summary>
/// Quota di puntate gratuite fra quelle spese dai vincitori, in percentuale.
///
/// <para>Non e' un dettaglio: sui dati raccolti e' l'84%. Vuol dire che chi vince
/// queste aste lo fa in gran parte con puntate regalate, e che il costo in denaro di
/// una vittoria e' molto piu' basso del numero di puntate moltiplicato per il loro
/// prezzo. Il costo mostrato resta quello prudente - tutte pagate - ma questa quota
/// dice di quanto si puo' abbassare accumulando puntate gratis.</para>
/// Sui dati raccolti e' l'84%: chi vince lo fa in gran parte con puntate regalate.
/// </summary>
public double? FreeBidShare
{
@@ -466,9 +397,7 @@ namespace AutoBidder.Utilities
/// <summary>
/// Quanto costa davvero vincere: il prezzo tipico di aggiudicazione piu' le puntate
/// che sono servite a portarlo li'. Il prezzo da solo racconta meta' della storia -
/// un buono da 50 EUR aggiudicato a 4 EUR con 200 puntate e' costato 44 EUR.
/// <c>null</c> se le puntate non sono note: meglio nessun numero che uno finto.
/// che sono servite a portarlo li'. <c>null</c> se le puntate non sono note.
/// </summary>
public double? TypicalWinCost
{
@@ -485,8 +414,7 @@ namespace AutoBidder.Utilities
/// <summary>
/// Risparmio percentuale sul valore del prodotto, calcolato sul costo vero di una
/// vittoria tipica. <c>null</c> se manca il valore o il conteggio delle puntate.
/// Puo' essere negativo: vincere costa piu' che comprare, e va detto.
/// vittoria tipica. Puo' essere negativo: vincere costa piu' che comprare, e va detto.
/// </summary>
public double? TypicalSavingsPercent
{
@@ -501,8 +429,6 @@ namespace AutoBidder.Utilities
}
}
// ── Colonne della griglia ────────────────────────────────────────
public double WinRate => Count > 0 ? WonCount * 100.0 / Count : 0;
public string WinRateDisplay => Count > 0 ? $"{WinRate:F0}%" : "—";
@@ -515,10 +441,6 @@ namespace AutoBidder.Utilities
/// Tetto di prezzo suggerito. Delega a <see cref="PriceAdvisor"/>: il criterio
/// dev'essere uno solo, altrimenti la scheda Storico e la scheda Prodotti
/// mostrerebbero due numeri diversi per lo stesso articolo.
///
/// <para>Qui i limiti propri del prodotto non si conoscono, quindi valgono i
/// predefiniti generali. Il numero completo, che tiene conto delle puntate massime
/// impostate su quell'articolo, sta nella scheda Prodotti.</para>
/// </summary>
public double SuggestedMaxPrice
{
+93
View File
@@ -0,0 +1,93 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using AutoBidder.Data;
namespace AutoBidder.Utilities
{
/// <summary>
/// Esporta le decisioni del motore in CSV con delimitatore punto e virgola e la
/// colonna <c>reason_detail</c> con la motivazione per esteso — come chiede il
/// documento di progetto (§9). Una riga per decisione, in ordine di tempo, con
/// l'esito noto alla chiusura dell'asta.
/// </summary>
public static class DecisionExporter
{
public sealed record Result(bool Success, string Path, int Rows, string Message);
public static Result ExportCsv(string? destination = null, int maxRows = 0)
{
try
{
var db = AuctionDatabase.Instance;
db.Flush();
var sql =
"SELECT d.decision_id, d.ts, d.auction_id, a.name, a.product_key, d.mode, d.action, d.price, d.timer, " +
" d.p_win, d.ev_estimate, d.policy, d.regime, d.executed, d.outcome, d.model_version, d.reason_detail, " +
" a.final_price, a.outcome AS auction_outcome, a.won_by_me " +
"FROM bot_decisions d LEFT JOIN auctions a ON a.auction_id = d.auction_id " +
"ORDER BY d.decision_id" + (maxRows > 0 ? $" LIMIT {maxRows}" : "");
var rows = db.Query(sql);
if (rows.Count == 0) return new Result(false, "", 0, "Nessuna decisione registrata finora.");
var path = destination ?? Path.Combine(AppPaths.ExportFolder, $"decisioni-{DateTime.Now:yyyyMMdd-HHmmss}.csv");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var sb = new StringBuilder();
sb.AppendLine(string.Join(';',
"decision_id", "ts", "auction_id", "asta", "prodotto", "modalita", "azione", "prezzo", "timer",
"p_win", "ev_stimato", "policy", "regime", "eseguita", "esito", "versione_modello",
"prezzo_finale", "esito_asta", "vinta_da_me", "reason_detail"));
foreach (var r in rows)
{
sb.AppendLine(string.Join(';',
r.Long("decision_id"),
r.Str("ts"),
Csv(r.Str("auction_id")),
Csv(r.Str("name")),
Csv(r.Str("product_key")),
r.Str("mode"),
r.Str("action"),
Num(r.DblOrNull("price")),
Num(r.DblOrNull("timer")),
Num(r.DblOrNull("p_win"), 5),
Num(r.DblOrNull("ev_estimate"), 4),
Csv(r.Str("policy")),
Csv(r.Str("regime")),
r.Bool("executed") ? "1" : "0",
Csv(r.Str("outcome")),
Csv(r.Str("model_version")),
Num(r.DblOrNull("final_price")),
Csv(r.Str("auction_outcome")),
r.Bool("won_by_me") ? "1" : "0",
Csv(r.Str("reason_detail"))));
}
File.WriteAllText(path, sb.ToString(), new UTF8Encoding(true));
return new Result(true, path, rows.Count, $"{rows.Count} decisioni esportate");
}
catch (Exception ex)
{
return new Result(false, "", 0, $"esportazione non riuscita: {ex.Message}");
}
}
/// <summary>Virgola decimale, come si aspetta Excel in italiano; vuoto se manca.</summary>
private static string Num(double? v, int decimals = 2) =>
v is { } x && double.IsFinite(x) ? x.ToString("F" + decimals, CultureInfo.InvariantCulture).Replace('.', ',') : "";
private static string Csv(string? s)
{
if (string.IsNullOrEmpty(s)) return "";
var clean = s.Replace("\r", " ").Replace("\n", " ");
return clean.Contains(';') || clean.Contains('"')
? "\"" + clean.Replace("\"", "\"\"") + "\""
: clean;
}
}
}
+18
View File
@@ -57,6 +57,9 @@ namespace AutoBidder.Utilities
public double? MinPrice { get; set; }
public double? MaxPrice { get; set; }
public int? MaxBids { get; set; }
/// <summary>Valore reale V scelto dall'utente; null = Compralo Ora.</summary>
public double? MarketValue { get; set; }
public string? AutoAddState { get; set; }
/// <summary>Orizzonte proprio per l'aggiunta automatica, in minuti.</summary>
@@ -242,8 +245,23 @@ namespace AutoBidder.Utilities
card.MinPrice = product.MinPrice;
card.MaxPrice = product.MaxPrice;
card.MaxBids = product.MaxClicks;
card.MarketValue = product.MarketValue;
card.MaxStartMinutes = product.MaxStartMinutes;
card.AutoAddState = product.AutoAddState;
// Il valore reale finisce anche sul prodotto nel database: è da lì
// che le analisi lo leggono, e da lì che le aste nuove lo ereditano.
try
{
var now = Data.AuctionDatabase.Now();
Data.AuctionDatabase.Instance.Enqueue(
"INSERT INTO items(product_key, title, market_price_est, market_price_source, first_seen_at, updated_at) " +
"VALUES(?1, ?2, ?3, CASE WHEN ?3 IS NULL THEN NULL ELSE 'utente' END, ?4, ?4) " +
"ON CONFLICT(product_key) DO UPDATE SET market_price_est = excluded.market_price_est, " +
" market_price_source = excluded.market_price_source, updated_at = excluded.updated_at",
key, product.DisplayName ?? "", product.MarketValue, now);
}
catch { /* il database non deve fermare il salvataggio delle opzioni */ }
}
Save(data);
+29
View File
@@ -159,6 +159,12 @@ namespace AutoBidder.Utilities
/// </summary>
public string LogFolder { get; set; } = "";
/// <summary>
/// File del database SQLite. Vuoto = <c>%LocalAppData%\AutoBidder\Database\autobidder.sqlite</c>.
/// Non va messo in una cartella sincronizzata.
/// </summary>
public string DatabasePath { get; set; } = "";
// ═══════════════════════════════════════════════════════════════════
// REGISTRI SU FILE
// ═══════════════════════════════════════════════════════════════════
@@ -594,6 +600,29 @@ namespace AutoBidder.Utilities
/// </summary>
public double DailyBudgetEuro { get; set; } = 0;
// ── Gestore del rischio (vedi Services/RiskManager) ──────────────
// Tutti in euro o in numero, tutti dell'utente, tutti con 0 = nessun limite.
// Lo stop-loss e il drawdown portano in HALT, che resta finché non lo si toglie.
/// <summary>Perdita netta del giorno oltre la quale si entra in HALT (0 = mai).</summary>
public double DailyStopLossEuro { get; set; } = 0;
/// <summary>Discesa massima del saldo cumulato dal suo picco oltre la quale si entra in HALT (0 = mai).</summary>
public double MaxDrawdownEuro { get; set; } = 0;
/// <summary>Quante aste al massimo su cui spendere insieme (0 = nessun limite).</summary>
public int MaxConcurrentActiveAuctions { get; set; } = 0;
/// <summary>Fee di transazione sulla vincita: circa 1 € su Bidoo. Entra nel valore atteso.</summary>
public double TransactionFeeEuro { get; set; } = 1.0;
/// <summary>
/// Considerare il «Compralo Ora» come copertura: chi perde compra al listino
/// recuperando le puntate spese, e la perdita si limita a listino valore reale.
/// Vedi Ml/Theory. È un'ipotesi forte, e per questo è una scelta dell'utente.
/// </summary>
public bool BuyNowHedgeEnabled { get; set; } = true;
/// <summary>
/// Costo medio di una puntata, in euro. Entra nel budget e nel tetto di prezzo
/// consigliato per ogni prodotto. Predefinito 0,20: è il valore che il motore usa
+37 -22
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutoBidder.Data;
namespace AutoBidder.Utilities
{
@@ -9,11 +10,10 @@ namespace AutoBidder.Utilities
/// Azzera le statistiche registrate, una voce alla volta e solo quelle scelte.
///
/// <para>Ogni voce è un archivio con un padrone diverso, e ognuno si svuota nel modo
/// suo: lo storico passa dalla copia di sicurezza, le statistiche per prodotto tengono
/// le opzioni e azzerano i numeri, l'apprendimento dimentica anche in memoria — se no
/// il modello ancora vivo riscriverebbe il file al primo salvataggio. I dossier delle
/// aste non si toccano da qui: sono i dati grezzi da cui tutto il resto si
/// ricostruisce.</para>
/// suo: le osservazioni del database passano dalla copia di sicurezza, le statistiche
/// per prodotto tengono le opzioni e azzerano i numeri, l'apprendimento dimentica
/// anche in memoria — se no il modello ancora vivo riscriverebbe il file al primo
/// salvataggio.</para>
///
/// <para><see cref="Measure"/> pesa ogni voce prima: cancellare è irreversibile, e
/// dire quanto sparisce è il minimo per una scelta informata.</para>
@@ -22,10 +22,13 @@ namespace AutoBidder.Utilities
{
public sealed class Options
{
/// <summary>Le osservazioni del database: aste, puntate, interrogazioni, decisioni, misure.</summary>
public bool History { get; set; } = true;
public bool ProductStats { get; set; } = true;
public bool BidLeadMeasures { get; set; } = true;
public bool Exports { get; set; } = true;
/// <summary>Gli archivi JSON delle versioni precedenti (storico compatto, archivi mensili).</summary>
public bool LegacyArchives { get; set; } = true;
public bool Learning { get; set; }
@@ -60,7 +63,8 @@ namespace AutoBidder.Utilities
public static Sizes Measure()
{
int aste = 0, misure = 0;
try { aste = CompletedAuctionsStore.LoadAll().Count; } catch { }
long dbBytes = 0;
try { aste = (int)AuctionDatabase.Instance.Count("auctions"); dbBytes = AuctionDatabase.Instance.SizeBytes; } catch { }
try { misure = BidLeadStats.All().Count; } catch { }
var export = ListFiles(AppPaths.ExportFolder, "*");
@@ -68,7 +72,7 @@ namespace AutoBidder.Utilities
var learning = ListFiles(LearningFolder, "*");
return new Sizes(
aste, SizeOf(CompletedAuctionsStore.StorePath),
aste, dbBytes,
SizeOf(AppPaths.ProductsFile),
misure, SizeOf(AppPaths.BidLeadStatsFile),
export.Count, export.Sum(SizeOf),
@@ -92,11 +96,12 @@ namespace AutoBidder.Utilities
if (o.History)
{
Try(report, "storico", () =>
Try(report, "database", () =>
{
report.BytesFreed += SizeOf(CompletedAuctionsStore.StorePath);
var prima = AuctionDatabase.Instance.SizeBytes;
CompletedAuctionsStore.Clear();
report.Steps.Add($"storico svuotato (copia in {backup})");
report.BytesFreed += Math.Max(0, prima - AuctionDatabase.Instance.SizeBytes);
report.Steps.Add($"osservazioni del database azzerate (copia in {backup})");
});
}
@@ -128,10 +133,10 @@ namespace AutoBidder.Utilities
if (o.LegacyArchives)
{
var (n, bytes) = DeleteAll(report, "archivi mensili", LegacyFiles());
var (n, bytes) = DeleteAll(report, "archivi precedenti", LegacyFiles());
report.FilesDeleted += n;
report.BytesFreed += bytes;
report.Steps.Add($"archivi mensili: {n} file tolti");
report.Steps.Add($"archivi delle versioni precedenti: {n} file tolti");
}
if (o.Learning)
@@ -144,7 +149,7 @@ namespace AutoBidder.Utilities
var (n, bytes) = DeleteAll(report, "apprendimento", ListFiles(LearningFolder, "*"));
report.FilesDeleted += n;
report.BytesFreed += bytes;
report.Steps.Add("apprendimento azzerato: ristudia i dossier al prossimo avvio");
report.Steps.Add("apprendimento azzerato: ristudia le aste al prossimo avvio");
}
return report;
@@ -159,21 +164,31 @@ namespace AutoBidder.Utilities
// ── Attrezzi ─────────────────────────────────────────────────────
/// <summary>Gli archivi mensili delle versioni precedenti: aste-AAAA-MM.jsonl e .json.</summary>
/// <summary>
/// Gli archivi JSON delle versioni precedenti: lo storico compatto, gli archivi
/// mensili delle schede, il vecchio storico dei prodotti. Il database li ha
/// sostituiti tutti.
/// </summary>
private static List<string> LegacyFiles()
{
var result = new List<string>();
var folder = AppPaths.StatsFolder;
if (!Directory.Exists(folder)) return new List<string>();
if (!Directory.Exists(folder)) return result;
return Directory.EnumerateFiles(folder, "aste-*.json*")
.Where(f =>
try
{
foreach (var f in Directory.EnumerateFiles(folder, "aste-*.json*"))
{
var name = Path.GetFileName(f);
return name.Length >= 12 && char.IsDigit(name[5]) &&
(name.EndsWith(".jsonl", StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(".json", StringComparison.OrdinalIgnoreCase));
})
.ToList();
if (name.Length >= 12 && char.IsDigit(name[5])) result.Add(f);
}
var compact = Path.Combine(folder, "completed-auctions.json");
if (File.Exists(compact)) result.Add(compact);
}
catch { }
return result;
}
private static List<string> ListFiles(string folder, string pattern)
+1 -2
View File
@@ -94,8 +94,7 @@ namespace AutoBidder.Utilities
var days = settings.LogRetentionDays;
var removed = FileLogWriter.PurgeOlderThan(AppPaths.LogFolder, "app-*.log", days)
+ FileLogWriter.PurgeOlderThan(AppPaths.LogFolder, "puntate-*.log", days)
+ FileLogWriter.PurgeOlderThan(AppPaths.AuctionLogFolder, "*.jsonl", days);
+ FileLogWriter.PurgeOlderThan(AppPaths.LogFolder, "puntate-*.log", days);
if (removed > 0) App("INFO", $"Registri scaduti rimossi: {removed} (oltre {days} giorni)");
}
+13
View File
@@ -100,6 +100,18 @@ namespace AutoBidder.ViewModels
}
}
/// <summary>Valore reale del bene: vuoto = il «Compralo Ora» di Bidoo.</summary>
public string MarketValueText
{
get => Format(Rule.MarketValue);
set
{
Rule.MarketValue = ParseDouble(value);
OnPropertyChanged();
OnPropertyChanged(nameof(HasCustomLimits));
}
}
/// <summary>
/// Orizzonte proprio: oltre quanti minuti dall'inizio non aggiungere l'asta da
/// sola. Vuoto = vale il predefinito generale, <c>0</c> = nessun limite qui.
@@ -410,6 +422,7 @@ namespace AutoBidder.ViewModels
OnPropertyChanged(nameof(MinPriceText));
OnPropertyChanged(nameof(MaxPriceText));
OnPropertyChanged(nameof(MaxClicksText));
OnPropertyChanged(nameof(MarketValueText));
OnPropertyChanged(nameof(MaxStartMinutesText));
OnPropertyChanged(nameof(StateChoice));
OnPropertyChanged(nameof(HasCustomLimits));
+11 -11
View File
@@ -403,20 +403,20 @@
<Target Name="Backtest">
<PropertyGroup>
<CartellaDossier Condition="'$(CartellaDossier)' == ''">$(LOCALAPPDATA)\AutoBidder\Dati\Registri\Aste</CartellaDossier>
<PercorsoDb Condition="'$(PercorsoDb)' == ''">$(LOCALAPPDATA)\AutoBidder\Database\autobidder.sqlite</PercorsoDb>
<MaxDossier Condition="'$(MaxDossier)' == ''">0</MaxDossier>
<Riepilogo Condition="'$(Riepilogo)' == ''">$(Radice)\bin\backtest-report.txt</Riepilogo>
</PropertyGroup>
<Error Condition="!Exists('$(CartellaDossier)')"
Text="Cartella dei dossier non trovata: $(CartellaDossier). Indicala con -p:CartellaDossier=..., oppure accendi «Dossier per ogni asta» nelle Impostazioni." />
<Error Condition="!Exists('$(PercorsoDb)')"
Text="Database non trovato: $(PercorsoDb). Indicalo con -p:PercorsoDb=..., oppure avvia l'applicazione una volta perché lo crei." />
<Message Importance="High" Text="== Rigiocata sui dossier ==" />
<Message Importance="High" Text=" cartella : $(CartellaDossier)" />
<Message Importance="High" Text=" database : $(PercorsoDb)" />
<Exec WorkingDirectory="$(Radice)"
Command="dotnet test &quot;$(TestProj)&quot; --nologo -v q --artifacts-path &quot;$(CartellaProve)&quot; --filter &quot;FullyQualifiedName~RealDossierBacktest&quot; --logger &quot;console;verbosity=detailed&quot;"
EnvironmentVariables="$(AmbientePulito);AUTOBIDDER_BACKTEST_DIR=$(CartellaDossier);AUTOBIDDER_BACKTEST_MAX=$(MaxDossier);AUTOBIDDER_BACKTEST_OUT=$(Riepilogo)" />
EnvironmentVariables="$(AmbientePulito);AUTOBIDDER_BACKTEST_DIR=$(PercorsoDb);AUTOBIDDER_BACKTEST_MAX=$(MaxDossier);AUTOBIDDER_BACKTEST_OUT=$(Riepilogo)" />
<Message Importance="High" Text=" riepilogo in $(Riepilogo)" />
</Target>
@@ -431,7 +431,7 @@
-->
<Target Name="Apprendimento">
<PropertyGroup>
<CartellaDossier Condition="'$(CartellaDossier)' == ''">$(LOCALAPPDATA)\AutoBidder\Dati\Registri\Aste</CartellaDossier>
<PercorsoDb Condition="'$(PercorsoDb)' == ''">$(LOCALAPPDATA)\AutoBidder\Database\autobidder.sqlite</PercorsoDb>
<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
@@ -439,15 +439,15 @@
<UtenteMl Condition="'$(UtenteMl)' == ''"></UtenteMl>
</PropertyGroup>
<Error Condition="!Exists('$(CartellaDossier)')"
Text="Cartella dei dossier non trovata: $(CartellaDossier). Indicala con -p:CartellaDossier=..." />
<Error Condition="!Exists('$(PercorsoDb)')"
Text="Database non trovato: $(PercorsoDb). Indicalo con -p:PercorsoDb=..." />
<Message Importance="High" Text="== Valutazione del modello sui dossier ==" />
<Message Importance="High" Text=" cartella : $(CartellaDossier)" />
<Message Importance="High" Text="== Valutazione del modello sulle aste del database ==" />
<Message Importance="High" Text=" database : $(PercorsoDb)" />
<Exec WorkingDirectory="$(Radice)"
Command="dotnet test &quot;$(TestProj)&quot; --nologo -v q --artifacts-path &quot;$(CartellaProve)&quot; --filter &quot;FullyQualifiedName~MlModelBacktest&quot; --logger &quot;console;verbosity=detailed&quot;"
EnvironmentVariables="$(AmbientePulito);AUTOBIDDER_ML_DIR=$(CartellaDossier);AUTOBIDDER_ML_MAX=$(MaxDossier);AUTOBIDDER_ML_OUT=$(RiepilogoMl);AUTOBIDDER_ML_ME=$(UtenteMl)" />
EnvironmentVariables="$(AmbientePulito);AUTOBIDDER_ML_DB=$(PercorsoDb);AUTOBIDDER_ML_MAX=$(MaxDossier);AUTOBIDDER_ML_OUT=$(RiepilogoMl);AUTOBIDDER_ML_ME=$(UtenteMl)" />
<Message Importance="High" Text=" rapporto in $(RiepilogoMl)" />
</Target>