Sostituisci le euristiche a soglia con regime e anticipo adattivi; un solo archivio per asta

Le strategie a numero fisso (calore, ritiro morbido, anti-bot, avversari aggressivi,
puntata probabilistica, velocità del prezzo, esaurimento, concorrenza) e le impostazioni
morte (cadenze di polling, finestra critica, database, log avanzati, suggerimenti
sull'anticipo, schede dettagliate) non ci sono più: rigiocate sui dossier non fermavano
una puntata sbagliata senza fermarne anche di giuste, e i loro numeri andavano tarati a
mano. Restano i paletti dell'utente (tetti, budget, fascia oraria) e il duello.

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 00:11:49 +02:00
co-authored by Claude Fable 5.1
parent afb778ea26
commit 8b7c39c9e3
36 changed files with 2111 additions and 1155 deletions
+7
View File
@@ -77,6 +77,13 @@
Content="Apri la cartella" Margin="0,0,8,0"
Click="OpenFolderButton_Click"/>
<Button x:Name="ClearExportsButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Danger}"
Content="Svuota la cartella" Margin="0,0,8,0"
ToolTip="Cancella tutti i file esportati finora. Sono copie: si rigenerano in qualunque momento da qui."
Click="ClearExportsButton_Click"/>
<Button x:Name="ResetFiltersButton" Style="{StaticResource IconButton}"
ToolTip="Azzera i filtri" Click="ResetFiltersButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE711;"/>
+12
View File
@@ -142,6 +142,9 @@ namespace AutoBidder.Controls
private void OpenFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenExportFolderClickedEvent, this));
private void ClearExportsButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearExportsClickedEvent, this));
private void OpenLastFileButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenLastFileClickedEvent, this));
@@ -171,6 +174,15 @@ namespace AutoBidder.Controls
public static readonly RoutedEvent OpenLastFileClickedEvent = EventManager.RegisterRoutedEvent(
"OpenLastFileClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ExportControl));
public static readonly RoutedEvent ClearExportsClickedEvent = EventManager.RegisterRoutedEvent(
"ClearExportsClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ExportControl));
public event RoutedEventHandler ClearExportsClicked
{
add { AddHandler(ClearExportsClickedEvent, value); }
remove { RemoveHandler(ClearExportsClickedEvent, value); }
}
public event RoutedEventHandler ExportRequested
{
add { AddHandler(ExportRequestedEvent, value); }
+67
View File
@@ -106,6 +106,7 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Stato -->
@@ -212,6 +213,72 @@
Text="Nessuna valutazione ancora. Premi «Valuta ora»."/>
</StackPanel>
</Border>
<!-- Autonomia sul momento -->
<Border Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="3" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Autonomia sul momento" Margin="0,0,0,10"
Help="Due cose che il sistema decide da solo sulla sessione in corso, dentro i paletti delle impostazioni.&#10;&#10;ANTICIPO: margine di sicurezza più la coda alta (p99) della latenza misurata adesso. Una puntata arrivata tardi alza il margine di 150 ms subito; venti puntate in tempo lo abbassano di 25. Il risultato resta fra il minimo e il massimo impostati; un anticipo scritto a mano su una singola asta vince sempre.&#10;&#10;REGIME: per ogni asta seguita. Calmo = punta se il valore atteso è positivo. Sfogo = gli altri si stanno battendo, si aspetta che si sfoghino: si rientra solo dopo «pazienza» cicli buoni di fila. Sondaggio = una puntata di prova; se viene coperta entro 9 s si torna a Sfogo e la pazienza raddoppia (fino a 24)."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="2*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Anticipo adattivo" FontWeight="SemiBold" Margin="0,0,0,8"
Foreground="{DynamicResource Brush.Text}"/>
<UniformGrid Columns="3" Rows="2">
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatLeadNow" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="anticipo adesso"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatLatP99" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="coda del ping (p99)"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatLatMargin" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="margine"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatLatP50" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="ping mediano"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatLatSamples" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="campioni"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatLatLate" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="puntate tardive / totali"/>
</StackPanel>
</UniformGrid>
<TextBlock x:Name="StatLeadHint" Style="{StaticResource Hint}" Margin="0,8,0,0" Text=""/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="Regime per asta" FontWeight="SemiBold" Margin="0,0,0,8"
Foreground="{DynamicResource Brush.Text}"/>
<DataGrid x:Name="RegimeGrid" MaxHeight="240" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Asta" Binding="{Binding Asta}" Width="*"/>
<DataGridTextColumn Header="Stato" Binding="{Binding StatoAsta}" Width="Auto" MinWidth="60"/>
<DataGridTextColumn Header="Regime" Binding="{Binding Regime}" Width="Auto" MinWidth="80"/>
<DataGridTextColumn Header="Pazienza" Binding="{Binding Pazienza}" Width="Auto" MinWidth="70"/>
<DataGridTextColumn Header="Sondaggi coperti" Binding="{Binding Sondaggi}" Width="Auto" MinWidth="110"/>
<DataGridTextColumn Header="P senza risposta" Binding="{Binding Prob}" Width="Auto" MinWidth="110"/>
<DataGridTextColumn Header="Valore atteso" Binding="{Binding Ev}" Width="Auto" MinWidth="100"/>
<DataGridTextColumn Header="Duello" Binding="{Binding Duello}" Width="Auto" MinWidth="60"/>
</DataGrid.Columns>
</DataGrid>
<TextBlock x:Name="RegimeHint" Style="{StaticResource Hint}" Margin="0,8,0,0" Text=""/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</Grid>
+68
View File
@@ -44,6 +44,24 @@ namespace AutoBidder.Controls
public string Chiusura { get; init; } = "";
}
private sealed class RegimeRow
{
public string Asta { get; init; } = "";
public string StatoAsta { get; init; } = "";
public string Regime { get; init; } = "";
public string Pazienza { get; init; } = "";
public string Sondaggi { get; init; } = "";
public string Prob { get; init; } = "";
public string Ev { get; init; } = "";
public string Duello { get; init; } = "";
}
/// <summary>
/// Da dove prendere le aste seguite adesso, per la tabella dei regimi. Lo imposta
/// la finestra principale: la scheda non conosce il monitor, e non deve.
/// </summary>
public Func<IReadOnlyList<Models.AuctionInfo>>? AuctionsProvider { get; set; }
private sealed class DecisioneRow
{
public string Ora { get; init; } = "";
@@ -92,6 +110,8 @@ namespace AutoBidder.Controls
StatThreshold.Text = soglia.ToString("N0");
StatBootstrap.Text = LearningService.BootstrapRunning ? "in corso" : "fermo";
RefreshAutonomy(settings);
var decisioni = LearningService.RecentDecisions();
var fermate = decisioni.Count(d => d.Blocked);
StatDecisions.Text = decisioni.Count == 0
@@ -145,6 +165,54 @@ namespace AutoBidder.Controls
0 => "0-8", 1 => "9", 2 => "10-12", 3 => "13-17", 4 => "18-20", _ => "21-23"
};
/// <summary>La sezione «Autonomia sul momento»: modello di latenza e regime per asta.</summary>
private void RefreshAutonomy(AppSettings settings)
{
var lat = LatencyModel.Snapshot();
var lead = LatencyModel.RecommendedLeadMs(settings);
StatLeadNow.Text = $"{lead} ms";
StatLatP99.Text = lat.Samples > 0 ? $"{lat.P99} ms" : "—";
StatLatP50.Text = lat.Samples > 0 ? $"{lat.P50} ms" : "—";
StatLatMargin.Text = $"{lat.MarginMs} ms";
StatLatSamples.Text = lat.Samples.ToString("N0");
StatLatLate.Text = $"{lat.LateBids} / {lat.Bids}";
StatLeadHint.Text = !settings.AdaptiveLeadEnabled
? $"Anticipo adattivo spento nelle impostazioni: vale l'anticipo fisso di {settings.DefaultBidBeforeDeadlineMs} ms."
: lat.Samples < 10
? $"Ancora pochi campioni: finché non ce ne sono dieci vale l'anticipo fisso di {settings.DefaultBidBeforeDeadlineMs} ms."
: $"Margine {lat.MarginMs} + coda {lat.P99} = {lat.MarginMs + lat.P99} ms, tenuto fra {settings.LeadMinMs} e {settings.LeadMaxMs}.";
var aste = AuctionsProvider?.Invoke() ?? Array.Empty<Models.AuctionInfo>();
var righe = aste
.Where(a => a.State != Models.RunState.Stopped)
.Select(a => new RegimeRow
{
Asta = a.Name,
StatoAsta = a.State == Models.RunState.Active ? "Attiva" : "Osserva",
Regime = a.Regime.StatoAttuale switch
{
CompetitionRegime.Stato.Sfogo => $"Sfogo ({a.Regime.CicliBuoni}/{a.Regime.Pazienza})",
CompetitionRegime.Stato.Sondaggio => "Sondaggio",
_ => "Calmo"
},
Pazienza = a.Regime.Pazienza.ToString(),
Sondaggi = a.Regime.SondaggiFalliti.ToString(),
Prob = a.LearnedUnansweredProbability is { } p ? p.ToString("P1") : "—",
Ev = a.LearnedExpectedValue is { } ev ? ev.ToString("+0.000;-0.000") + " €" : "—",
Duello = a.AutoBidDuelDetected ? "sì" : a.AutoResponsesInARow > 0 ? $"{a.AutoResponsesInARow}/5" : ""
})
.ToList();
RegimeGrid.ItemsSource = righe;
var sfogo = righe.Count(r => r.Regime.StartsWith("Sfogo"));
RegimeHint.Text = righe.Count == 0
? "Nessuna asta seguita in questo momento."
: $"{righe.Count} aste seguite: {sfogo} in Sfogo, {righe.Count(r => r.Regime == "Sondaggio")} in Sondaggio, {righe.Count - sfogo - righe.Count(r => r.Regime == "Sondaggio")} Calme.";
}
private void RefreshButton_Click(object sender, RoutedEventArgs e) => Refresh();
private void OpenFolderButton_Click(object sender, RoutedEventArgs e)
+27 -45
View File
@@ -140,20 +140,32 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Anticipo Puntata (millisecondi)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quanti millisecondi prima della scadenza parte la puntata. È il parametro che decide quanto costa vincere: il motore punta solo nei cicli che arrivano fin qui senza che nessun altro abbia puntato.&#10;&#10;Dalla rigiocata di 2.095 aste concluse: sotto il secondo basta in mediana 1 puntata per asta, sotto i 2 s ne servono 15, sotto i 3 s 46. Ma sotto i 600 ms un picco di rete fa arrivare la puntata a giochi chiusi (il ping tocca 444 ms nel p99,9).&#10;&#10;Fascia consigliata: 6002000 ms. Predefinito 1000."/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="DefaultBidBeforeDeadlineMsTextBox" Text="200" Margin="10,10"/>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Anticipo deciso dalla rete" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Acceso: l'anticipo lo sceglie il modello di latenza — margine di sicurezza più la coda alta del ping di questa sessione — e si corregge da solo a ogni puntata tardiva. Si muove solo fra il minimo e il massimo qui sotto.&#10;&#10;Spento: vale l'anticipo fisso della riga seguente.&#10;&#10;In entrambi i casi un anticipo scritto a mano su una singola asta vince sempre."/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="AdaptiveLeadCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Anticipo minimo (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Il modello non scende mai sotto questo valore. Sotto i 300 ms un picco di rete fa arrivare la puntata a giochi chiusi: il ping tocca 444 ms nel p99,9."/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="LeadMinMsTextBox" Text="300" Margin="10,10"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Anticipo massimo (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Il modello non sale mai sopra questo valore. Ogni millisecondo in più è tempo regalato agli avversari: dalla rigiocata di 2.095 aste, sotto il secondo basta in mediana 1 puntata per asta, sotto i 2 s ne servono 15."/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="LeadMaxMsTextBox" Text="1500" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Anticipo fisso (millisecondi)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Usato quando l'anticipo deciso dalla rete è spento, e come punto di partenza finché il modello non ha misurato abbastanza ping. Quanti millisecondi prima della scadenza parte la puntata: il motore punta solo nei cicli che arrivano fin qui senza che nessun altro abbia puntato."/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="DefaultBidBeforeDeadlineMsTextBox" Text="500" Margin="10,10"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Prezzo Minimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="DefaultMinPriceTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="Prezzo Minimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="5" Grid.Column="1" x:Name="DefaultMinPriceTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Prezzo Massimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="DefaultMaxPriceTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="Prezzo Massimo (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="6" Grid.Column="1" x:Name="DefaultMaxPriceTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Max Click" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quante puntate al massimo spendere su una singola asta. Entra anche nel calcolo dei limiti consigliati per prodotto: ogni puntata in più è denaro che non si può mettere sul prezzo. 0 = illimitate."/>
<TextBox Grid.Row="4" Grid.Column="1" x:Name="DefaultMaxClicksTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="7" Grid.Column="0" Text="Max Click" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quante puntate al massimo spendere su una singola asta. Entra anche nel calcolo dei limiti consigliati per prodotto: ogni puntata in più è denaro che non si può mettere sul prezzo. 0 = illimitate."/>
<TextBox Grid.Row="7" Grid.Column="1" x:Name="DefaultMaxClicksTextBox" Text="0" Margin="10,10"/>
</Grid>
</StackPanel>
</Border>
@@ -257,7 +269,7 @@ Consiglio: usa Fermata per caricare senza avviare, poi avvia a mano quelle che v
<StackPanel>
<ctl:HelpHeader Title="Motore di Precisione"
Margin="0,0,0,14"
Help="Ogni asta ha il proprio motore indipendente. Il polling si infittisce solo avvicinandosi alla scadenza: un'asta lontana non consuma richieste inutili, una vicina sì."/>
Help="Ogni asta ha il proprio motore indipendente e interroga Bidoo al massimo che la rete concede: nessuna cadenza da impostare. Se il server chiede di rallentare (429/503) il motore frena da solo e riparte appena può."/>
<Grid>
<Grid.ColumnDefinitions>
@@ -267,33 +279,13 @@ Consiglio: usa Fermata per caricare senza avviare, poi avvia a mano quelle che v
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Polling oltre 60s (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Cadenza quando la scadenza è lontana"/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="PollIntervalFarMsTextBox" Text="2000" Margin="10,10"/>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Limite richieste al secondo" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="0 = nessun tetto (consigliato: si va al massimo che la rete concede). Le puntate hanno corsia preferenziale e non sono mai soggette a questo limite."/>
<TextBox Grid.Row="0" Grid.Column="1" x:Name="MaxRequestsPerSecondTextBox" Text="0" Margin="10,10"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Polling 10-60s (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="PollIntervalMidMsTextBox" Text="900" Margin="10,10"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Polling sotto 10s (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="PollIntervalNearMsTextBox" Text="400" Margin="10,10"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Polling in finestra critica (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Usato solo dalle aste in stato Attiva: in Osserva non c'è puntata da azzeccare"/>
<TextBox Grid.Row="3" Grid.Column="1" x:Name="PollIntervalCriticalMsTextBox" Text="220" Margin="10,10"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="Ampiezza finestra critica (ms)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="4" Grid.Column="1" x:Name="CriticalWindowMsTextBox" Text="4000" Margin="10,10"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="Limite richieste al secondo" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Le puntate hanno corsia preferenziale e non sono soggette a questo limite"/>
<TextBox Grid.Row="5" Grid.Column="1" x:Name="MaxRequestsPerSecondTextBox" Text="40" Margin="10,10"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="Timer di sistema a 1 ms" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Senza, ogni attesa può sforare di ~15 ms e l'anticipo perde significato"/>
<CheckBox Grid.Row="6" Grid.Column="1" x:Name="PrecisionTimerCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Timer di sistema a 1 ms" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Senza, ogni attesa può sforare di ~15 ms e l'anticipo perde significato"/>
<CheckBox Grid.Row="1" Grid.Column="1" x:Name="PrecisionTimerCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
</Grid>
<Border Style="{StaticResource InfoBox}" Margin="0,15,0,0">
@@ -405,7 +397,6 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Misura l'anticipo effettivo"
@@ -413,15 +404,9 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="BidLeadTrackingCheckBox"
Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Proponi correzioni"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Il consiglio non viene mai applicato da solo"/>
<CheckBox Grid.Row="1" Grid.Column="1" x:Name="BidLeadSuggestionsCheckBox"
Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Puntate minime per un consiglio"
<TextBlock Grid.Row="1" Grid.Column="0" Text="Puntate minime per un consiglio"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="2" Grid.Column="1" x:Name="BidLeadMinSamplesTextBox"
<TextBox Grid.Row="1" Grid.Column="1" x:Name="BidLeadMinSamplesTextBox"
Text="15" Margin="10,10"/>
</Grid>
@@ -577,9 +562,6 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button>
</Grid>
<CheckBox x:Name="DetailedStatsCheckBox" IsChecked="True" Margin="0,0,0,6"
Content="Registra una scheda dettagliata per ogni asta conclusa (serie prezzi, puntate per utente, durata)"/>
</StackPanel>
</Border>
@@ -21,6 +21,9 @@ namespace AutoBidder
// Carica impostazioni predefinite aste
DefaultBidBeforeDeadlineMs.Text = settings.DefaultBidBeforeDeadlineMs.ToString();
Settings.AdaptiveLeadCheckBox.IsChecked = settings.AdaptiveLeadEnabled;
Settings.LeadMinMsTextBox.Text = settings.LeadMinMs.ToString();
Settings.LeadMaxMsTextBox.Text = settings.LeadMaxMs.ToString();
DefaultMinPrice.Text = settings.DefaultMinPrice.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
DefaultMaxPrice.Text = settings.DefaultMaxPrice.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
DefaultMaxClicks.Text = settings.DefaultMaxClicks.ToString();
@@ -63,11 +66,6 @@ namespace AutoBidder
}
// Motore di precisione
Settings.PollIntervalFarMsTextBox.Text = settings.PollIntervalFarMs.ToString();
Settings.PollIntervalMidMsTextBox.Text = settings.PollIntervalMidMs.ToString();
Settings.PollIntervalNearMsTextBox.Text = settings.PollIntervalNearMs.ToString();
Settings.PollIntervalCriticalMsTextBox.Text = settings.PollIntervalCriticalMs.ToString();
Settings.CriticalWindowMsTextBox.Text = settings.CriticalWindowMs.ToString();
Settings.MaxRequestsPerSecondTextBox.Text = settings.MaxRequestsPerSecond.ToString("F0", System.Globalization.CultureInfo.InvariantCulture);
Settings.PrecisionTimerCheckBox.IsChecked = settings.PrecisionTimerEnabled;
@@ -88,7 +86,6 @@ namespace AutoBidder
// Anticipo, aste programmate, notifiche, cartelle
Settings.BidLeadTrackingCheckBox.IsChecked = settings.BidLeadTrackingEnabled;
Settings.BidLeadSuggestionsCheckBox.IsChecked = settings.BidLeadSuggestionsEnabled;
Settings.BidLeadMinSamplesTextBox.Text = settings.BidLeadMinSamples.ToString();
Settings.ScheduledBackoffCheckBox.IsChecked = settings.ScheduledAuctionBackoffEnabled;
@@ -104,7 +101,6 @@ namespace AutoBidder
// ed è quello che serve sapere.
RefreshDataFolderFields();
Settings.DetailedStatsCheckBox.IsChecked = settings.DetailedStatsEnabled;
Settings.CatalogCacheSecondsTextBox.Text = settings.CatalogCacheSeconds.ToString();
Settings.QuietHoursCheckBox.IsChecked = settings.QuietHoursEnabled;
@@ -190,6 +186,16 @@ namespace AutoBidder
Log("[ERRORE] Valore anticipo puntata non valido (deve essere 0-5000ms)", LogLevel.Error);
return;
}
// Paletti dell'anticipo adattivo: minimo sotto il massimo, entrambi sensati.
settings.AdaptiveLeadEnabled = Settings.AdaptiveLeadCheckBox.IsChecked == true;
settings.LeadMinMs = ReadBounded(Settings.LeadMinMsTextBox.Text, settings.LeadMinMs, 100, 5000, "anticipo minimo", " ms");
settings.LeadMaxMs = ReadBounded(Settings.LeadMaxMsTextBox.Text, settings.LeadMaxMs, 100, 5000, "anticipo massimo", " ms");
if (settings.LeadMaxMs < settings.LeadMinMs)
{
Log($"[ERRORE] Anticipo massimo ({settings.LeadMaxMs} ms) sotto il minimo ({settings.LeadMinMs} ms): riportato al minimo", LogLevel.Error);
settings.LeadMaxMs = settings.LeadMinMs;
}
if (double.TryParse(DefaultMinPrice.Text.Replace(',', '.'), System.Globalization.NumberStyles.Any,
@@ -320,11 +326,6 @@ namespace AutoBidder
// === SEZIONE: Motore di precisione ===
// Ogni cadenza ha un minimo sensato: sotto i 100 ms si spreca banda senza
// guadagnare precisione, perché la puntata la decide il cecchino, non il poll.
settings.PollIntervalFarMs = ReadBounded(Settings.PollIntervalFarMsTextBox.Text, settings.PollIntervalFarMs, 200, 30000, "polling lontano");
settings.PollIntervalMidMs = ReadBounded(Settings.PollIntervalMidMsTextBox.Text, settings.PollIntervalMidMs, 150, 10000, "polling medio");
settings.PollIntervalNearMs = ReadBounded(Settings.PollIntervalNearMsTextBox.Text, settings.PollIntervalNearMs, 100, 5000, "polling vicino");
settings.PollIntervalCriticalMs = ReadBounded(Settings.PollIntervalCriticalMsTextBox.Text, settings.PollIntervalCriticalMs, 100, 3000, "polling critico");
settings.CriticalWindowMs = ReadBounded(Settings.CriticalWindowMsTextBox.Text, settings.CriticalWindowMs, 1000, 60000, "finestra critica");
if (double.TryParse(Settings.MaxRequestsPerSecondTextBox.Text.Replace(',', '.'),
System.Globalization.NumberStyles.Any,
@@ -392,7 +393,6 @@ namespace AutoBidder
// === SEZIONE: Anticipo puntata ===
settings.BidLeadTrackingEnabled = Settings.BidLeadTrackingCheckBox.IsChecked ?? true;
settings.BidLeadSuggestionsEnabled = Settings.BidLeadSuggestionsCheckBox.IsChecked ?? true;
settings.BidLeadMinSamples = ReadBounded(Settings.BidLeadMinSamplesTextBox.Text,
settings.BidLeadMinSamples, 5, 500, "puntate minime per il consiglio", "");
@@ -420,7 +420,6 @@ namespace AutoBidder
settings.StatsFolder = NormalizeFolderChoice(Settings.StatsFolderTextBox.Text, AppPaths.StatsFolder, settings.StatsFolder);
settings.LogFolder = NormalizeFolderChoice(Settings.LogFolderTextBox.Text, AppPaths.LogFolder, settings.LogFolder);
settings.DetailedStatsEnabled = Settings.DetailedStatsCheckBox.IsChecked ?? true;
settings.QuietHoursEnabled = Settings.QuietHoursCheckBox.IsChecked ?? true;
if (int.TryParse(Settings.QuietHoursStartTextBox.Text?.Trim(), out var qStart) && qStart is >= 0 and <= 23)
@@ -68,13 +68,10 @@ namespace AutoBidder
$"(prezzo {record.FinalPrice:F2} EUR)", LogLevel.Info);
}
// La scheda dettagliata vive solo nel riepilogo del dossier: l'archivio
// mensile che la duplicava non c'è più (vedi AuctionDetailStore).
var detail = BuildDetail(auction, state, record);
if (settings.DetailedStatsEnabled)
{
AuctionDetailStore.Append(detail);
}
// Il dossier si chiude con il riepilogo: da lì in poi quel file è una
// storia completa, ed è così che l'analisi sa di poterlo usare. Il percorso
// si prende prima, perché Close lo toglie dal registro dei dossier aperti.
+1
View File
@@ -254,6 +254,7 @@ namespace AutoBidder
if (_selectedAuction != null && int.TryParse(AuctionMonitor.SelectedBidBeforeDeadlineMs.Text, out int ms))
{
_selectedAuction.AuctionInfo.BidBeforeDeadlineMs = ms;
_selectedAuction.AuctionInfo.BidLeadIsManual = true;
SaveAuctions();
}
}
+19
View File
@@ -54,6 +54,25 @@ namespace AutoBidder
}
}
private void Export_ClearExportsClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Cancello tutti i file nella cartella delle esportazioni? Sono copie: si rigenerano in qualunque momento.",
"Svuota le esportazioni", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
try
{
var (files, bytes) = StatsWipe.ClearExports();
Log($"[ESPORTA] Cartella svuotata: {files} file, {bytes / 1024.0:F0} KB liberati", LogLevel.Success);
}
catch (Exception ex)
{
Log($"[ESPORTA] Svuotamento non riuscito: {ex.Message}", LogLevel.Error);
}
}
private void Export_OpenLastFileClicked(object sender, RoutedEventArgs e)
{
var path = Export.LastExportPath;
+13 -12
View File
@@ -69,21 +69,22 @@ namespace AutoBidder
}
}
/// <summary>Svuota lo storico delle aste concluse (con conferma).</summary>
/// <summary>
/// Azzera le statistiche registrate: il dialogo dice cosa c'è e quanto pesa, e
/// l'utente sceglie voce per voce. Vedi <see cref="StatsWipe"/>.
/// </summary>
private void ClearStatsButton_Click(object sender, RoutedEventArgs e)
{
var res = MessageBox.Show(this,
"Vuoi eliminare tutto lo storico delle aste concluse? L'operazione non è reversibile.",
"Svuota storico",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
var dialog = new Dialogs.WipeStatsDialog { Owner = this };
if (dialog.ShowDialog() != true || dialog.Result is not { } report) return;
if (res == MessageBoxResult.Yes)
{
CompletedAuctionsStore.Clear();
LoadStatistics();
Log("[STATISTICHE] Storico svuotato", LogLevel.Info);
}
LoadStatistics();
LoadProducts();
if (Learning.IsVisible) Learning.Refresh();
Log($"[STATISTICHE] Azzeramento: {report.Summary}. " +
$"{report.FilesDeleted} file tolti, {report.BytesFreed / (1024.0 * 1024.0):F1} MB liberati",
report.Errors.Count == 0 ? LogLevel.Success : LogLevel.Warning);
}
/// <summary>
+1
View File
@@ -272,6 +272,7 @@ namespace AutoBidder
// Resetta ai valori predefiniti dalle impostazioni
_selectedAuction.AuctionInfo.BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs;
_selectedAuction.AuctionInfo.BidLeadIsManual = false; // torna all'anticipo adattivo
_selectedAuction.MinPrice = settings.DefaultMinPrice;
_selectedAuction.MaxPrice = settings.DefaultMaxPrice;
_selectedAuction.MaxClicks = settings.DefaultMaxClicks;
+74
View File
@@ -0,0 +1,74 @@
<Window x:Class="AutoBidder.Dialogs.WipeStatsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Azzera le statistiche"
Width="600" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
Background="{DynamicResource Brush.Bg}">
<StackPanel Margin="22,18">
<TextBlock Text="Cosa azzerare"
FontSize="{StaticResource Font.Size.Lg}" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
<TextBlock Style="{StaticResource Hint}" Margin="0,6,0,14"
Text="Accanto a ogni voce c'è quanto spazio occupa adesso. Niente viene toccato finché non premi Azzera. Lo storico delle aste concluse viene copiato nella cartella dei backup prima di sparire; 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."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkProducts" Margin="0,4" IsChecked="True"
Foreground="{DynamicResource Brush.Text}"
ToolTip="I totali per prodotto (aste viste, prezzi medi, puntate del vincitore). Le opzioni scelte nella scheda Prodotti restano: si azzerano i numeri, non le scelte."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkBidLead" Margin="0,4" IsChecked="True"
Foreground="{DynamicResource Brush.Text}"
ToolTip="Le misure dell'anticipo effettivo di ogni puntata (bid-lead-stats.json)."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkExports" Margin="0,4" IsChecked="True"
Foreground="{DynamicResource Brush.Text}"
ToolTip="Tutti i file nella cartella Esportazioni. Sono copie: si rigenerano in qualunque momento dalla scheda Esporta."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<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ù."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkLearning" Margin="0,4" IsChecked="False"
Foreground="{DynamicResource Brush.Text}"
ToolTip="Il modello appreso, il profilo per prodotto e ora, il modello di latenza e l'elenco dei dossier già studiati. Non si perde niente di irrecuperabile: al prossimo avvio l'applicazione ristudia tutti i dossier da capo (qualche minuto in sottofondo)."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<Border Style="{StaticResource InfoBox}" Margin="0,14,0,0">
<StackPanel>
<TextBlock x:Name="SummaryText" TextWrapping="Wrap"
Foreground="{DynamicResource Brush.Text}"
FontSize="{StaticResource Font.Size.Sm}" LineHeight="18"
Text="Calcolo…"/>
<TextBlock TextWrapping="Wrap" Margin="0,8,0,0"
Foreground="{DynamicResource Brush.TextMuted}"
FontSize="{StaticResource Font.Size.Sm}" LineHeight="17"
Text="I dossier delle aste (cartella Registri\Aste) non vengono toccati da qui: sono i dati grezzi da cui tutto il resto si ricostruisce. Per liberare quello spazio usa la scheda Dati."/>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<Button x:Name="WipeButton" Content="Azzera" Padding="26,7" Margin="0,0,10,0"
Style="{StaticResource ModernButton}"
Background="{DynamicResource Brush.Danger}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Click="Wipe_Click"/>
<Button Content="Annulla" Padding="20,7" IsCancel="True"
Style="{StaticResource ModernButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"/>
</StackPanel>
</StackPanel>
</Window>
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Windows;
using AutoBidder.Utilities;
namespace AutoBidder.Dialogs
{
/// <summary>
/// Sceglie quali statistiche azzerare e le azzera.
///
/// <para>Accanto a ogni voce c'è quanto pesa adesso: cancellare è irreversibile, e
/// l'unico modo di renderlo una decisione informata è dire cosa sparisce e quanto è.
/// Lo storico delle aste concluse è l'unica voce con copia di sicurezza automatica:
/// è il solo archivio che non si ricostruisce da nient'altro.</para>
/// </summary>
public partial class WipeStatsDialog : Window
{
private StatsWipe.Sizes _sizes;
public WipeStatsDialog()
{
InitializeComponent();
Loaded += (_, _) => { _sizes = StatsWipe.Measure(); Refresh(); };
}
/// <summary>Il rapporto dell'azzeramento, dopo la conferma. Null se annullato.</summary>
public StatsWipe.Report? Result { get; private set; }
private StatsWipe.Options ReadOptions() => new()
{
History = ChkHistory.IsChecked == true,
ProductStats = ChkProducts.IsChecked == true,
BidLeadMeasures = ChkBidLead.IsChecked == true,
Exports = ChkExports.IsChecked == true,
LegacyArchives = ChkLegacy.IsChecked == true,
Learning = ChkLearning.IsChecked == true
};
private void Option_Changed(object sender, RoutedEventArgs e) => Refresh();
private void Refresh()
{
if (!IsLoaded) return;
ChkHistory.Content = $"Storico delle aste concluse ({_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)})";
ChkLearning.Content = $"Apprendimento: modello, profilo, latenza ({Fmt(_sizes.LearningBytes)})";
var o = ReadOptions();
long totale = 0;
if (o.History) totale += _sizes.HistoryBytes;
if (o.ProductStats) totale += _sizes.ProductsBytes;
if (o.BidLeadMeasures) totale += _sizes.BidLeadBytes;
if (o.Exports) totale += _sizes.ExportBytes;
if (o.LegacyArchives) totale += _sizes.LegacyBytes;
if (o.Learning) totale += _sizes.LearningBytes;
SummaryText.Text = o.Nothing
? "Nessuna voce scelta."
: $"Si liberano circa {Fmt(totale)}." +
(o.History ? " Lo storico viene prima copiato nei backup." : "") +
(o.Learning ? " L'apprendimento ripartirà da zero e ristudierà i dossier al prossimo avvio." : "");
WipeButton.IsEnabled = !o.Nothing;
}
private void Wipe_Click(object sender, RoutedEventArgs e)
{
var o = ReadOptions();
if (o.Nothing) return;
var answer = MessageBox.Show(this,
"Le voci scelte vengono cancellate. L'operazione non è reversibile" +
(o.History ? ", salvo la copia dello storico nei backup" : "") + ".\n\nProcedo?",
"Azzera le statistiche", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
try
{
Result = StatsWipe.Run(o);
DialogResult = true;
}
catch (Exception ex)
{
MessageBox.Show(this, $"Azzeramento non riuscito: {ex.Message}",
"Azzera le statistiche", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private static string Fmt(long bytes) =>
bytes >= 1L << 30 ? $"{bytes / (double)(1L << 30):F2} GB"
: bytes >= 1L << 20 ? $"{bytes / (double)(1L << 20):F1} MB"
: bytes >= 1L << 10 ? $"{bytes / (double)(1L << 10):F0} KB"
: $"{bytes} B";
}
}
+28 -3
View File
@@ -228,6 +228,33 @@ namespace AutoBidder.Engine
return (int)Math.Clamp(Math.Min(desired, untilWake), 1000, 1_800_000);
}
/// <summary>
/// L'anticipo con cui parte la puntata su quest'asta.
///
/// <para>Un anticipo scritto a mano sull'asta vince sempre. Altrimenti decide il
/// modello di latenza — margine più coda del ping di questa sessione, dentro i
/// paletti delle impostazioni — e il valore scelto viene scritto sull'asta, così
/// la griglia e il dossier mostrano l'anticipo davvero in uso.</para>
/// </summary>
private int EffectiveLeadMs(AppSettings settings)
{
if (_auction.BidLeadIsManual && _auction.BidBeforeDeadlineMs > 0)
return _auction.BidBeforeDeadlineMs;
var lead = settings.AdaptiveLeadEnabled
? Ml.LatencyModel.RecommendedLeadMs(settings)
: settings.DefaultBidBeforeDeadlineMs;
if (lead != _auction.BidBeforeDeadlineMs)
{
_auction.AddLog($"Anticipo adattivo: {lead} ms (era {_auction.BidBeforeDeadlineMs} ms)",
AuctionLogLevel.Debug, AuctionLogCategory.Ticker);
_auction.BidBeforeDeadlineMs = lead;
}
return lead;
}
// ── Anello del cecchino ──────────────────────────────────────────────────
private async Task SniperLoopAsync(CancellationToken ct)
@@ -265,9 +292,7 @@ namespace AutoBidder.Engine
}
var settings = _host.Settings;
var leadMs = _auction.BidBeforeDeadlineMs > 0
? _auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
var leadMs = EffectiveLeadMs(settings);
var fireTicks = deadline - PrecisionWait.MsToTicks(leadMs);
var msToDeadline = PrecisionWait.MsUntil(deadline);
+6 -7
View File
@@ -96,12 +96,11 @@ namespace AutoBidder.Engine.Backtest
{
if (string.IsNullOrEmpty(reason)) return "altro";
if (reason.StartsWith("Anti-bot", StringComparison.OrdinalIgnoreCase)) return "anti-bot";
if (reason.StartsWith("Soft retreat", StringComparison.OrdinalIgnoreCase)) return "soft-retreat";
if (reason.StartsWith("Asta troppo calda", StringComparison.OrdinalIgnoreCase)) return "asta-calda";
if (reason.StartsWith("Bidder aggressivi", StringComparison.OrdinalIgnoreCase)) return "avversari-aggressivi";
if (reason.StartsWith("Skip probabilistico", StringComparison.OrdinalIgnoreCase)) return "probabilistico";
if (reason.StartsWith("Prezzo sale", StringComparison.OrdinalIgnoreCase)) return "velocita-prezzo";
if (reason.StartsWith("autopuntata avversaria", StringComparison.OrdinalIgnoreCase)) return "duello";
if (reason.StartsWith("puntata saltata: fascia", StringComparison.OrdinalIgnoreCase)) return "fascia-oraria";
if (reason.StartsWith("valore atteso", StringComparison.OrdinalIgnoreCase)) return "valore-atteso";
if (reason.StartsWith("lascio sfogare", StringComparison.OrdinalIgnoreCase)) return "sfogo";
if (reason.StartsWith("puntata di prova", StringComparison.OrdinalIgnoreCase)) return "sondaggio";
if (reason.StartsWith("Limite puntate", StringComparison.OrdinalIgnoreCase)) return "limite-puntate";
if (reason.StartsWith("Budget", StringComparison.OrdinalIgnoreCase)) return "budget";
@@ -181,7 +180,6 @@ namespace AutoBidder.Engine.Backtest
continue;
}
strategy.UpdateHeatMetric(auction, options.EffectiveSettings, options.Username);
var decision = strategy.ShouldPlaceBid(auction, state, options.EffectiveSettings, options.Username);
if (!decision.ShouldBid)
@@ -312,6 +310,7 @@ namespace AutoBidder.Engine.Backtest
Name = header?.Name ?? "",
BuyNowPrice = header?.BuyNowPrice,
BidBeforeDeadlineMs = options.LeadMs,
BidLeadIsManual = true, // la rigiocata prova un anticipo preciso, non quello adattivo
BidsUsedOnThisAuction = 0
};
+4 -2
View File
@@ -220,7 +220,8 @@
Visibility="Collapsed"
ExportRequested="Export_ExportRequested"
OpenExportFolderClicked="Export_OpenFolderClicked"
OpenLastFileClicked="Export_OpenLastFileClicked"/>
OpenLastFileClicked="Export_OpenLastFileClicked"
ClearExportsClicked="Export_ClearExportsClicked"/>
<!-- Apprendimento Panel -->
<controls:LearningControl x:Name="Learning" Visibility="Collapsed"/>
@@ -300,7 +301,8 @@
</Button>
<Button Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Svuota lo storico" Click="ClearStatsButton_Click">
ToolTip="Azzera le statistiche registrate: scegli cosa (storico, prodotti, misure, esportazioni, archivi vecchi, apprendimento). Lo storico viene prima copiato nei backup."
Click="ClearStatsButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE74D;"/>
</Button>
</StackPanel>
+1
View File
@@ -101,6 +101,7 @@ namespace AutoBidder
// Inizializza servizi
_auctionMonitor = new AuctionMonitor();
Learning.AuctionsProvider = () => _auctionMonitor.GetAuctions();
// ? NUOVO: Inizializza SessionService
InitializeSessionService();
+168
View File
@@ -0,0 +1,168 @@
using System;
namespace AutoBidder.Ml
{
/// <summary>
/// Il regime di concorrenza di un'asta: quando lasciar sfogare gli altri, e quando
/// tornare a puntare. Sostituisce «calore», «ritiro morbido», «avversari aggressivi» e
/// il vecchio rilevamento del duello con una sola macchina a stati, guidata da ciò
/// che il modello ha imparato invece che da soglie scritte a mano.
///
/// <para><b>Gli stati.</b></para>
/// <list type="bullet">
/// <item><b>Calmo</b>: si punta quando il valore atteso è positivo. È il regime
/// normale.</item>
/// <item><b>Sfogo</b>: gli altri si stanno battendo e ogni nostra puntata verrebbe
/// coperta. Non si punta. Ci si entra quando il valore atteso resta negativo per
/// qualche decisione di fila, o quando si riconosce un'autopuntata armata.</item>
/// <item><b>Sondaggio</b>: le condizioni sono tornate buone da abbastanza cicli. Si
/// concede <i>una</i> puntata di prova. Se resta senza risposta si torna Calmo; se
/// viene coperta subito si torna a Sfogo, e la pazienza richiesta per il prossimo
/// sondaggio raddoppia.</item>
/// </list>
///
/// <para><b>Perché l'isteresi.</b> Il valore atteso oscilla da un ciclo all'altro: un
/// cancello che punta al primo segno positivo e si ferma al primo negativo farebbe
/// avanti e indietro, e ogni «avanti» è una puntata pagata. Servono più segnali
/// buoni di fila per rientrare che segnali cattivi per uscire: sbagliare a rientrare
/// costa soldi, sbagliare a uscire costa al massimo un'attesa.</para>
///
/// <para><b>Perché la pazienza raddoppia.</b> Un sondaggio coperto subito dice che
/// dall'altra parte c'è ancora qualcuno che risponde: insistere con la stessa
/// pazienza sarebbe rifare lo stesso errore allo stesso prezzo. Raddoppiare fino a un
/// tetto è il modo più semplice di «capire da soli» che quell'asta, oggi, non si
/// lascia prendere — senza deciderlo una volta per tutte.</para>
///
/// <para>Classe pura con stato proprio: riceve i segnali, non legge niente da fuori.
/// Così si prova a tavolino e si rigioca sui dossier con la stessa logica del motore.</para>
/// </summary>
public sealed class CompetitionRegime
{
public enum Stato { Calmo, Sfogo, Sondaggio }
/// <summary>Decisioni negative di fila prima di andare in Sfogo.</summary>
public const int NegativiPerSfogo = 3;
/// <summary>Cicli buoni di fila richiesti al primo rientro; raddoppia a ogni sondaggio coperto.</summary>
public const int PazienzaIniziale = 3;
public const int PazienzaMassima = 24;
/// <summary>Secondi entro cui una risposta alla puntata di prova conta come «coperta subito».</summary>
public const double RispostaRapidaSecondi = 9.0;
public Stato StatoAttuale { get; private set; } = Stato.Calmo;
/// <summary>Quanti cicli buoni di fila servono adesso per rientrare.</summary>
public int Pazienza { get; private set; } = PazienzaIniziale;
/// <summary>Cicli buoni di fila visti finora durante lo Sfogo.</summary>
public int CicliBuoni { get; private set; }
/// <summary>Decisioni negative di fila viste finora in Calmo.</summary>
public int Negativi { get; private set; }
/// <summary>Sondaggi coperti subito, in quest'asta.</summary>
public int SondaggiFalliti { get; private set; }
private bool _sondaggioInCorso;
/// <summary>
/// Una decisione del motore: il valore atteso di puntare adesso era positivo?
/// Restituisce true se in questo regime si può puntare.
/// </summary>
public bool Osserva(bool valoreAttesoPositivo)
{
switch (StatoAttuale)
{
case Stato.Calmo:
if (valoreAttesoPositivo) { Negativi = 0; return true; }
if (++Negativi >= NegativiPerSfogo) EntraInSfogo();
return false;
case Stato.Sfogo:
if (!valoreAttesoPositivo) { CicliBuoni = 0; return false; }
if (++CicliBuoni < Pazienza) return false;
// Abbastanza cicli buoni di fila: una puntata di prova.
StatoAttuale = Stato.Sondaggio;
_sondaggioInCorso = false;
return true;
case Stato.Sondaggio:
// Finché la prova non è stata piazzata si può ancora puntare (una volta);
// dopo, si aspetta l'esito.
return !_sondaggioInCorso && valoreAttesoPositivo;
}
return true;
}
/// <summary>La nostra puntata è partita.</summary>
public void PuntataPiazzata()
{
if (StatoAttuale == Stato.Sondaggio) _sondaggioInCorso = true;
}
/// <summary>
/// Qualcuno ha risposto alla nostra ultima puntata dopo tanti secondi. Se eravamo
/// in Sondaggio e la risposta è rapida, il sondaggio è fallito.
/// </summary>
public void RispostaAvversaria(double secondiDopoLaMiaPuntata)
{
if (StatoAttuale != Stato.Sondaggio || !_sondaggioInCorso) return;
if (secondiDopoLaMiaPuntata <= RispostaRapidaSecondi)
{
SondaggiFalliti++;
Pazienza = Math.Min(PazienzaMassima, Pazienza * 2);
EntraInSfogo();
}
else
{
// Risposta lenta: non era una macchina, si torna a giocare.
Riprendi();
}
}
/// <summary>La nostra ultima puntata è rimasta senza risposta: siamo in testa da un pezzo.</summary>
public void NessunaRisposta()
{
if (StatoAttuale == Stato.Sondaggio) Riprendi();
}
/// <summary>Riconosciuta un'autopuntata armata: non c'è niente da sondare per ora.</summary>
public void AutopuntataRiconosciuta()
{
if (StatoAttuale != Stato.Sfogo)
{
Pazienza = Math.Max(Pazienza, PazienzaIniziale * 2);
EntraInSfogo();
}
}
private void EntraInSfogo()
{
StatoAttuale = Stato.Sfogo;
CicliBuoni = 0;
Negativi = 0;
_sondaggioInCorso = false;
}
private void Riprendi()
{
StatoAttuale = Stato.Calmo;
Negativi = 0;
CicliBuoni = 0;
_sondaggioInCorso = false;
// La pazienza non si azzera del tutto: chi ci ha coperto una volta può tornare.
Pazienza = Math.Max(PazienzaIniziale, Pazienza / 2);
}
/// <summary>Una riga per il registro.</summary>
public string Spiegazione() => StatoAttuale switch
{
Stato.Sfogo => $"lascio sfogare gli altri: rientro dopo {Pazienza} cicli buoni di fila (visti {CicliBuoni})",
Stato.Sondaggio => "puntata di prova: se viene coperta subito torno ad aspettare",
_ => "regime calmo"
};
}
}
+154
View File
@@ -0,0 +1,154 @@
# Come decide il motore
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.
## 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).
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.
## La pipeline: quattro passi, sempre nello stesso ordine
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. PALETTI │──▶│ 2. DUELLO │──▶│ 3. VALORE │──▶│ 4. REGIME │──▶ punta?
│ (utente) │ │ (sessione) │ │ ATTESO │ │ (sessione) │
└──────────────┘ └──────────────┘ │ (storico+ora)│ └──────────────┘
└──────────────┘
┌──────────▼──────────┐
│ QUANDO: anticipo │
│ adattivo (sessione) │
└─────────────────────┘
```
### 1. Paletti — `BidStrategyService`, `BiddingHours`
Regole dell'utente che non si discutono e non imparano niente:
- 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.
### 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. 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 è
```
EV = p × (Valore Prezzo 0,01) CostoPuntata
```
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).
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.
### 4. Regime di concorrenza — `CompetitionRegime`
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 |
**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.
**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.
È 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».
### Quando puntare: l'anticipo adattivo — `LatencyModel`
L'anticipo giusto dipende dalla latenza di *questa* rete in *questo* momento. Un numero
fisso è sbagliato quasi sempre. La regola:
```
anticipo = clamp( margine + p99(latenza) , LeadMinMs , LeadMaxMs )
```
- 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`).
Lo stato si salva fra le sessioni (`Statistiche/Apprendimento/latenza.json`).
## Cosa impara, e da dove
| 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 |
## Dove si vede
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.
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 €`.
## Come si prova
- `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.
## 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.
+221
View File
@@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using AutoBidder.Utilities;
namespace AutoBidder.Ml
{
/// <summary>
/// Decide da solo l'anticipo con cui parte la puntata, a partire dalla rete di questa
/// sessione — entro i paletti dell'utente.
///
/// <para><b>Il problema.</b> L'anticipo giusto dipende da quanto ci mette la puntata ad
/// arrivare al server, e questo cambia da macchina a macchina, da rete a rete, da ora a
/// ora. Un numero fisso è sbagliato quasi sempre: troppo largo regala secondi agli
/// avversari, troppo stretto fa arrivare la puntata a giochi chiusi. Nei registri erano
/// entrambe le cose, in giorni diversi.</para>
///
/// <para><b>La regola.</b> L'anticipo è un margine di sicurezza più la coda alta della
/// latenza misurata: <c>margine + p99(andata e ritorno)</c>. La coda, non la media —
/// una puntata su cento che arriva tardi costa un'asta intera, e la media non la vede.
/// Il margine copre l'elaborazione del server e l'arrotondamento al secondo con cui
/// Bidoo dichiara la scadenza.</para>
///
/// <para><b>Come impara.</b> Da tre cose che passano di qui comunque: il ping di ogni
/// interrogazione, l'andata e ritorno di ogni puntata, e l'esito «arrivata troppo
/// tardi». Una puntata tardiva alza il margine subito e di molto; una lunga serie di
/// puntate in tempo lo abbassa piano. Asimmetrico apposta: sbagliare per eccesso costa
/// qualche puntata in più, sbagliare per difetto costa l'asta.</para>
///
/// <para><b>I paletti.</b> L'utente fissa minimo e massimo; il modello si muove solo lì
/// dentro. Un anticipo fissato a mano su una singola asta vince sempre.</para>
///
/// <para>Lo stato si salva fra una sessione e l'altra: la rete di casa è quasi sempre
/// la stessa, e ripartire da zero ogni volta significherebbe pagare la prima puntata
/// tardiva ogni giorno.</para>
/// </summary>
public static class LatencyModel
{
private const int Window = 300;
private static readonly object Sync = new();
private static readonly Queue<int> _pings = new();
private static readonly Queue<int> _roundTrips = new();
/// <summary>Margine oltre la coda della latenza. Parte da 300 ms: elaborazione del server più arrotondamento al secondo.</summary>
private static int _marginMs = 300;
private static int _bidsSinceLastLate;
private static int _lateBids;
private static int _bidsTotal;
private static bool _loaded;
private static string FilePath => Path.Combine(AppPaths.StatsFolder, "Apprendimento", "latenza.json");
// ── Alimentazione ────────────────────────────────────────────────
public static void NotePing(int ms)
{
if (ms <= 0 || ms > 10000) return;
lock (Sync)
{
_pings.Enqueue(ms);
while (_pings.Count > Window) _pings.Dequeue();
}
}
/// <summary>Una puntata è stata inviata: quanto ci ha messo, e se è arrivata tardi.</summary>
public static void NoteBid(int roundTripMs, bool late)
{
lock (Sync)
{
if (roundTripMs > 0 && roundTripMs < 10000)
{
_roundTrips.Enqueue(roundTripMs);
while (_roundTrips.Count > Window) _roundTrips.Dequeue();
}
_bidsTotal++;
if (late)
{
// Una sola puntata tardiva vale un'asta persa: si sale subito e di
// molto, poi si riscende piano se non ricapita.
_lateBids++;
_bidsSinceLastLate = 0;
_marginMs = Math.Min(2000, _marginMs + 150);
}
else
{
_bidsSinceLastLate++;
// Venti puntate in tempo di fila: si prova a stringere di un pelo.
if (_bidsSinceLastLate % 20 == 0) _marginMs = Math.Max(150, _marginMs - 25);
}
}
Save();
}
// ── Risposta ─────────────────────────────────────────────────────
/// <summary>L'anticipo consigliato adesso, dentro i paletti delle impostazioni.</summary>
public static int RecommendedLeadMs(AppSettings settings)
{
Load();
var min = Math.Max(100, settings.LeadMinMs);
var max = Math.Max(min, settings.LeadMaxMs);
lock (Sync)
{
// La coda della latenza: l'andata e ritorno delle puntate se ne abbiamo
// abbastanza, altrimenti il ping delle interrogazioni, che è la stessa
// strada. Senza campioni si usa il predefinito dell'utente.
var coda = _roundTrips.Count >= 10 ? Percentile(_roundTrips, 0.99)
: _pings.Count >= 10 ? Percentile(_pings, 0.99)
: -1;
if (coda < 0) return Math.Clamp(settings.DefaultBidBeforeDeadlineMs, min, max);
return Math.Clamp(_marginMs + coda, min, max);
}
}
/// <summary>Numeri per la scheda e il registro.</summary>
public static (int Samples, int P50, int P99, int MarginMs, int LateBids, int Bids) Snapshot()
{
Load();
lock (Sync)
{
var src = _roundTrips.Count >= 10 ? _roundTrips : _pings;
return (src.Count,
src.Count > 0 ? Percentile(src, 0.50) : 0,
src.Count > 0 ? Percentile(src, 0.99) : 0,
_marginMs, _lateBids, _bidsTotal);
}
}
private static int Percentile(Queue<int> q, double p)
{
var a = q.ToArray();
Array.Sort(a);
var i = (int)Math.Ceiling(p * a.Length) - 1;
return a[Math.Clamp(i, 0, a.Length - 1)];
}
// ── Persistenza ──────────────────────────────────────────────────
private sealed class Snap
{
public int MarginMs { get; set; }
public int LateBids { get; set; }
public int Bids { get; set; }
public int[] RoundTrips { get; set; } = Array.Empty<int>();
}
private static void Load()
{
if (_loaded) return;
lock (Sync)
{
if (_loaded) return;
_loaded = true;
try
{
if (!File.Exists(FilePath)) return;
var s = JsonSerializer.Deserialize<Snap>(File.ReadAllText(FilePath));
if (s == null) return;
_marginMs = Math.Clamp(s.MarginMs, 150, 2000);
_lateBids = s.LateBids;
_bidsTotal = s.Bids;
foreach (var r in s.RoundTrips) _roundTrips.Enqueue(r);
}
catch { /* si riparte dai predefiniti */ }
}
}
private static void Save()
{
try
{
string json;
lock (Sync)
{
json = JsonSerializer.Serialize(new Snap
{
MarginMs = _marginMs,
LateBids = _lateBids,
Bids = _bidsTotal,
RoundTrips = _roundTrips.ToArray()
});
}
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, json);
}
catch { /* la prossima puntata riproverà */ }
}
/// <summary>Dimentica le misure, in memoria e su disco: si riparte dai predefiniti.</summary>
public static void Forget()
{
ResetForTests();
try { if (File.Exists(FilePath)) File.Delete(FilePath); } catch { }
}
/// <summary>Solo per i test.</summary>
public static void ResetForTests(int marginMs = 300)
{
lock (Sync)
{
_pings.Clear();
_roundTrips.Clear();
_marginMs = marginMs;
_bidsSinceLastLate = 0;
_lateBids = 0;
_bidsTotal = 0;
_loaded = true;
}
}
}
}
+18 -8
View File
@@ -175,6 +175,23 @@ namespace AutoBidder.Ml
/// modello abbia imparato da dati sbagliati. Non tocca i dossier.
/// </summary>
public static Task RetrainFromScratchAsync(AppSettings settings)
{
Forget();
OnLog?.Invoke("[APPRENDIMENTO] Ricomincio da capo: profilo dallo storico, poi tutti i dossier.");
BootstrapProfileFromHistory();
// Un riaddestramento voluto ha diritto a tutto il tempo che serve.
var generose = new AppSettings { LearningBootstrapSecondsPerStart = Math.Max(600, settings.LearningBootstrapSecondsPerStart) };
return Task.Run(() => BootstrapAsync(generose));
}
/// <summary>
/// Dimentica tutto: modello, profilo, 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.
/// </summary>
public static void Forget()
{
lock (Sync)
{
@@ -190,7 +207,7 @@ namespace AutoBidder.Ml
{
lock (SaveSync)
{
foreach (var f in new[] { ModelFile, ProfileFile, ManifestFile })
foreach (var f in new[] { ModelFile, ProfileFile, ManifestFile, EvaluationFile })
if (File.Exists(f)) File.Delete(f);
}
}
@@ -198,13 +215,6 @@ namespace AutoBidder.Ml
{
OnLog?.Invoke($"[APPRENDIMENTO] Archivio non cancellato del tutto: {ex.Message}");
}
OnLog?.Invoke("[APPRENDIMENTO] Ricomincio da capo: profilo dallo storico, poi tutti i dossier.");
BootstrapProfileFromHistory();
// Un riaddestramento voluto ha diritto a tutto il tempo che serve.
var generose = new AppSettings { LearningBootstrapSecondsPerStart = Math.Max(600, settings.LearningBootstrapSecondsPerStart) };
return Task.Run(() => BootstrapAsync(generose));
}
// ── Avvio ────────────────────────────────────────────────────────
+19 -76
View File
@@ -42,6 +42,14 @@ namespace AutoBidder.Models
/// Es: 200ms = punta 200ms prima che il timer raggiunga 0.
/// </summary>
public int BidBeforeDeadlineMs { get; set; } = 200;
/// <summary>
/// L'anticipo di quest'asta è stato scritto a mano dall'utente. Solo allora
/// <see cref="BidBeforeDeadlineMs"/> comanda: altrimenti il cecchino lo lascia
/// decidere al modello di latenza (vedi Ml/LatencyModel) e scrive qui il valore
/// scelto, così la griglia mostra l'anticipo davvero in uso.
/// </summary>
public bool BidLeadIsManual { get; set; }
public double MinPrice { get; set; } = 0;
public double MaxPrice { get; set; } = 0;
@@ -528,43 +536,6 @@ namespace AutoBidder.Models
public double AverageLatencyMs => LatencyHistory.Count > 0
? LatencyHistory.Average()
: PollingLatencyMs > 0 ? PollingLatencyMs : 60;
/// <summary>
/// Heat metric (0-100) che indica quanto "calda" l'asta
/// Calcolato in base a: bidder attivi, frequenza puntate, collisioni
/// </summary>
[JsonIgnore]
public int HeatMetric { get; set; } = 0;
/// <summary>
/// Numero di bidder unici attivi negli ultimi N secondi
/// </summary>
[JsonIgnore]
public int ActiveBiddersCount { get; set; } = 0;
/// <summary>
/// Numero di collisioni rilevate (puntate nello stesso secondo)
/// </summary>
[JsonIgnore]
public int CollisionCount { get; set; } = 0;
/// <summary>
/// Collisioni consecutive senza puntata vincente
/// </summary>
[JsonIgnore]
public int ConsecutiveCollisions { get; set; } = 0;
/// <summary>
/// Timestamp dell'ultimo soft retreat
/// </summary>
[JsonIgnore]
public DateTime? LastSoftRetreatAt { get; set; }
/// <summary>
/// Se true, l'asta in soft retreat temporaneo
/// </summary>
[JsonIgnore]
public bool IsInSoftRetreat { get; set; } = false;
// ── Duello con l'autopuntata avversaria ──────────────────────────
// Vedi Utilities/AutoBidDuel per il perche' e per i numeri misurati.
@@ -584,6 +555,14 @@ namespace AutoBidder.Models
[JsonIgnore]
public bool AutoBidDuelDetected { get; set; }
/// <summary>
/// Il regime di concorrenza di quest'asta: quando lasciar sfogare gli altri e
/// quando tornare a puntare. Vedi <see cref="Ml.CompetitionRegime"/>.
/// Stato di sessione: al riavvio riparte Calmo, e va bene così.
/// </summary>
[JsonIgnore]
public Ml.CompetitionRegime Regime { get; } = new();
// ── Apprendimento: l'ultima risposta del modello per quest'asta ──
// Vedi Ml/LearningService. Non si salvano: valgono per l'istante in cui sono state
// calcolate, e al riavvio non ci sarebbe piu' lo stato che le ha prodotte.
@@ -612,6 +591,9 @@ namespace AutoBidder.Models
AutoResponsesInARow = Utilities.AutoBidDuel.Aggiorna(AutoResponsesInARow, ritardo);
// Il regime vuole sapere se una puntata di prova è stata coperta subito.
Regime.RispostaAvversaria(ritardo);
if (AutoBidDuelDetected) return false;
if (!Utilities.AutoBidDuel.DuelloRiconosciuto(AutoResponsesInARow, soglia)) return false;
@@ -643,12 +625,6 @@ namespace AutoBidder.Models
[JsonIgnore]
public int FailedBidCount { get; set; } = 0;
/// <summary>
/// Lista utenti identificati come aggressivi in questa asta
/// </summary>
[JsonIgnore]
public HashSet<string> AggressiveBidders { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Offset dinamico calcolato per questa asta (ms)
/// </summary>
@@ -700,21 +676,10 @@ namespace AutoBidder.Models
// IMPOSTAZIONI PER-ASTA (override globali)
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Override: abilita/disabilita strategie avanzate per questa asta
/// null = usa impostazione globale
/// </summary>
public bool? AdvancedStrategiesEnabled { get; set; }
/// <summary>
/// Override: abilita/disabilita jitter per questa asta
/// </summary>
public bool? JitterEnabledOverride { get; set; }
/// <summary>
/// Override: abilita/disabilita soft retreat per questa asta
/// </summary>
public bool? SoftRetreatEnabledOverride { get; set; }
/// <summary>
/// Override per questa asta della sospensione a fascia oraria.
@@ -730,25 +695,6 @@ namespace AutoBidder.Models
// ?? NUOVO: Rilevamento situazione di duello
/// <summary>
/// True se rilevata situazione di duello (solo 2 bidder dominanti)
/// </summary>
[JsonIgnore]
public bool IsDuelSituation { get; set; } = false;
/// <summary>
/// Username dell'avversario in caso di duello
/// </summary>
[JsonIgnore]
public string? DuelOpponent { get; set; }
/// <summary>
/// Vantaggio/svantaggio nel duello (% puntate mie - % puntate avversario)
/// Positivo = sto dominando, Negativo = sto perdendo
/// </summary>
[JsonIgnore]
public double DuelAdvantage { get; set; } = 0;
// ???????????????????????????????????????????????????????????????????
// GESTIONE MEMORIA
// ???????????????????????????????????????????????????????????????????
@@ -775,13 +721,10 @@ namespace AutoBidder.Models
LatencyHistory?.Clear();
LatencyHistory = null!;
AggressiveBidders?.Clear();
AggressiveBidders = null!;
// Pulisci oggetti complessi
LastState = null;
CalculatedValue = null;
DuelOpponent = null;
WinLimitDescription = null;
// Reset flag
+28 -81
View File
@@ -282,7 +282,9 @@ namespace AutoBidder.Services
_monitoringCts = new CancellationTokenSource();
_supervisorTask = Task.Run(() => SupervisorLoop(_monitoringCts.Token));
OnLog?.Invoke($"[START] Monitoraggio avviato (poll {settings.PollIntervalCriticalMs}-{settings.PollIntervalFarMs}ms, max {settings.MaxRequestsPerSecond:F0} req/s)");
OnLog?.Invoke(settings.MaxRequestsPerSecond > 0
? $"[START] Monitoraggio avviato (max {settings.MaxRequestsPerSecond:F0} richieste/s)"
: "[START] Monitoraggio avviato (nessun tetto alle richieste: frena solo se il server lo chiede)");
}
public void Stop()
@@ -430,6 +432,7 @@ namespace AutoBidder.Services
}
auction.AddLatencyMeasurement(state.PollingLatencyMs);
Ml.LatencyModel.NotePing(state.PollingLatencyMs);
// Serie prezzi: va raccolta ora, a posteriori Bidoo non la espone.
auction.TrackPrice(state.Price);
@@ -641,30 +644,7 @@ namespace AutoBidder.Services
auction.AddLog($"[ASTA TERMINATA] {statusMsg}");
OnLog?.Invoke($"[FINE] [{auction.AuctionId}] Asta {statusMsg}");
// Feedback per aste perse
var settings = SettingsManager.Load();
if (!won && settings.ShowLateBidWarning)
{
// Se abbiamo provato a puntare ma fallito con errore timer
var lastBidAttempt = auction.BidHistory
.Where(b => b.EventType == BidEventType.MyBid && !b.Success)
.OrderByDescending(b => b.Timestamp)
.FirstOrDefault();
if (lastBidAttempt != null &&
(lastBidAttempt.Notes?.Contains("timer") == true ||
lastBidAttempt.Notes?.Contains("scaduto") == true))
{
int currentOffset = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
auction.AddLog($"[⚠️ SUGGERIMENTO] Puntata arrivata troppo tardi! " +
$"Tempo attuale: {currentOffset}ms. " +
$"Prova ad aumentarlo a {currentOffset + 500}ms o più.");
OnLog?.Invoke($"[LATE] {auction.Name}: aumenta il tempo di puntata (attuale: {currentOffset}ms)");
}
}
auction.BidHistory.Add(new BidHistory
{
@@ -759,10 +739,15 @@ namespace AutoBidder.Services
auction.LastClickAt = DateTime.UtcNow;
// Registra metriche
bool isCollision = result.Error?.Contains("timer") == true || result.Error?.Contains("scaduto") == true;
_bidStrategy.RecordBidAttempt(auction, result.Success, collision: isCollision);
if (!result.Success && isCollision)
bool isCollision = !result.Success && IsLateBidError(result.Error);
_bidStrategy.RecordBidAttempt(auction, result.Success);
// Il modello di latenza impara da ogni puntata: quanto ci ha messo, e se è
// arrivata tardi. Una tardiva alza l'anticipo subito; molte in tempo lo
// abbassano piano.
Ml.LatencyModel.NoteBid(result.LatencyMs, late: isCollision);
if (isCollision)
{
_bidStrategy.RecordTimerExpired(auction);
}
@@ -809,19 +794,18 @@ namespace AutoBidder.Services
auction.AddLog($"[BID FAIL] {result.Error} | Ping: {pollingPing}ms");
OnLog?.Invoke($"[FAIL] Puntata fallita su {auction.Name} ({auction.AuctionId}): {result.Error}");
// Feedback per puntata tardiva
// Puntata tardiva: l'anticipo lo corregge da solo il modello di
// latenza (è già stato avvisato sopra). Qui si dice solo cosa ha deciso.
if (isLateBid && settings.ShowLateBidWarning)
{
int currentOffset = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
int suggestedOffset = currentOffset + 300 + pollingPing;
auction.AddLog($"[⚠️ TIMING] Puntata arrivata troppo tardi! " +
$"Offset attuale: {currentOffset}ms. Latenza totale: ~{pollingPing + result.LatencyMs}ms. " +
$"Suggerimento: aumenta a {suggestedOffset}ms");
OnLog?.Invoke($"[LATE] {auction.Name}: puntata tardiva, aumenta offset a {suggestedOffset}ms");
var adattivo = Ml.LatencyModel.RecommendedLeadMs(settings);
var manuale = auction.BidLeadIsManual && auction.BidBeforeDeadlineMs > 0;
auction.AddLog($"[TIMING] Puntata arrivata troppo tardi (latenza ~{pollingPing + result.LatencyMs}ms). " +
(manuale
? $"L'anticipo di quest'asta è fisso a {auction.BidBeforeDeadlineMs}ms: il modello consiglierebbe {adattivo}ms."
: $"Anticipo adattivo portato a {adattivo}ms."));
OnLog?.Invoke($"[LATE] {auction.Name}: puntata tardiva, anticipo adattivo ora {adattivo}ms");
}
}
@@ -874,44 +858,6 @@ namespace AutoBidder.Services
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Value);
}
// CONTROLLO ANTI-COLLISIONE (OPZIONALE)
if (settings.HardcodedAntiCollisionEnabled)
{
var recentBidsThreshold = 10;
var maxActiveBidders = 3;
try
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var recentBids = auction.RecentBids
.Where(b => now - b.Timestamp <= recentBidsThreshold)
.ToList();
var activeBidders = recentBids
.Select(b => b.Username)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
auction.AddLog($"Competizione: {activeBidders} bidder attivi (soglia={maxActiveBidders})",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Competition);
if (activeBidders >= maxActiveBidders)
{
var session = _apiClient.GetSession();
var lastBid = recentBids.OrderByDescending(b => b.Timestamp).FirstOrDefault();
if (lastBid != null &&
!lastBid.Username.Equals(session?.Username, StringComparison.OrdinalIgnoreCase))
{
auction.AddLog($"⛔ Asta affollata: {activeBidders} bidder attivi",
Models.AuctionLogLevel.Strategy, Models.AuctionLogCategory.Competition);
return false;
}
}
}
catch { }
}
// CONTROLLO 1: Limite minimo puntate residue
if (settings.MinimumRemainingBids > 0)
{
@@ -1209,11 +1155,12 @@ namespace AutoBidder.Services
});
}
// Una puntata avversaria: se arriva col ritardo tipico dell'autopuntata
// (~6 s dalla nostra) alimenta il conteggio del duello.
// Una puntata avversaria: quanto ha tardato rispetto alla nostra alimenta
// il riconoscimento del duello (~6 s = autopuntata) e il regime di
// concorrenza (una puntata di prova coperta subito). Si misura sempre;
// l'impostazione decide solo se il duello riconosciuto ferma le puntate.
var impostazioni = SettingsManager.Load();
if (impostazioni.AutoBidDuelWithdrawEnabled &&
auction.NoteOpponentResponse(DateTime.UtcNow, impostazioni.AutoBidDuelResponses))
if (auction.NoteOpponentResponse(DateTime.UtcNow, impostazioni.AutoBidDuelResponses))
{
auction.AddLog(AutoBidDuel.Spiegazione(auction.AutoResponsesInARow),
AuctionLogLevel.Warning, AuctionLogCategory.Strategy);
+118 -520
View File
@@ -1,359 +1,62 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per strategie avanzate di puntata.
/// Implementa: adaptive latency, jitter, dynamic offset, heat metric,
/// competition detection, soft retreat, probabilistic bidding, opponent profiling.
/// Decide se puntare. Quattro 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>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>
/// <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>
/// <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>
/// <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>
/// </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>
/// </summary>
public class BidStrategyService
{
private readonly Random _random = new();
private int _sessionTotalBids = 0;
private DateTime _sessionStartedAt = DateTime.UtcNow;
/// <summary>
/// Aggiorna heat metric per un'asta
/// </summary>
public void UpdateHeatMetric(AuctionInfo auction, AppSettings settings, string currentUsername = "")
{
if (!settings.CompetitionDetectionEnabled) return;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var windowStart = now - settings.CompetitionWindowSeconds;
// Conta bidder unici nella finestra temporale (escludo me stesso)
var recentBids = auction.RecentBids
.Where(b => b.Timestamp >= windowStart)
.Where(b => !b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
.ToList();
auction.ActiveBiddersCount = recentBids
.Select(b => b.Username)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
// Conta collisioni (puntate nello stesso secondo)
var bidsBySecond = recentBids
.GroupBy(b => b.Timestamp)
.Where(g => g.Count() > 1)
.Count();
auction.CollisionCount = bidsBySecond;
// Calcola heat metric (0-100)
// Fattori: bidder attivi (40%), frequenza puntate (30%), collisioni (30%)
int bidderScore = Math.Min(auction.ActiveBiddersCount * 15, 40); // Max 40 punti
int frequencyScore = Math.Min(recentBids.Count * 3, 30); // Max 30 punti
int collisionScore = Math.Min(auction.CollisionCount * 10, 30); // Max 30 punti
auction.HeatMetric = bidderScore + frequencyScore + collisionScore;
// Identifica bidder aggressivi e situazioni di duello
if (settings.OpponentProfilingEnabled)
{
UpdateAggressiveBidders(auction, settings, currentUsername);
DetectDuelSituation(auction, settings, currentUsername);
}
}
/// <summary>
/// Identifica e tracca bidder aggressivi (basato su ultime N puntate, esclude utente corrente)
/// </summary>
private void UpdateAggressiveBidders(AuctionInfo auction, AppSettings settings, string currentUsername)
{
// ?? FIX: Usa finestra scorrevole di ultime N puntate
var windowSize = settings.AggressiveBidderWindowSize > 0 ? settings.AggressiveBidderWindowSize : 30;
var recentWindow = auction.RecentBids
.Take(windowSize)
.ToList();
var bidCounts = recentWindow
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
.ToList();
auction.AggressiveBidders.Clear();
foreach (var bidder in bidCounts)
{
// ?? FIX: NON aggiungere l'utente corrente come aggressivo!
if (bidder.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
continue;
// ?? FIX: Soglia pi permissiva - usa percentuale invece di conteggio assoluto
// Un bidder "aggressivo" se ha pi del 40% delle puntate nella finestra (configurabile)
var percentageThreshold = settings.AggressiveBidderPercentageThreshold > 0 ? settings.AggressiveBidderPercentageThreshold : 40.0;
if (bidder.Percentage >= percentageThreshold || bidder.Count >= settings.AggressiveBidderThreshold)
{
auction.AggressiveBidders.Add(bidder.Username);
}
}
}
/// <summary>
/// Rileva situazione di "duello" (solo 2 bidder attivi che si contendono l'asta)
/// In questa situazione bisogna essere pronti perch se uno si ritira l'altro vince
/// </summary>
private void DetectDuelSituation(AuctionInfo auction, AppSettings settings, string currentUsername)
{
var windowSize = settings.DuelDetectionWindowSize > 0 ? settings.DuelDetectionWindowSize : 20;
var recentWindow = auction.RecentBids.Take(windowSize).ToList();
if (recentWindow.Count < 6) // Serve un minimo di puntate per rilevare un pattern
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
return;
}
var bidders = recentWindow
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
.OrderByDescending(b => b.Count)
.ToList();
// Duello: esattamente 2 bidder dominanti che coprono almeno l'80% delle puntate
if (bidders.Count >= 2)
{
var top2Percentage = bidders.Take(2).Sum(b => b.Percentage);
if (top2Percentage >= 80 && bidders.Count <= 3)
{
auction.IsDuelSituation = true;
// Trova l'avversario (chi NON sono io)
var opponent = bidders.FirstOrDefault(b =>
!b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
auction.DuelOpponent = opponent?.Username;
// Calcola chi sta dominando
var myStats = bidders.FirstOrDefault(b =>
b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
auction.DuelAdvantage = myStats != null && opponent != null
? myStats.Percentage - opponent.Percentage
: 0;
}
else
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
auction.DuelAdvantage = 0;
}
}
else
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
}
}
/// <summary>
/// Verifica se il caso di puntare considerando tutte le strategie
/// </summary>
public BidDecision ShouldPlaceBid(AuctionInfo auction, AuctionState state, AppSettings settings, string currentUsername)
{
var decision = new BidDecision { ShouldBid = true };
// Se le strategie avanzate sono disabilitate per questa asta, salta tutto
if (auction.AdvancedStrategiesEnabled == false)
{
return decision;
}
// 0. DUELLO CON L'AUTOPUNTATA — il primo controllo, perché è quello che
// costa di più sbagliare.
//
// L'autopuntata del sito scatta a 2 secondi e si riarma ogni volta che il suo
// padrone perde la testa: risponde sempre, risponde in ~6 secondi, e non si
// stanca. Contro di lei non esiste attrito che si possa vincere — si
// scambiano puntate una a una finché uno dei due esaurisce il credito.
//
// Nelle prove reali il motore ci ha lasciato 191 puntate su tre aste per zero
// vittorie, con un ciclo regolare da 13,0 secondi (7 di attesa nostra più 6
// della sua risposta). Rigiocando i dossier con questa regola, quelle aste
// sarebbero costate una manciata di puntate senza perdere nessuna vittoria,
// perché vittorie non ce n'erano.
if (settings.AutoBidDuelWithdrawEnabled && auction.AutoBidDuelDetected)
{
decision.ShouldBid = false;
decision.Reason = Utilities.AutoBidDuel.Spiegazione(auction.AutoResponsesInARow);
return decision;
}
// ── 1. Paletti ──────────────────────────────────────────────────
// 0-bis. FASCIA ORARIA SOSPESA.
//
// L'asta NON viene fermata: resta Attiva, continua a essere seguita e
// riprende a puntare da sola alla fine della fascia. È voluto — le aste
// lunghe attraversano la fascia, e fermarle davvero significherebbe
// perderle invece che risparmiare.
//
// Su 6060 aste concluse: chiusura mediana al 4,6% del valore alle 12 e al
// 5,3% fra le 10 e le 13, contro l'8,6% a mezzanotte e il 9,7% alle 9, con
// 26-29 puntate per vincere invece di 21-24. Quasi il doppio del costo per
// lo stesso oggetto.
// 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.
if (QuietHoursApply(auction, settings) &&
Utilities.BiddingHours.IsQuiet(DateTime.Now, settings.QuietHoursStart, settings.QuietHoursEnd))
BiddingHours.IsQuiet(DateTime.Now, settings.QuietHoursStart, settings.QuietHoursEnd))
{
decision.ShouldBid = false;
decision.Reason = Utilities.BiddingHours.Spiegazione(
settings.QuietHoursStart, settings.QuietHoursEnd);
decision.Reason = BiddingHours.Spiegazione(settings.QuietHoursStart, settings.QuietHoursEnd);
return decision;
}
// 0-ter. VALORE ATTESO APPRESO.
//
// Il modello dice con che probabilità una puntata fatta adesso resterebbe
// senza risposta; il valore atteso è quella probabilità per il margine che
// resta (valore meno prezzo meno il centesimo), meno il costo della puntata.
// Sotto zero, in media, puntare perde soldi.
//
// Il modello parla sempre, ma decide solo quando ha appreso abbastanza aste:
// un modello appena nato direbbe cose a caso, e a caso fermerebbe le puntate.
// La probabilità e il valore atteso finiscono comunque sull'asta, per il
// registro e per l'interfaccia.
if (settings.LearningGateEnabled && auction.BuyNowPrice is > 0)
{
var prob = Ml.LearningService.PredictUnanswered(auction, state, DateTime.Now, currentUsername);
if (prob is { } p)
{
var costo = settings.AverageBidCostEuro * Math.Max(0.1, settings.LearningEvMultiplier);
var margine = auction.BuyNowPrice.Value - state.Price - 0.01;
var ev = p * margine - costo;
auction.LearnedUnansweredProbability = p;
auction.LearnedExpectedValue = ev;
var pronto = Ml.LearningService.IsReady(settings);
Ml.LearningService.RecordDecision(auction.Name, state.Price, p, ev, blocked: pronto && ev < 0, ready: pronto);
if (pronto && ev < 0)
{
decision.ShouldBid = false;
decision.Reason =
$"valore atteso negativo: P(senza risposta) {p:P2} × margine {margine:F2} € " +
$" puntata {costo:F2} € = {ev:+0.000;-0.000} €";
return decision;
}
}
}
// ? RIMOSSO: Entry Point - Era sbagliato!
// I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
// Se l'utente imposta MaxPrice=2, vuole puntare FINO A 2, non fino al 70%!
// I controlli MinPrice/MaxPrice sono gi gestiti in AuctionMonitor.ShouldBid()
// L'Entry Point pu essere usato SOLO per calcolare limiti CONSIGLIATI, non per bloccare.
// 1. ANTI-BOT — riconoscimento del puntatore a cadenza fissa.
//
// Spento di proposito (vedi AppSettings.AntiBotDetectionEnabled): rigiocando i
// dossier raccolti la regola rifiutava fra il 4% e il 9% delle puntate, a seconda
// dell'anticipo. Chi lo accende lo fa sapendo che e' una scelta di
// prudenza, non una difesa: un avversario a cadenza fissa è il più facile da
// battere, perché punta sempre con secondi di anticipo.
if (settings.AntiBotDetectionEnabled && !string.IsNullOrEmpty(state.LastBidder))
{
var botCheck = DetectBotPattern(auction, state.LastBidder, currentUsername);
if (botCheck.IsBot)
{
decision.ShouldBid = false;
decision.Reason = $"Anti-bot: {state.LastBidder} punta a cadenza fissa ({botCheck.GapSeconds:F0}s)";
return decision;
}
}
// ?? 2. USER EXHAUSTION - Sfrutta utenti stanchi (info solo, non blocca)
if (settings.UserExhaustionEnabled && !string.IsNullOrEmpty(state.LastBidder))
{
var exhaustionCheck = CheckUserExhaustion(auction, state.LastBidder, currentUsername);
// Non blocchiamo, ma potremmo loggare per info
}
// 3. Verifica soft retreat
if (settings.SoftRetreatEnabled || (auction.SoftRetreatEnabledOverride ?? settings.SoftRetreatEnabled))
{
if (auction.IsInSoftRetreat)
{
var retreatEnd = auction.LastSoftRetreatAt?.AddSeconds(settings.SoftRetreatDurationSeconds);
if (retreatEnd > DateTime.UtcNow)
{
decision.ShouldBid = false;
decision.Reason = $"Soft retreat attivo (termina tra {(retreatEnd.Value - DateTime.UtcNow).TotalSeconds:F0}s)";
return decision;
}
else
{
// Fine soft retreat
auction.IsInSoftRetreat = false;
auction.ConsecutiveCollisions = 0;
}
}
// Verifica se attivare soft retreat
if (auction.ConsecutiveCollisions >= settings.SoftRetreatAfterCollisions)
{
auction.IsInSoftRetreat = true;
auction.LastSoftRetreatAt = DateTime.UtcNow;
decision.ShouldBid = false;
decision.Reason = $"Soft retreat attivato dopo {auction.ConsecutiveCollisions} collisioni";
return decision;
}
}
// 2. Verifica competition threshold
if (settings.CompetitionDetectionEnabled)
{
if (auction.ActiveBiddersCount >= settings.CompetitionThreshold)
{
// Controlla se l'ultimo bidder sono io - se s, posso continuare
var lastBid = auction.RecentBids.OrderByDescending(b => b.Timestamp).FirstOrDefault();
if (lastBid != null && !lastBid.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
{
if (settings.AutoPauseHotAuctions && auction.HeatMetric >= settings.HeatThresholdForPause)
{
decision.ShouldBid = false;
decision.Reason = $"Asta troppo calda (heat={auction.HeatMetric}%, bidder={auction.ActiveBiddersCount})";
return decision;
}
}
}
}
// 3. Verifica opponent profiling
if (settings.OpponentProfilingEnabled && auction.AggressiveBidders.Count > 0)
{
if (settings.AggressiveBidderAction == "Avoid")
{
decision.ShouldBid = false;
decision.Reason = $"Bidder aggressivi rilevati: {string.Join(", ", auction.AggressiveBidders.Take(3))}";
return decision;
}
}
// 4. Probabilistic bidding
if (settings.ProbabilisticBiddingEnabled)
{
var probability = CalculateBidProbability(auction, settings);
var roll = _random.NextDouble();
if (roll > probability)
{
decision.ShouldBid = false;
decision.Reason = $"Skip probabilistico (p={probability:P0}, roll={roll:P0})";
return decision;
}
}
// 5. Bankroll manager
if (settings.BankrollManagerEnabled)
{
var bankrollCheck = CheckBankrollLimits(auction, settings);
@@ -364,150 +67,90 @@ namespace AutoBidder.Services
return decision;
}
}
// ? RIMOSSO: DetectLastSecondSniper - causava falsi positivi
// In un duello, TUTTI i bidder hanno pattern regolari (ogni reset del timer)
// Questa strategia bloccava puntate legittime e faceva perdere aste
// 7. VELOCITA' DEL PREZZO - l'asta sta salendo troppo in fretta.
// ── 2. Duello con l'autopuntata ─────────────────────────────────
//
// La soglia era fissa a 0,10 EUR/s, cioe' dieci puntate al secondo: su 40.000
// valutazioni riprese dai dossier non e' scattata mai una volta, e il massimo
// mai osservato e' 0,016 EUR/s. Ora e' un'impostazione, spenta di predefinito:
// un controllo che non puo' scattare da' una falsa sensazione di protezione.
if (settings.PriceVelocityBlockPerSecond > 0)
// L'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.
if (auction.AutoBidDuelDetected)
{
var priceVelocity = CalculatePriceVelocity(auction);
if (priceVelocity > settings.PriceVelocityBlockPerSecond)
auction.Regime.AutopuntataRiconosciuta();
if (settings.AutoBidDuelWithdrawEnabled)
{
decision.ShouldBid = false;
decision.Reason = $"Prezzo sale troppo in fretta ({priceVelocity:F3} EUR/s, soglia {settings.PriceVelocityBlockPerSecond:F3})";
decision.Reason = AutoBidDuel.Spiegazione(auction.AutoResponsesInARow);
return decision;
}
}
return decision;
}
/// <summary>
/// Calcola la velocit di crescita del prezzo (/secondo)
/// </summary>
private double CalculatePriceVelocity(AuctionInfo auction)
{
if (auction.RecentBids.Count < 5) return 0;
var recentBids = auction.RecentBids.Take(10).ToList();
if (recentBids.Count < 2) return 0;
var first = recentBids.Last();
var last = recentBids.First();
var timeDiffSeconds = last.Timestamp - first.Timestamp;
if (timeDiffSeconds <= 0) return 0;
var priceDiff = last.Price - first.Price;
return (double)priceDiff / timeDiffSeconds;
}
/// <summary>
/// Riconosce un avversario che punta a cadenza fissa.
///
/// <para><b>Limite di misura, da tenere presente.</b> Le marche temporali dello
/// storico di Bidoo sono in <i>secondi interi</i>: le pause fra due puntate sono
/// quindi numeri interi, e la loro deviazione standard vale 0 ms (pause tutte
/// uguali) oppure almeno 500 ms. La vecchia soglia "deviazione &lt; 50 ms" si
/// riduceva percio' a "le ultime pause sono identiche al secondo" - condizione
/// comunissima fra utenti normali: rigiocando i dossier raccolti rifiutava fra il
/// 4% e il 9% delle puntate, proprio negli istanti in cui il motore avrebbe
/// sparato.</para>
///
/// <para>Ora servono <b>quattro</b> pause tutte uguali e brevi (sotto i 15 s):
/// resta un indizio, non una prova, ed e' il motivo per cui l'impostazione che
/// la usa nasce spenta.</para>
/// </summary>
private (bool IsBot, double GapSeconds) DetectBotPattern(AuctionInfo auction, string? lastBidder, string currentUsername)
{
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
return (false, 0);
var userBids = auction.RecentBids
.Where(b => b.Username.Equals(lastBidder, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(b => b.Timestamp)
.Take(5)
.ToList();
// ── 3. Valore atteso appreso ────────────────────────────────────
//
// 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.
double? ev = null;
string? spiegazioneEv = null;
var pronto = false;
if (userBids.Count < 5) return (false, 0);
var gaps = new List<long>();
for (var i = 0; i < userBids.Count - 1; i++)
gaps.Add(userBids[i].Timestamp - userBids[i + 1].Timestamp);
// Una pausa nulla vuol dire due puntate nello stesso secondo: e' rumore
// dello storico, non una cadenza.
if (gaps.Count < 4 || gaps.Any(g => g <= 0 || g > 15)) return (false, 0);
var isBot = gaps.All(g => g == gaps[0]);
return (isBot, gaps[0]);
}
/// <summary>
/// Verifica se un utente esausto (molte puntate, pu mollare)
/// </summary>
private (bool ShouldExploit, string Reason) CheckUserExhaustion(AuctionInfo auction, string? lastBidder, string currentUsername)
{
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
return (false, "");
// Verifica se l'utente un "heavy user" (>50 puntate totali)
if (auction.BidderStats.TryGetValue(lastBidder, out var stats))
if (settings.LearningGateEnabled && auction.BuyNowPrice is > 0)
{
if (stats.BidCount > 50)
var prob = Ml.LearningService.PredictUnanswered(auction, state, DateTime.Now, currentUsername);
if (prob is { } p)
{
// Se ci sono pochi altri bidder attivi, pu essere un buon momento
var activeBidders = auction.BidderStats.Values.Count(b => b.BidCount > 5);
if (activeBidders <= 3)
{
return (true, $"{lastBidder} ha {stats.BidCount} puntate, potrebbe mollare");
}
var costo = settings.AverageBidCostEuro * Math.Max(0.1, settings.LearningEvMultiplier);
var margine = auction.BuyNowPrice.Value - state.Price - 0.01;
var valore = p * margine - costo;
auction.LearnedUnansweredProbability = p;
auction.LearnedExpectedValue = valore;
pronto = Ml.LearningService.IsReady(settings);
ev = valore;
spiegazioneEv =
$"valore atteso negativo: P(senza risposta) {p:P2} × margine {margine:F2} € " +
$" puntata {costo:F2} € = {valore:+0.000;-0.000} €";
Ml.LearningService.RecordDecision(auction.Name, state.Price, p, valore,
blocked: pronto && valore < 0, ready: pronto);
}
}
return (false, "");
}
/// <summary>
/// Calcola probabilit di puntata basata su competizione e ROI
/// </summary>
private double CalculateBidProbability(AuctionInfo auction, AppSettings settings)
{
var probability = settings.BaseBidProbability;
// Riduci probabilit per ogni bidder attivo oltre la soglia
var extraBidders = Math.Max(0, auction.ActiveBiddersCount - settings.CompetitionThreshold);
probability -= extraBidders * settings.ProbabilityReductionPerBidder;
// Riduci per heat metric alto
if (auction.HeatMetric > 70)
// ── 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.
if (pronto && ev is { } valoreAtteso)
{
probability -= 0.1;
var statoPrima = auction.Regime.StatoAttuale;
var consentito = auction.Regime.Osserva(valoreAtteso >= 0);
if (auction.Regime.StatoAttuale != statoPrima)
{
auction.AddLog($"[REGIME] {statoPrima} → {auction.Regime.StatoAttuale}: {auction.Regime.Spiegazione()}",
AuctionLogLevel.Strategy, AuctionLogCategory.Strategy);
}
if (!consentito)
{
decision.ShouldBid = false;
decision.Reason = auction.Regime.StatoAttuale == Ml.CompetitionRegime.Stato.Calmo
? spiegazioneEv
: auction.Regime.Spiegazione();
return decision;
}
}
// Aumenta se abbiamo un buon ROI potenziale
if (auction.CalculatedValue?.Savings > 0)
{
probability += 0.1;
}
return Math.Clamp(probability, 0.1, 1.0);
return decision;
}
/// <summary>
/// Verifica limiti bankroll
/// </summary>
private BankrollCheckResult CheckBankrollLimits(AuctionInfo auction, AppSettings settings)
{
var result = new BankrollCheckResult { CanBid = true };
// Limite puntate per asta
var maxPerAuction = auction.MaxBidsOverride ?? settings.MaxBidsPerAuction;
if (maxPerAuction > 0 && auction.SessionBidCount >= maxPerAuction)
@@ -516,7 +159,7 @@ namespace AutoBidder.Services
result.Reason = $"Limite puntate per asta raggiunto ({auction.SessionBidCount}/{maxPerAuction})";
return result;
}
// Limite puntate per sessione
if (settings.MaxBidsPerSession > 0 && _sessionTotalBids >= settings.MaxBidsPerSession)
{
@@ -524,7 +167,7 @@ namespace AutoBidder.Services
result.Reason = $"Limite puntate per sessione raggiunto ({_sessionTotalBids}/{settings.MaxBidsPerSession})";
return result;
}
// Budget giornaliero
if (settings.DailyBudgetEuro > 0)
{
@@ -532,17 +175,14 @@ namespace AutoBidder.Services
if (spent >= settings.DailyBudgetEuro)
{
result.CanBid = false;
result.Reason = $"Budget giornaliero esaurito ({spent:F2}/{settings.DailyBudgetEuro:F2})";
result.Reason = $"Budget giornaliero esaurito ({spent:F2}/{settings.DailyBudgetEuro:F2})";
return result;
}
}
return result;
}
/// <summary>
/// Registra una puntata effettuata (per tracking)
/// </summary>
/// <summary>
/// La sospensione a fascia oraria vale per quest'asta?
///
@@ -563,60 +203,40 @@ namespace AutoBidder.Services
return settings.QuietHoursEnabled;
}
public void RecordBidAttempt(AuctionInfo auction, bool success, bool collision = false)
/// <summary>Registra una puntata inviata, riuscita o no.</summary>
public void RecordBidAttempt(AuctionInfo auction, bool success)
{
auction.SessionBidCount++;
_sessionTotalBids++;
if (success)
{
auction.SuccessfulBidCount++;
auction.ConsecutiveCollisions = 0;
// 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 la regola del duello vede le stesse cose in entrambi.
// quindi duello e regime vedono le stesse cose in entrambi.
auction.LastMyBidAtUtc = DateTime.UtcNow;
auction.Regime.PuntataPiazzata();
}
else
{
auction.FailedBidCount++;
}
if (collision)
{
auction.CollisionCount++;
auction.ConsecutiveCollisions++;
}
}
/// <summary>
/// Registra un ciclo perso perche' la puntata e' arrivata a giochi chiusi.
///
/// <para>Qui <b>non</b> si tocca <c>ConsecutiveCollisions</c>: chi chiama questo
/// metodo ha gia' chiamato <see cref="RecordBidAttempt"/> con <c>collision: true</c>
/// sulla stessa puntata, e il contatore veniva percio' incrementato due volte.
/// Con la soglia predefinita di tre collisioni bastavano <i>due</i> puntate tardive
/// per far scattare il ritiro - e un ritiro di trenta secondi, su cicli da otto o
/// dieci, significa perdere l'asta.</para>
/// </summary>
/// <summary>Un ciclo perso perche' la puntata e' arrivata a giochi chiusi.</summary>
public void RecordTimerExpired(AuctionInfo auction)
{
auction.TimerExpiredCount++;
}
/// <summary>
/// Reset contatori sessione
/// </summary>
public void ResetSession()
{
_sessionTotalBids = 0;
_sessionStartedAt = DateTime.UtcNow;
}
/// <summary>
/// Ottiene statistiche sessione corrente
/// </summary>
public SessionStats GetSessionStats()
{
return new SessionStats
@@ -626,41 +246,19 @@ namespace AutoBidder.Services
};
}
}
/// <summary>
/// Risultato calcolo timing puntata
/// </summary>
public class BidTimingResult
{
public int BaseOffsetMs { get; set; }
public int LatencyCompensationMs { get; set; }
public int DynamicAdjustmentMs { get; set; }
public int JitterMs { get; set; }
public int FinalOffsetMs { get; set; }
public bool ShouldBid { get; set; }
}
/// <summary>
/// Decisione se puntare
/// </summary>
public class BidDecision
{
public bool ShouldBid { get; set; }
public string? Reason { get; set; }
}
/// <summary>
/// Risultato verifica bankroll
/// </summary>
public class BankrollCheckResult
{
public bool CanBid { get; set; }
public string? Reason { get; set; }
}
/// <summary>
/// Statistiche sessione
/// </summary>
public class SessionStats
{
public int TotalBids { get; set; }
+217
View File
@@ -0,0 +1,217 @@
using AutoBidder.Ml;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il regime di concorrenza deve avere isteresi nel verso giusto: uscire e' facile, rientrare
/// e' difficile, e ogni sondaggio coperto rende il rientro successivo piu' difficile.
/// Sbagliare a rientrare costa una puntata; sbagliare a uscire costa un'attesa.
/// </summary>
public class CompetitionRegimeTests
{
[Fact]
public void In_calmo_si_punta_quando_il_valore_atteso_e_positivo()
{
var r = new CompetitionRegime();
Assert.True(r.Osserva(true));
Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
}
[Fact]
public void Tre_negativi_di_fila_portano_allo_sfogo_ma_uno_solo_no()
{
var r = new CompetitionRegime();
Assert.False(r.Osserva(false));
Assert.False(r.Osserva(false));
Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
Assert.False(r.Osserva(false));
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
}
[Fact]
public void Un_positivo_in_mezzo_azzera_il_conto_dei_negativi()
{
var r = new CompetitionRegime();
r.Osserva(false); r.Osserva(false);
r.Osserva(true);
r.Osserva(false); r.Osserva(false);
Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
}
private static CompetitionRegime InSfogo()
{
var r = new CompetitionRegime();
for (var i = 0; i < CompetitionRegime.NegativiPerSfogo; i++) r.Osserva(false);
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
return r;
}
[Fact]
public void Dallo_sfogo_si_rientra_solo_dopo_abbastanza_cicli_buoni_di_fila()
{
var r = InSfogo();
Assert.False(r.Osserva(true));
Assert.False(r.Osserva(true));
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
Assert.True(r.Osserva(true)); // terzo: puntata di prova concessa
Assert.Equal(CompetitionRegime.Stato.Sondaggio, r.StatoAttuale);
}
[Fact]
public void Un_ciclo_cattivo_durante_lo_sfogo_azzera_la_serie()
{
var r = InSfogo();
r.Osserva(true); r.Osserva(true);
r.Osserva(false);
r.Osserva(true); r.Osserva(true);
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
}
[Fact]
public void Un_sondaggio_coperto_subito_raddoppia_la_pazienza()
{
var r = InSfogo();
for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
r.PuntataPiazzata();
r.RispostaAvversaria(6.0); // la firma dell'autopuntata
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
Assert.Equal(CompetitionRegime.PazienzaIniziale * 2, r.Pazienza);
Assert.Equal(1, r.SondaggiFalliti);
}
[Fact]
public void Un_sondaggio_senza_risposta_riporta_al_calmo()
{
var r = InSfogo();
for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
r.PuntataPiazzata();
r.NessunaRisposta();
Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
}
[Fact]
public void Una_risposta_lenta_al_sondaggio_non_e_una_macchina()
{
var r = InSfogo();
for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
r.PuntataPiazzata();
r.RispostaAvversaria(25.0);
Assert.Equal(CompetitionRegime.Stato.Calmo, r.StatoAttuale);
Assert.Equal(0, r.SondaggiFalliti);
}
[Fact]
public void Durante_il_sondaggio_si_punta_una_volta_sola()
{
var r = InSfogo();
for (var i = 0; i < CompetitionRegime.PazienzaIniziale; i++) r.Osserva(true);
r.PuntataPiazzata();
Assert.False(r.Osserva(true)); // la prova e' in corso: si aspetta l'esito
}
[Fact]
public void La_pazienza_ha_un_tetto()
{
var r = new CompetitionRegime();
for (var k = 0; k < 10; k++)
{
if (r.StatoAttuale == CompetitionRegime.Stato.Calmo)
for (var i = 0; i < CompetitionRegime.NegativiPerSfogo; i++) r.Osserva(false);
while (r.StatoAttuale == CompetitionRegime.Stato.Sfogo) r.Osserva(true);
r.PuntataPiazzata();
r.RispostaAvversaria(6.0);
}
Assert.Equal(CompetitionRegime.PazienzaMassima, r.Pazienza);
}
[Fact]
public void L_autopuntata_riconosciuta_manda_in_sfogo_con_pazienza_doppia()
{
var r = new CompetitionRegime();
r.AutopuntataRiconosciuta();
Assert.Equal(CompetitionRegime.Stato.Sfogo, r.StatoAttuale);
Assert.True(r.Pazienza >= CompetitionRegime.PazienzaIniziale * 2);
}
}
/// <summary>
/// L'anticipo adattivo si muove solo dentro i paletti, sale subito su una puntata
/// tardiva e scende piano, e senza campioni usa il predefinito.
/// </summary>
public class LatencyModelTests
{
private static AppSettings Paletti(int min = 300, int max = 1500, int predefinito = 500) =>
new() { LeadMinMs = min, LeadMaxMs = max, DefaultBidBeforeDeadlineMs = predefinito };
[Fact]
public void Senza_campioni_vale_il_predefinito_entro_i_paletti()
{
LatencyModel.ResetForTests();
Assert.Equal(500, LatencyModel.RecommendedLeadMs(Paletti()));
Assert.Equal(600, LatencyModel.RecommendedLeadMs(Paletti(min: 600)));
}
[Fact]
public void L_anticipo_e_margine_piu_coda_della_latenza()
{
LatencyModel.ResetForTests(marginMs: 300);
for (var i = 0; i < 98; i++) LatencyModel.NotePing(50);
LatencyModel.NotePing(400); // la coda: due campioni su cento
LatencyModel.NotePing(400);
// p99 su 100 campioni = il 99-esimo ordinato = 400 -> 300 + 400 = 700
Assert.Equal(700, LatencyModel.RecommendedLeadMs(Paletti()));
}
[Fact]
public void Una_puntata_tardiva_alza_il_margine_subito()
{
LatencyModel.ResetForTests(marginMs: 300);
for (var i = 0; i < 20; i++) LatencyModel.NotePing(50);
var prima = LatencyModel.RecommendedLeadMs(Paletti());
LatencyModel.NoteBid(60, late: true);
var dopo = LatencyModel.RecommendedLeadMs(Paletti());
Assert.Equal(prima + 150, dopo);
}
[Fact]
public void Molte_puntate_in_tempo_lo_abbassano_piano()
{
LatencyModel.ResetForTests(marginMs: 600);
for (var i = 0; i < 20; i++) LatencyModel.NotePing(50);
var prima = LatencyModel.RecommendedLeadMs(Paletti());
// Stessa latenza dei ping, cosi' cambia solo il margine e non la coda.
for (var i = 0; i < 20; i++) LatencyModel.NoteBid(50, late: false);
var dopo = LatencyModel.RecommendedLeadMs(Paletti());
Assert.Equal(prima - 25, dopo);
}
[Fact]
public void Non_esce_mai_dai_paletti()
{
LatencyModel.ResetForTests(marginMs: 2000);
for (var i = 0; i < 20; i++) LatencyModel.NotePing(900);
Assert.Equal(1500, LatencyModel.RecommendedLeadMs(Paletti(max: 1500)));
LatencyModel.ResetForTests(marginMs: 150);
for (var i = 0; i < 20; i++) LatencyModel.NotePing(10);
Assert.Equal(300, LatencyModel.RecommendedLeadMs(Paletti(min: 300)));
}
}
+4 -39
View File
@@ -35,13 +35,9 @@ public class BacktestTests
/// <summary>Impostazioni con tutte le strategie che potrebbero bloccare messe a tacere.</summary>
private static AppSettings Neutral() => new()
{
AntiBotDetectionEnabled = false,
SoftRetreatEnabled = false,
ProbabilisticBiddingEnabled = false,
CompetitionDetectionEnabled = false,
OpponentProfilingEnabled = false,
BankrollManagerEnabled = false,
PriceVelocityBlockPerSecond = 0
AutoBidDuelWithdrawEnabled = false,
LearningGateEnabled = false
};
// ── Lettura ──────────────────────────────────────────────────────────
@@ -195,37 +191,6 @@ public class BacktestTests
// ── Le strategie che bloccano ────────────────────────────────────────
[Fact]
public void Lanti_bot_acceso_blocca_su_cadenza_fissa_e_spento_no()
{
// Un avversario che punta esattamente ogni 4 secondi: e' il caso che la vecchia
// regola scambiava per automatismo, e sui dossier veri costava fra il 4% e il 9%
// delle puntate.
var lines = new List<string> { Header };
long unix = 1000;
for (var i = 0; i < 8; i++)
{
lines.Add(Bid(i * 0.1, "tizio", unix + i * 4, 1.0 + i * 0.01));
}
lines.Add(Poll(1.0, 1100, 1100, bidder: "tizio"));
lines.Add(SummaryLine);
var session = DossierReader.Read(lines);
var acceso = Neutral();
acceso.AntiBotDetectionEnabled = true;
var bloccato = BacktestRunner.Run(session, new BacktestRunner.Options(1000, acceso));
Assert.Equal(1, bloccato.Reached);
Assert.Equal(0, bloccato.Bids);
Assert.Equal(1, bloccato.Blocks["anti-bot"]);
// Con il predefinito nuovo (spento) la puntata parte.
var spento = BacktestRunner.Run(session, new BacktestRunner.Options(1000, Neutral()));
Assert.Equal(1, spento.Bids);
Assert.Empty(spento.Blocks);
}
[Fact]
public void Il_predefinito_dellapplicazione_non_blocca_le_puntate()
{
@@ -253,7 +218,7 @@ public class BacktestTests
{
new() { Cycles = 10, Reached = 1, Bids = 1, FinalPrice = 1 },
new() { Cycles = 20, Reached = 5, Bids = 3, FinalPrice = 2,
Blocks = new Dictionary<string, int>(StringComparer.Ordinal) { ["anti-bot"] = 2 } },
Blocks = new Dictionary<string, int>(StringComparer.Ordinal) { ["sfogo"] = 2 } },
new() { Cycles = 30, Reached = 1, Bids = 1, FinalPrice = 3 }
};
@@ -269,7 +234,7 @@ public class BacktestTests
Assert.Equal(2, a.AuctionsWithOneBid);
var text = BacktestReport.Format(new[] { a });
Assert.Contains("anti-bot=2", text);
Assert.Contains("sfogo=2", text);
}
[Fact]
+102
View File
@@ -0,0 +1,102 @@
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); }
}
}
+9 -26
View File
@@ -53,19 +53,6 @@ public class RealDossierBacktest
// vuole mettere alla prova, non una configurazione inventata per l'occasione.
var settings = new AppSettings();
// Con AUTOBIDDER_BACKTEST_LEGACY=1 si rimettono i vecchi predefiniti, per vedere
// quanto costavano davvero. Serve a rendere visibile la regressione da cui questa
// prova difende: senza un confronto, "0 puntate bloccate" non dice se il controllo
// funziona o se semplicemente non c'era nulla da bloccare.
var legacy = Environment.GetEnvironmentVariable("AUTOBIDDER_BACKTEST_LEGACY") == "1";
if (legacy)
{
settings.AntiBotDetectionEnabled = true;
settings.SoftRetreatDurationSeconds = 30;
settings.PriceVelocityBlockPerSecond = 0.10;
_output.WriteLine("*** vecchi predefiniti (anti-bot acceso, ritiro 30 s, velocita' 0,10 EUR/s) ***");
}
var aggregates = BacktestReport.Run(folder, Leads, settings, username: "", maxFiles: max);
var text = BacktestReport.Format(aggregates);
@@ -85,25 +72,21 @@ public class RealDossierBacktest
return;
}
// Le due condizioni che la rigiocata deve garantire sui dati veri:
// il motore non si inceppa, e coi predefiniti nessuna strategia rifiuta puntate.
// Le due condizioni che la rigiocata deve garantire sui dati veri: il motore non
// si inceppa, e ogni blocco ha un motivo riconosciuto. I motivi leciti sono i
// paletti (budget, pareggio, tetti) e le tre cose apprese o misurate sul momento
// (duello con l'autopuntata, valore atteso, regime). Quello che NON deve esserci
// e' un motivo che nessuno riconosce: sarebbe una regola entrata di nascosto.
foreach (var a in aggregates)
{
Assert.True(a.Reached >= 0 && a.Bids <= a.Reached,
$"conteggi incoerenti con anticipo {a.LeadMs} ms");
if (legacy) continue; // coi vecchi predefiniti i blocchi sono il punto
var ignoti = a.Blocks.Where(kv => kv.Key == "altro").ToList();
// I blocchi di budget e pareggio sono il comportamento voluto: fermano le
// puntate che farebbero perdere soldi, e su un archivio vero devono esserci.
// Quello che NON deve esserci e' un blocco da strategia "difensiva".
var difensivi = a.Blocks
.Where(kv => kv.Key is not ("pareggio" or "tetto-spesa" or "tetto-puntate"))
.ToList();
Assert.True(difensivi.Count == 0,
$"con i predefiniti, anticipo {a.LeadMs} ms: puntate bloccate da strategie " +
$"difensive ({string.Join(", ", difensivi.Select(kv => $"{kv.Key}={kv.Value}"))})");
Assert.True(ignoti.Count == 0,
$"anticipo {a.LeadMs} ms: puntate bloccate per un motivo non riconosciuto " +
$"({string.Join(", ", ignoti.Select(kv => $"{kv.Key}={kv.Value}"))})");
}
}
}
+53
View File
@@ -0,0 +1,53 @@
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");
}
}
+80
View File
@@ -0,0 +1,80 @@
using System;
using System.IO;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// 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>
public class StatsWipeTests
{
[Fact]
public void Toglie_gli_archivi_mensili_e_lascia_il_resto()
{
var stats = AppPaths.StatsFolder;
Directory.CreateDirectory(stats);
var legacy = Path.Combine(stats, "aste-2020-01.jsonl");
var legacyJson = Path.Combine(stats, "aste-2020-02.json");
var altro = Path.Combine(stats, $"altro-{Guid.NewGuid():N}.json");
File.WriteAllText(legacy, "{}\n");
File.WriteAllText(legacyJson, "[]");
File.WriteAllText(altro, "[]");
try
{
var prima = StatsWipe.Measure();
Assert.True(prima.LegacyFiles >= 2);
var report = StatsWipe.Run(new StatsWipe.Options
{
History = false, ProductStats = false, BidLeadMeasures = false,
Exports = false, LegacyArchives = true, Learning = false
});
Assert.False(File.Exists(legacy));
Assert.False(File.Exists(legacyJson));
Assert.True(File.Exists(altro));
Assert.Null(report.BackupPath);
Assert.Empty(report.Errors);
Assert.Contains(report.Steps, s => s.StartsWith("archivi mensili"));
}
finally
{
foreach (var f in new[] { legacy, legacyJson, altro }) if (File.Exists(f)) File.Delete(f);
}
}
[Fact]
public void Svuota_le_esportazioni_senza_toccare_altro()
{
Directory.CreateDirectory(AppPaths.ExportFolder);
var export = Path.Combine(AppPaths.ExportFolder, $"storico-{Guid.NewGuid():N}.csv");
File.WriteAllText(export, "a;b\n");
var (files, bytes) = StatsWipe.ClearExports();
Assert.False(File.Exists(export));
Assert.True(files >= 1);
Assert.True(bytes >= 4);
}
[Fact]
public void Senza_voci_scelte_non_fa_nulla()
{
var o = new StatsWipe.Options
{
History = false, ProductStats = false, BidLeadMeasures = false,
Exports = false, LegacyArchives = false, Learning = false
};
Assert.True(o.Nothing);
var report = StatsWipe.Run(o);
Assert.Empty(report.Steps);
Assert.Null(report.BackupPath);
}
}
+209 -54
View File
@@ -2,81 +2,74 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
/// <summary>
/// Schede dettagliate delle aste concluse, una riga per asta.
/// Le schede dettagliate delle aste concluse, lette dai dossier.
///
/// <para>Formato <b>JSON Lines</b>: un oggetto per riga, file separato per mese. La
/// scelta è deliberata — un unico documento JSON andrebbe riletto e riscritto per
/// intero a ogni asta conclusa, mentre qui si accoda e basta; e un file che cresce per
/// mesi diventerebbe scomodo da aprire e impossibile da leggere a pezzi. Ogni riga è
/// indipendente: strumenti di analisi e fogli di calcolo li leggono in streaming, e un
/// file troncato perde al più l'ultima riga invece di diventare illeggibile.</para>
/// <para>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>
/// </summary>
public static class AuctionDetailStore
{
private static readonly object Sync = new();
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = false // una riga per record: il formato lo richiede
};
/// <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;
/// <summary>File del mese indicato (predefinito: quello corrente).</summary>
public static string FileFor(DateTime when) =>
Path.Combine(AppPaths.StatsFolder, $"aste-{when:yyyy-MM}.jsonl");
private sealed record CacheEntry(long Length, DateTime LastWrite, AuctionDetailRecord? Record);
public static void Append(AuctionDetailRecord record)
{
if (record == null) return;
private static readonly Dictionary<string, CacheEntry> Cache = new(StringComparer.OrdinalIgnoreCase);
try
{
AppPaths.EnsureFolders();
var line = JsonSerializer.Serialize(record, Options);
lock (Sync)
{
File.AppendAllText(FileFor(record.EndedAt), line + Environment.NewLine);
}
}
catch { /* la raccolta dati non deve mai interrompere il monitoraggio */ }
}
/// <summary>Tutte le schede registrate, dalla più recente.</summary>
/// <summary>Tutte le schede delle aste con dossier concluso, dalla più recente.</summary>
public static List<AuctionDetailRecord> LoadAll()
{
var result = new List<AuctionDetailRecord>();
try
{
if (!Directory.Exists(AppPaths.StatsFolder)) return result;
var folder = AppPaths.AuctionLogFolder;
if (!Directory.Exists(folder)) return result;
lock (Sync)
{
foreach (var file in Directory.GetFiles(AppPaths.StatsFolder, "aste-*.jsonl"))
{
foreach (var line in File.ReadLines(file))
{
if (string.IsNullOrWhiteSpace(line)) continue;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
var record = JsonSerializer.Deserialize<AuctionDetailRecord>(line);
if (record != null) result.Add(record);
}
catch
{
// Una riga corrotta non deve far perdere tutte le altre:
// è il vantaggio principale di un record per riga.
}
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 { }
@@ -84,19 +77,181 @@ namespace AutoBidder.Utilities
return result.OrderByDescending(r => r.EndedAt).ToList();
}
public static int Count()
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)
{
try
{
if (!Directory.Exists(AppPaths.StatsFolder)) return 0;
string? headerLine, lastLine;
lock (Sync)
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
return Directory.GetFiles(AppPaths.StatsFolder, "aste-*.jsonl")
.Sum(f => File.ReadLines(f).Count(l => !string.IsNullOrWhiteSpace(l)));
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;
}
}
// ── Lettura di testa e coda ──────────────────────────────────────
private static string? ReadFirstLine(FileStream fs)
{
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;
}
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)
{
var r = new AuctionDetailRecord
{
AuctionId = Str(h, "auctionId"),
Name = Str(h, "name"),
ProductKey = Str(h, "productKey"),
Url = Str(h, "url"),
DossierPath = path,
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")
};
if (h.TryGetProperty("product", out var product))
{
r.BuyNowPrice = NullableNum(product, "buyNowPrice");
r.BidCostEuro = Num(product, "bidCostEuro", 0.20);
}
if (s.TryGetProperty("coverage", out var cov))
{
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)
{
foreach (var p in byUser.EnumerateObject())
if (p.Value.TryGetInt32(out var n)) r.BidsByUser[p.Name] = n;
}
}
catch { return 0; }
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") });
}
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();
}
}
}
+5 -6
View File
@@ -233,12 +233,12 @@ namespace AutoBidder.Utilities
maxBids = auction.MaxClicks,
minResets = auction.MinResets,
maxResets = auction.MaxResets,
pollCriticalMs = settings.PollIntervalCriticalMs,
criticalWindowMs = settings.CriticalWindowMs,
bidLeadIsManual = auction.BidLeadIsManual,
adaptiveLead = settings.AdaptiveLeadEnabled,
leadMinMs = settings.LeadMinMs,
leadMaxMs = settings.LeadMaxMs,
valueCheckEnabled = settings.ValueCheckEnabled,
minSavingsPercentage = settings.MinSavingsPercentage,
antiBotEnabled = settings.AntiBotDetectionEnabled,
competitionEnabled = settings.CompetitionDetectionEnabled,
rawPollsIncluded = settings.DossierIncludeRawPolls
}
});
@@ -324,8 +324,7 @@ namespace AutoBidder.Utilities
configuredLeadMs = detail.ConfiguredLeadMs,
timerExpiredCount = auction.TimerExpiredCount,
successfulBids = auction.SuccessfulBidCount,
failedBids = auction.FailedBidCount,
collisions = auction.CollisionCount
failedBids = auction.FailedBidCount
},
priceSeries = detail.PriceSeries.Select(p => new { t = Math.Round(p.T, 2), price = p.Price }),
+15
View File
@@ -279,6 +279,21 @@ namespace AutoBidder.Utilities
return summaries.Count;
}
/// <summary>
/// Azzera i numeri di tutte le schede tenendo le opzioni scelte nella scheda
/// Prodotti: si perde la statistica, non la configurazione.
/// </summary>
public static void ClearStatistics()
{
lock (Sync)
{
_cache = new StoreData();
Save(_cache);
}
SyncOptionsFromWatchList();
}
private static void SyncOptions(ProductCard card)
{
var product = WatchedProductsStore.GetAll()
+23 -257
View File
@@ -56,8 +56,6 @@ namespace AutoBidder.Utilities
public double DefaultMinPrice { get; set; } = 0;
public double DefaultMaxPrice { get; set; } = 0;
public int DefaultMaxClicks { get; set; } = 0;
public int DefaultMinResets { get; set; } = 0;
public int DefaultMaxResets { get; set; } = 0;
/// <summary>
/// Mostra avviso quando una puntata arriva troppo tardi (timer scaduto).
@@ -73,25 +71,6 @@ namespace AutoBidder.Utilities
// la scadenza si avvicina: un'asta a otto minuti non ha bisogno di quattro
// chiamate al secondo, una a tre secondi sì.
/// <summary>Cadenza polling oltre i 60 s dalla scadenza. Default: 2000 ms.</summary>
public int PollIntervalFarMs { get; set; } = 2000;
/// <summary>Cadenza polling tra 10 e 60 s dalla scadenza. Default: 900 ms.</summary>
public int PollIntervalMidMs { get; set; } = 900;
/// <summary>Cadenza polling sotto i 10 s dalla scadenza. Default: 400 ms.</summary>
public int PollIntervalNearMs { get; set; } = 400;
/// <summary>
/// Cadenza polling nella finestra critica, solo per le aste in stato Attiva.
/// In sola osservazione non c'è puntata da azzeccare e si risparmiano chiamate.
/// Default: 220 ms.
/// </summary>
public int PollIntervalCriticalMs { get; set; } = 220;
/// <summary>Ampiezza della finestra critica prima della scadenza. Default: 4000 ms.</summary>
public int CriticalWindowMs { get; set; } = 4000;
/// <summary>
/// Tetto complessivo di richieste al secondo verso Bidoo (le puntate non sono soggette
/// al limite: hanno corsia preferenziale). Default: 40.
@@ -147,12 +126,6 @@ namespace AutoBidder.Utilities
/// </summary>
public bool BidLeadTrackingEnabled { get; set; } = true;
/// <summary>
/// Propone (non applica) una correzione dell'anticipo quando i dati raccolti
/// mostrano uno scarto sistematico. Default: true.
/// </summary>
public bool BidLeadSuggestionsEnabled { get; set; } = true;
/// <summary>Misure necessarie prima di azzardare una proposta. Default: 15.</summary>
public int BidLeadMinSamples { get; set; } = 15;
@@ -217,12 +190,6 @@ namespace AutoBidder.Utilities
/// </summary>
public int LogRetentionDays { get; set; } = 90;
/// <summary>
/// Registra una scheda dettagliata per ogni asta conclusa (serie dei prezzi,
/// puntate per utente, durata). È la materia prima per migliorare le strategie.
/// </summary>
public bool DetailedStatsEnabled { get; set; } = true;
// ═══════════════════════════════════════════════════════════════════
// CATALOGO — cache
// ═══════════════════════════════════════════════════════════════════
@@ -401,31 +368,6 @@ namespace AutoBidder.Utilities
// IMPOSTAZIONI DATABASE
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Abilita il salvataggio automatico delle aste completate nel database.
/// Default: true (consigliato per statistiche)
/// </summary>
public bool DatabaseAutoSaveEnabled { get; set; } = true;
/// <summary>
/// Esegue pulizia automatica duplicati all'avvio dell'applicazione.
/// Default: true (consigliato per mantenere database pulito)
/// </summary>
public bool DatabaseAutoCleanupDuplicates { get; set; } = true;
/// <summary>
/// Esegue pulizia automatica record incompleti all'avvio.
/// Default: false (può rimuovere dati utili in caso di errori temporanei)
/// </summary>
public bool DatabaseAutoCleanupIncomplete { get; set; } = false;
/// <summary>
/// Numero massimo di giorni da mantenere nei risultati aste.
/// Record più vecchi vengono eliminati automaticamente.
/// Default: 180 (6 mesi), 0 = disabilitato
/// </summary>
public int DatabaseMaxRetentionDays { get; set; } = 180;
// ???????????????????????????????????????????????????????????????
// STRATEGIE AVANZATE DI PUNTATA
// ???????????????????????????????????????????????????????????????
@@ -449,24 +391,6 @@ namespace AutoBidder.Utilities
/// </summary>
public bool LogStrategyDecisions { get; set; } = true;
/// <summary>
/// Log calcoli valore prodotto [VALUE]
/// Default: false (attiva per debug)
/// </summary>
public bool LogValueCalculations { get; set; } = false;
/// <summary>
/// Log rilevamento competizione e heat [COMPETITION]
/// Default: false
/// </summary>
public bool LogCompetition { get; set; } = false;
/// <summary>
/// Log timing e polling (molto verbose!) [TIMING]
/// Default: false (attiva solo per debug timing)
/// </summary>
public bool LogTiming { get; set; } = false;
/// <summary>
/// Log errori e warning [ERROR/WARN]
/// Default: true
@@ -494,45 +418,9 @@ namespace AutoBidder.Utilities
/// </summary>
public bool LogAuctionStatus { get; set; } = true;
/// <summary>
/// Log profiling avversari [OPPONENT]
/// Default: false
/// </summary>
public bool LogOpponentProfiling { get; set; } = false;
// 🎯 STRATEGIE SEMPLIFICATE
/// <summary>
/// Entry Point: Usato SOLO per calcolare i limiti consigliati (70% del MaxPrice storico).
/// NON blocca le puntate! I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
/// Default: true (per calcolo limiti consigliati)
/// </summary>
public bool EntryPointEnabled { get; set; } = true;
/// <summary>
/// Anti-bot: rinuncia al ciclo quando l'ultimo puntatore punta a cadenza fissa.
///
/// <para><b>Predefinito cambiato a false.</b> Le marche temporali di Bidoo sono in
/// secondi interi, quindi la regola non puo' davvero distinguere un automatismo da
/// una persona regolare: si riduce a "le ultime pause dell'avversario sono identiche
/// al secondo", cosa comunissima. Rigiocando i dossier con <c>backtest.ps1</c>
/// rifiutava fra il <b>4% e il 9%</b> delle puntate, a seconda dell'anticipo, e la
/// premessa era comunque rovesciata: un avversario a cadenza fissa punta con secondi
/// di anticipo, quindi e' il piu' facile da battere aspettando l'ultimo istante.</para>
///
/// <para>Per rivedere il confronto con i propri dati:
/// <c>$env:AUTOBIDDER_BACKTEST_LEGACY=1</c> prima di <c>backtest.ps1</c>.</para>
/// </summary>
public bool AntiBotDetectionEnabled { get; set; } = false;
/// <summary>
/// User Exhaustion: Sfrutta utenti stanchi (oltre 50 puntate)
/// quando ci sono pochi altri bidder attivi.
/// Default: true
/// </summary>
public bool UserExhaustionEnabled { get; set; } = true;
// 🎯 CONTROLLO CONVENIENZA PRODOTTO
/// <summary>
@@ -552,14 +440,6 @@ namespace AutoBidder.Utilities
/// Default: -5 (permetti fino al 5% di perdita)
/// </summary>
public double MinSavingsPercentage { get; set; } = -5.0;
/// <summary>
/// Abilita il controllo anti-collisione hardcoded.
/// Se attivo, blocca le puntate quando ci sono 3+ bidder attivi negli ultimi 10 secondi.
/// ATTENZIONE: Questo controllo può far perdere aste competitive!
/// Default: false (DISABILITATO - non blocca mai)
/// </summary>
public bool HardcodedAntiCollisionEnabled { get; set; } = false;
// ── Fascia oraria sospesa ────────────────────────────────────────
// Vedi BiddingHours per i numeri: alle 0 e alle 9 la stessa asta costa quasi il
@@ -594,6 +474,29 @@ namespace AutoBidder.Utilities
/// </summary>
public int LearningBootstrapSecondsPerStart { get; set; } = 120;
// ── Anticipo adattivo ────────────────────────────────────────────
// Vedi Ml/LatencyModel: l'anticipo lo decide la rete di questa sessione, dentro
// i paletti qui sotto. Un anticipo scritto a mano su una singola asta vince sempre.
/// <summary>
/// Lasciar decidere l'anticipo al modello di latenza (margine + coda del ping).
/// Spento, vale <see cref="DefaultBidBeforeDeadlineMs"/> per tutte le aste senza
/// un anticipo proprio.
/// </summary>
public bool AdaptiveLeadEnabled { get; set; } = true;
/// <summary>
/// Anticipo minimo che il modello può scegliere. Sotto i 300 ms un picco di rete
/// fa arrivare la puntata a giochi chiusi: il ping tocca 444 ms nel p99,9.
/// </summary>
public int LeadMinMs { get; set; } = 300;
/// <summary>
/// Anticipo massimo che il modello può scegliere. Oltre 1500 ms si regala
/// più di un secondo agli avversari a ogni ciclo, e il costo per vincere sale.
/// </summary>
public int LeadMaxMs { get; set; } = 1500;
// ── Rimozione automatica delle aste concluse ─────────────────────
// Vedi FinishedAuctionCleanup: si toglie di serie, si trattiene cio' su cui c'e'
// stato un esborso.
@@ -650,142 +553,18 @@ namespace AutoBidder.Utilities
// RILEVAMENTO COMPETIZIONE E HEAT METRIC
// 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
/// <summary>
/// Abilita rilevamento competizione e heat metric.
/// Conta bidder attivi e collisioni per determinare il "calore" dell'asta.
/// Default: true
/// </summary>
public bool CompetitionDetectionEnabled { get; set; } = true;
/// <summary>
/// Finestra temporale in secondi per contare bidder attivi.
/// Default: 30 (ultimi 30 secondi)
/// </summary>
public int CompetitionWindowSeconds { get; set; } = 30;
/// <summary>
/// Numero minimo di bidder attivi per considerare l'asta "affollata".
/// Se >= a questa soglia, applica logica di evitamento.
/// Default: 3
/// </summary>
public int CompetitionThreshold { get; set; } = 3;
/// <summary>
/// Abilita auto-pausa per aste troppo competitive.
/// Default: false (solo warning, non pausa automatica)
/// </summary>
public bool AutoPauseHotAuctions { get; set; } = false;
/// <summary>
/// Soglia heat metric per auto-pausa (0-100).
/// Default: 80 (pausa se heat >= 80%)
/// </summary>
public int HeatThresholdForPause { get; set; } = 80;
// ???????????????????????????????????????????????????????????????
// SOFT RETREAT E COLLISION MANAGEMENT
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Abilita soft retreat automatico dopo N collisioni consecutive.
/// Default: true
/// </summary>
public bool SoftRetreatEnabled { get; set; } = true;
/// <summary>
/// Numero di collisioni consecutive per attivare soft retreat.
/// Default: 3
/// </summary>
public int SoftRetreatAfterCollisions { get; set; } = 3;
/// <summary>
/// Durata del ritiro, in secondi.
///
/// <para>Ridotta da 30 a 12: il timer delle aste osservate si azzera ogni 8-12
/// secondi, quindi trenta secondi di pausa sono tre cicli interi — nella pratica
/// l'asta è persa. Dodici secondi bastano a spezzare una serie di collisioni
/// senza consegnare la partita.</para>
/// </summary>
public int SoftRetreatDurationSeconds { get; set; } = 12;
/// <summary>
/// Blocca la puntata se il prezzo sale più in fretta di tanti euro al secondo.
/// 0 = controllo spento (predefinito).
///
/// <para>Prima era una costante nel codice fissata a 0,10 €/s, cioè dieci puntate
/// al secondo: su oltre 40.000 valutazioni riprese dai dossier non è mai scattata,
/// e la velocità massima mai osservata è 0,016 €/s. Se lo si vuole usare davvero,
/// un valore sensato sta intorno a 0,01-0,02 €/s.</para>
/// </summary>
public double PriceVelocityBlockPerSecond { get; set; } = 0;
// ???????????????????????????????????????????????????????????????
// PROBABILISTIC BIDDING
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Abilita policy di puntata probabilistica.
/// Decide se puntare con probabilità p basata su competizione e ROI.
/// Default: false (richiede tuning)
/// </summary>
public bool ProbabilisticBiddingEnabled { get; set; } = false;
/// <summary>
/// Probabilità base di puntata (0.0 - 1.0).
/// Default: 0.8 (80%)
/// </summary>
public double BaseBidProbability { get; set; } = 0.8;
/// <summary>
/// Fattore di riduzione probabilità per ogni bidder attivo extra.
/// Default: 0.1 (riduce del 10% per ogni bidder oltre la soglia)
/// </summary>
public double ProbabilityReductionPerBidder { get; set; } = 0.1;
// ???????????????????????????????????????????????????????????????
// OPPONENT PROFILING
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Abilita profiling degli avversari.
/// Identifica utenti aggressivi e applica regole specifiche.
/// Default: true
/// </summary>
public bool OpponentProfilingEnabled { get; set; } = true;
/// <summary>
/// Soglia puntate per considerare un utente "aggressivo".
/// Default: 10 (se un utente ha fatto >= 10 puntate in un'asta)
/// </summary>
public int AggressiveBidderThreshold { get; set; } = 10;
/// <summary>
/// Dimensione finestra scorrevole per analisi bidder aggressivi.
/// Analizza le ultime N puntate invece del conteggio totale.
/// Default: 30 (ultime 30 puntate)
/// </summary>
public int AggressiveBidderWindowSize { get; set; } = 30;
/// <summary>
/// Soglia percentuale per considerare un utente "aggressivo".
/// Se un utente ha più di X% delle puntate nella finestra, è aggressivo.
/// Default: 40 (40% delle puntate)
/// </summary>
public double AggressiveBidderPercentageThreshold { get; set; } = 40.0;
/// <summary>
/// Dimensione finestra per rilevamento situazioni di duello.
/// Default: 20 (ultime 20 puntate)
/// </summary>
public int DuelDetectionWindowSize { get; set; } = 20;
/// <summary>
/// Azione da intraprendere con bidder aggressivi.
/// "Avoid" = evita l'asta, "Compete" = continua normalmente, "Outbid" = punta più aggressivamente
/// Default: "Compete" (cambiato da Avoid per essere meno restrittivo)
/// </summary>
public string AggressiveBidderAction { get; set; } = "Compete";
// ???????????????????????????????????????????????????????????????
// BANKROLL & SAFETY MANAGER
// ???????????????????????????????????????????????????????????????
@@ -840,19 +619,6 @@ namespace AutoBidder.Utilities
// ???????????????????????????????????????????????????????????????
// LOGGING AVANZATO
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Abilita logging avanzato con metriche dettagliate.
/// Include: collisioni, timer scaduto, latenza, heat metric.
/// Default: true
/// </summary>
public bool AdvancedLoggingEnabled { get; set; } = true;
/// <summary>
/// Salva metriche per ogni puntata nel database.
/// Default: true
/// </summary>
public bool SaveBidMetricsToDatabase { get; set; } = true;
// ═══════════════════════════════════════════════════════════════════
// INTERFACCIA
+225
View File
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace AutoBidder.Utilities
{
/// <summary>
/// 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>
///
/// <para><see cref="Measure"/> pesa ogni voce prima: cancellare è irreversibile, e
/// dire quanto sparisce è il minimo per una scelta informata.</para>
/// </summary>
public static class StatsWipe
{
public sealed class Options
{
public bool History { get; set; } = true;
public bool ProductStats { get; set; } = true;
public bool BidLeadMeasures { get; set; } = true;
public bool Exports { get; set; } = true;
public bool LegacyArchives { get; set; } = true;
public bool Learning { get; set; }
public bool Nothing => !(History || ProductStats || BidLeadMeasures || Exports || LegacyArchives || Learning);
}
public readonly record struct Sizes(
int HistoryAuctions, long HistoryBytes,
long ProductsBytes,
int BidLeadSamples, long BidLeadBytes,
int ExportFiles, long ExportBytes,
int LegacyFiles, long LegacyBytes,
long LearningBytes);
public sealed class Report
{
public string? BackupPath { get; init; }
public int FilesDeleted { get; set; }
public long BytesFreed { get; set; }
public List<string> Steps { get; } = new();
public List<string> Errors { get; } = new();
public string Summary =>
string.Join("; ", Steps) +
(Errors.Count > 0 ? $". Problemi: {string.Join("; ", Errors)}" : "");
}
private static string LearningFolder => Path.Combine(AppPaths.StatsFolder, "Apprendimento");
// ── Misura ───────────────────────────────────────────────────────
public static Sizes Measure()
{
int aste = 0, misure = 0;
try { aste = CompletedAuctionsStore.LoadAll().Count; } catch { }
try { misure = BidLeadStats.All().Count; } catch { }
var export = ListFiles(AppPaths.ExportFolder, "*");
var legacy = LegacyFiles();
var learning = ListFiles(LearningFolder, "*");
return new Sizes(
aste, SizeOf(CompletedAuctionsStore.StorePath),
SizeOf(AppPaths.ProductsFile),
misure, SizeOf(AppPaths.BidLeadStatsFile),
export.Count, export.Sum(SizeOf),
legacy.Count, legacy.Sum(SizeOf),
learning.Sum(SizeOf));
}
// ── Azzeramento ──────────────────────────────────────────────────
public static Report Run(Options o)
{
string? backup = null;
if (o.History)
{
// La copia prima di tutto: se fallisce, non si cancella niente.
backup = CompletedAuctionsStore.BackupNow();
}
var report = new Report { BackupPath = backup };
if (o.History)
{
Try(report, "storico", () =>
{
report.BytesFreed += SizeOf(CompletedAuctionsStore.StorePath);
CompletedAuctionsStore.Clear();
report.Steps.Add($"storico svuotato (copia in {backup})");
});
}
if (o.ProductStats)
{
Try(report, "statistiche prodotto", () =>
{
ProductStatsStore.ClearStatistics();
report.Steps.Add("statistiche per prodotto azzerate (opzioni conservate)");
});
}
if (o.BidLeadMeasures)
{
Try(report, "misure anticipo", () =>
{
BidLeadStats.Clear();
report.Steps.Add("misure dell'anticipo azzerate");
});
}
if (o.Exports)
{
var (n, bytes) = ClearExports();
report.FilesDeleted += n;
report.BytesFreed += bytes;
report.Steps.Add($"esportazioni: {n} file tolti");
}
if (o.LegacyArchives)
{
var (n, bytes) = DeleteAll(report, "archivi mensili", LegacyFiles());
report.FilesDeleted += n;
report.BytesFreed += bytes;
report.Steps.Add($"archivi mensili: {n} file tolti");
}
if (o.Learning)
{
Try(report, "apprendimento", () =>
{
Ml.LearningService.Forget();
Ml.LatencyModel.Forget();
});
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");
}
return report;
}
/// <summary>Svuota la sola cartella delle esportazioni. Restituisce file tolti e byte liberati.</summary>
public static (int Files, long Bytes) ClearExports()
{
var report = new Report();
return DeleteAll(report, "esportazioni", ListFiles(AppPaths.ExportFolder, "*"));
}
// ── Attrezzi ─────────────────────────────────────────────────────
/// <summary>Gli archivi mensili delle versioni precedenti: aste-AAAA-MM.jsonl e .json.</summary>
private static List<string> LegacyFiles()
{
var folder = AppPaths.StatsFolder;
if (!Directory.Exists(folder)) return new List<string>();
return Directory.EnumerateFiles(folder, "aste-*.json*")
.Where(f =>
{
var name = Path.GetFileName(f);
return name.Length >= 12 && char.IsDigit(name[5]) &&
(name.EndsWith(".jsonl", StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(".json", StringComparison.OrdinalIgnoreCase));
})
.ToList();
}
private static List<string> ListFiles(string folder, string pattern)
{
try
{
return Directory.Exists(folder)
? Directory.EnumerateFiles(folder, pattern).ToList()
: new List<string>();
}
catch { return new List<string>(); }
}
private static long SizeOf(string path)
{
try { return File.Exists(path) ? new FileInfo(path).Length : 0; }
catch { return 0; }
}
private static (int Files, long Bytes) DeleteAll(Report report, string label, IEnumerable<string> files)
{
var n = 0;
long bytes = 0;
foreach (var f in files)
{
try
{
var size = SizeOf(f);
File.Delete(f);
n++;
bytes += size;
}
catch (Exception ex)
{
report.Errors.Add($"{label}: {Path.GetFileName(f)} non cancellato ({ex.Message})");
}
}
return (n, bytes);
}
private static void Try(Report report, string label, Action action)
{
try { action(); }
catch (Exception ex) { report.Errors.Add($"{label}: {ex.Message}"); }
}
}
}
+1
View File
@@ -87,6 +87,7 @@ namespace AutoBidder.ViewModels
set
{
_auctionInfo.BidBeforeDeadlineMs = value;
_auctionInfo.BidLeadIsManual = true;
OnPropertyChanged(nameof(BidBeforeDeadlineMs));
}
}