Due database, interruttore dell'apprendimento, barre a icone; via kill-switch, esportazioni e voci deprecate

Tutto ciò che l'applicazione registra sta ora in due file SQLite in
%LocalAppData%\AutoBidder\Database: autobidder.sqlite per le osservazioni e
esercizio.sqlite per i documenti d'esercizio (aste nel monitor, prodotti, promozioni,
modelli appresi con prefisso ml/) e per i registri applicativo e del riscatto puntate.
Il motore comune è SqliteDatabase; i file JSON e la cartella Apprendimento delle
versioni precedenti vengono importati la prima volta e lasciati dove sono. La cartella
si cambia dalle Impostazioni: al salvataggio si chiede se spostare i file, e si riavvia.
Le cartelle Dati/Statistiche/Registri, i registri su file, le misure dell'anticipo e
le impostazioni morte (LogBids, AutoApplyProductDefaults, NewAuctionLimitsPriority…)
non esistono più.

Il kill-switch se ne va: al suo posto un solo interruttore in barra, accanto ad
Avvia/Osserva/Ferma, che spegne l'apprendimento. Spento, il motore punta solo entro i
limiti dell'utente (prezzo, puntate, budget, fascia oraria, rischio) con l'anticipo
fisso: niente valore atteso, regime, duello, bandit, anticipo adattivo. Le aste vengono
comunque registrate e studiate.

Tre difetti visti osservando il motore dal vivo per un'ora, in Osserva:
- la copertura «Compralo Ora» con V lasciato al listino azzerava la perdita coperta e
  il motore diceva BID a ogni scadenza con P dell'1%: ora vale solo per i prodotti con
  «Valore reale €» scritto, altrimenti la puntata si conta persa e P decide;
- la chiusura di un'asta arrivava due volte (fine, poi rimozione dal monitor) e di
  nuovo al riavvio: modello, profilo, bandit e statistiche per prodotto contavano ogni
  asta due volte. Il monitor la comunica una volta sola, l'apprendimento salta le aste
  già apprese, e le schede prodotto vengono ricostruite una volta dallo storico;
- la pagina delle ricompense veniva scambiata per la pagina di accesso perché porta un
  collegamento a /login.php: prima si guarda se è la pagina delle ricompense.

Interfaccia: la scheda Esporta e tutto il suo codice sono tolti; le esportazioni
chiedono sempre dove salvare. Prodotti, Storico e Apprendimento hanno barre a sole
icone con tooltip che dicono esattamente cosa succede, colorate come il monitor, con
i nuovi pulsanti di pulizia (spegni tutte le stelline, togli i non seguiti, pulizia
completa; seleziona tutte / elimina le selezionate / svuota lo storico). Impostazioni:
sezione Database, Manutenzione con «Elimina tutti i dati (tranne la login)» e
«Impostazioni di fabbrica», esportazione dei registri in testo. Modifiche.txt, il
prompt ormai realizzato, esce dal repository.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 13:58:08 +02:00
co-authored by Claude Fable 5.1
parent 66a1d23d87
commit 3dcceb87ef
63 changed files with 2177 additions and 3600 deletions
+15 -17
View File
@@ -23,32 +23,30 @@ namespace AutoBidder
// Le impostazioni stanno nella radice fissa, quindi si possono leggere anche // Le impostazioni stanno nella radice fissa, quindi si possono leggere anche
// prima di sapere dove finiranno gli altri dati. // prima di sapere dove finiranno gli altri dati.
var settings = SettingsManager.Load(); var settings = SettingsManager.Load();
AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder, settings.DatabasePath); AppPaths.Configure(settings.DatabaseFolder);
AppPaths.EnsureFolders(); AppPaths.EnsureFolders();
// Le versioni precedenti scrivevano parte dei dati in Roaming e parte in Local, // I due database si aprono per primi: da qui in poi ogni archivio e ogni
// senza sottocartelle: senza questo passaggio storico e aste sparirebbero. // registro ci scrive. Quello d'esercizio per primo, perché il registro
AppPaths.MigrateLegacyFiles(); // applicativo sta lì dentro.
Data.SqliteDatabase.OnLog += m => TextLogService.App("INFO", m);
// I registri su file partono subito: quello che succede durante l'avvio è
// proprio ciò che non si riesce a leggere a video.
TextLogService.SessionStarted(AppInfo.Version);
TextLogService.PurgeOldLogs();
TextLogService.App("INFO", $"[PERCORSI] configurazione in {AppPaths.ConfigRoot}, dati in {AppPaths.DataFolder}");
// Il database si apre per primo: da qui in poi ogni archivio ci scrive.
Data.AuctionDatabase.OnLog += m => TextLogService.App("INFO", m);
try try
{ {
var op = Data.OperationalDatabase.Instance;
TextLogService.SessionStarted(AppInfo.Version);
TextLogService.App("INFO", $"[DATABASE] Esercizio: {op.Path} ({op.SizeBytes / 1024:N0} KB)");
var db = Data.AuctionDatabase.Instance; var db = Data.AuctionDatabase.Instance;
TextLogService.App("INFO", $"[DATABASE] {db.Path} (SQLite {Data.AuctionDatabase.LibraryVersion}, {db.SizeBytes / 1024:N0} KB)"); TextLogService.App("INFO", $"[DATABASE] Osservazioni: {db.Path} (SQLite {Data.SqliteDatabase.LibraryVersion}, {db.SizeBytes / 1024:N0} KB)");
db.Enqueue("INSERT INTO sessions(started_at, app_version) VALUES(?1, ?2)", Data.AuctionDatabase.Now(), AppInfo.Version); db.Enqueue("INSERT INTO sessions(started_at, app_version) VALUES(?1, ?2)", Data.SqliteDatabase.Now(), AppInfo.Version);
} }
catch (Exception ex) catch (Exception ex)
{ {
TextLogService.App("ERROR", $"[DATABASE] Apertura non riuscita: {ex.Message}"); TextLogService.App("ERROR", $"[DATABASE] Apertura non riuscita: {ex.Message}");
} }
TextLogService.PurgeOldLogs();
// Le righe di registro delle aste confluiscono nel database. // Le righe di registro delle aste confluiscono nel database.
Data.AuctionRecorder.CaptureAuctionLogs(); Data.AuctionRecorder.CaptureAuctionLogs();
@@ -85,13 +83,13 @@ namespace AutoBidder
// Quello che è ancora in coda va sul disco adesso: un registro che perde // Quello che è ancora in coda va sul disco adesso: un registro che perde
// proprio le ultime righe è inutile esattamente quando serve di più. // proprio le ultime righe è inutile esattamente quando serve di più.
TextLogService.SessionEnded(); TextLogService.SessionEnded();
FileLogWriter.DisposeAll();
try try
{ {
var db = Data.AuctionDatabase.Instance; var db = Data.AuctionDatabase.Instance;
db.Enqueue("UPDATE sessions SET ended_at = ?1 WHERE session_id = (SELECT MAX(session_id) FROM sessions)", Data.AuctionDatabase.Now()); db.Enqueue("UPDATE sessions SET ended_at = ?1 WHERE session_id = (SELECT MAX(session_id) FROM sessions)", Data.SqliteDatabase.Now());
Data.AuctionDatabase.CloseInstance(); Data.AuctionDatabase.CloseInstance();
Data.OperationalDatabase.CloseInstance();
} }
catch { /* all'uscita non c'è più nessuno a cui dirlo */ } catch { /* all'uscita non c'è più nessuno a cui dirlo */ }
+4 -20
View File
@@ -66,17 +66,10 @@
<TextBlock Style="{StaticResource Glyph}" Text="&#xE71A;"/> <TextBlock Style="{StaticResource Glyph}" Text="&#xE71A;"/>
</Button> </Button>
<ToggleButton x:Name="KillSwitchToggle" Margin="6,0,0,0" Padding="7,3" <ToggleButton x:Name="LearningToggle" Style="{StaticResource IconToggle}"
BorderThickness="1" Cursor="Hand" ToolTip="Apprendimento. ACCESO: il motore decide con il valore atteso appreso, il regime di concorrenza, il riconoscimento del duello e l'anticipo adattivo dalla rete. SPENTO: si punta solo entro i tuoi limiti — prezzo minimo e massimo, puntate, budget, fascia oraria — con l'anticipo fisso delle impostazioni, come un cecchino senza cervello. In entrambi i casi le aste vengono registrate e studiate."
Background="{DynamicResource Brush.SurfaceAlt}" Checked="LearningToggle_Changed" Unchecked="LearningToggle_Changed">
BorderBrush="{DynamicResource Brush.Border}" <TextBlock Style="{StaticResource Glyph}" Text="&#xE99A;"/>
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Kill-switch: acceso, nessuna puntata dal vivo parte, qualunque cosa decidano le strategie. Le aste continuano a essere seguite e registrate. È un file KILL_SWITCH in %LocalAppData%\AutoBidder: resta anche dopo un riavvio."
Checked="KillSwitchToggle_Changed" Unchecked="KillSwitchToggle_Changed">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE7BA;" Margin="0,0,5,0"/>
<TextBlock Text="Kill-switch" VerticalAlignment="Center" FontSize="12"/>
</StackPanel>
</ToggleButton> </ToggleButton>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="8,4"/> <Border Width="1" Background="{DynamicResource Brush.Border}" Margin="8,4"/>
@@ -188,10 +181,6 @@
ToolTip="Rimuovi tutte le aste" Click="RemoveAllButton_Click"> ToolTip="Rimuovi tutte le aste" Click="RemoveAllButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE711;"/> <TextBlock Style="{StaticResource Glyph}" Text="&#xE711;"/>
</Button> </Button>
<Button x:Name="ExportButton" Style="{StaticResource IconButton}"
ToolTip="Esporta le aste monitorate" Click="ExportButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74E;"/>
</Button>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -491,11 +480,6 @@
Click="CopyAuctionUrlButton_Click"> Click="CopyAuctionUrlButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8C8;" FontSize="13"/> <TextBlock Style="{StaticResource Glyph}" Text="&#xE8C8;" FontSize="13"/>
</Button> </Button>
<Button x:Name="ExportAuctionButton" Style="{StaticResource IconButton}"
ToolTip="Esporta i dati di questa asta"
Click="ExportAuctionButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74E;" FontSize="13"/>
</Button>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
+7 -33
View File
@@ -67,18 +67,18 @@ namespace AutoBidder.Controls
RaiseEvent(new RoutedEventArgs(StopClickedEvent, this)); RaiseEvent(new RoutedEventArgs(StopClickedEvent, this));
} }
private void KillSwitchToggle_Changed(object sender, RoutedEventArgs e) private void LearningToggle_Changed(object sender, RoutedEventArgs e)
{ {
RaiseEvent(new RoutedEventArgs(KillSwitchChangedEvent, this)); RaiseEvent(new RoutedEventArgs(LearningToggledEvent, this));
} }
public static readonly RoutedEvent KillSwitchChangedEvent = EventManager.RegisterRoutedEvent( public static readonly RoutedEvent LearningToggledEvent = EventManager.RegisterRoutedEvent(
"KillSwitchChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); "LearningToggled", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public event RoutedEventHandler KillSwitchChanged public event RoutedEventHandler LearningToggled
{ {
add { AddHandler(KillSwitchChangedEvent, value); } add { AddHandler(LearningToggledEvent, value); }
remove { RemoveHandler(KillSwitchChangedEvent, value); } remove { RemoveHandler(LearningToggledEvent, value); }
} }
private void AddUrlButton_Click(object sender, RoutedEventArgs e) private void AddUrlButton_Click(object sender, RoutedEventArgs e)
@@ -96,11 +96,6 @@ namespace AutoBidder.Controls
RaiseEvent(new RoutedEventArgs(RemoveAllClickedEvent, this)); RaiseEvent(new RoutedEventArgs(RemoveAllClickedEvent, this));
} }
private void ExportButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ExportClickedEvent, this));
}
private void RemoveFinishedButton_Click(object sender, RoutedEventArgs e) private void RemoveFinishedButton_Click(object sender, RoutedEventArgs e)
{ {
RaiseEvent(new RoutedEventArgs(RemoveFinishedClickedEvent, this)); RaiseEvent(new RoutedEventArgs(RemoveFinishedClickedEvent, this));
@@ -337,10 +332,6 @@ namespace AutoBidder.Controls
RaiseEvent(new RoutedEventArgs(OpenAuctionExternalClickedEvent, this)); RaiseEvent(new RoutedEventArgs(OpenAuctionExternalClickedEvent, this));
} }
private void ExportAuctionButton_Click(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ExportAuctionClickedEvent, this));
}
private void RefreshProductInfoButton_Click(object sender, RoutedEventArgs e) private void RefreshProductInfoButton_Click(object sender, RoutedEventArgs e)
{ {
@@ -410,9 +401,6 @@ namespace AutoBidder.Controls
public static readonly RoutedEvent RemoveAllClickedEvent = EventManager.RegisterRoutedEvent( public static readonly RoutedEvent RemoveAllClickedEvent = EventManager.RegisterRoutedEvent(
"RemoveAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); "RemoveAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent ExportClickedEvent = EventManager.RegisterRoutedEvent(
"ExportClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent AuctionSelectionChangedEvent = EventManager.RegisterRoutedEvent( public static readonly RoutedEvent AuctionSelectionChangedEvent = EventManager.RegisterRoutedEvent(
"AuctionSelectionChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); "AuctionSelectionChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
@@ -449,8 +437,6 @@ namespace AutoBidder.Controls
public static readonly RoutedEvent OpenAuctionExternalClickedEvent = EventManager.RegisterRoutedEvent( public static readonly RoutedEvent OpenAuctionExternalClickedEvent = EventManager.RegisterRoutedEvent(
"OpenAuctionExternalClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); "OpenAuctionExternalClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent ExportAuctionClickedEvent = EventManager.RegisterRoutedEvent(
"ExportAuctionClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
public static readonly RoutedEvent RefreshProductInfoClickedEvent = EventManager.RegisterRoutedEvent( public static readonly RoutedEvent RefreshProductInfoClickedEvent = EventManager.RegisterRoutedEvent(
"RefreshProductInfoClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl)); "RefreshProductInfoClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(AuctionMonitorControl));
@@ -507,12 +493,6 @@ namespace AutoBidder.Controls
remove { RemoveHandler(RemoveFinishedClickedEvent, value); } remove { RemoveHandler(RemoveFinishedClickedEvent, value); }
} }
public event RoutedEventHandler ExportClicked
{
add { AddHandler(ExportClickedEvent, value); }
remove { RemoveHandler(ExportClickedEvent, value); }
}
public event RoutedEventHandler AuctionSelectionChanged public event RoutedEventHandler AuctionSelectionChanged
{ {
add { AddHandler(AuctionSelectionChangedEvent, value); } add { AddHandler(AuctionSelectionChangedEvent, value); }
@@ -597,12 +577,6 @@ namespace AutoBidder.Controls
remove { RemoveHandler(OpenAuctionExternalClickedEvent, value); } remove { RemoveHandler(OpenAuctionExternalClickedEvent, value); }
} }
public event RoutedEventHandler ExportAuctionClicked
{
add { AddHandler(ExportAuctionClickedEvent, value); }
remove { RemoveHandler(ExportAuctionClickedEvent, value); }
}
public event RoutedEventHandler RefreshProductInfoClicked public event RoutedEventHandler RefreshProductInfoClicked
{ {
add { AddHandler(RefreshProductInfoClickedEvent, value); } add { AddHandler(RefreshProductInfoClickedEvent, value); }
-248
View File
@@ -1,248 +0,0 @@
<UserControl x:Class="AutoBidder.Controls.ExportControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ctl="clr-namespace:AutoBidder.Controls"
mc:Ignorable="d"
d:DesignHeight="800" d:DesignWidth="1200"
Background="{DynamicResource Brush.Bg}">
<UserControl.Resources>
<Style x:Key="Glyph" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
<Style x:Key="FilterLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Margin" Value="0,8"/>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Background="{DynamicResource Brush.Surface}" Padding="10,7"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="PreviewButton" Style="{StaticResource IconButton}"
ToolTip="Ricalcola quante aste corrispondono ai filtri"
Click="PreviewButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72C;"/>
</Button>
<TextBlock Text="Esporta" FontWeight="SemiBold" Margin="8,0,0,0"
FontSize="{StaticResource Font.Size.Lg}"
Foreground="{DynamicResource Brush.Text}" VerticalAlignment="Center"/>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="10,4"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Border Style="{StaticResource Pill}" ToolTip="Aste che corrispondono ai filtri">
<TextBlock x:Name="MatchCountText" Text="—"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.Text}"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Di queste, quelle seguite dall'inizio alla fine">
<TextBlock x:Name="CompleteCountText" Text="—"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Border>
</StackPanel>
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="ExportButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Content="Esporta in un file" Margin="0,0,8,0"
Click="ExportButton_Click"/>
<Button x:Name="OpenFolderButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Apri la cartella" Margin="0,0,8,0"
Click="OpenFolderButton_Click"/>
<Button x:Name="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;"/>
</Button>
</StackPanel>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel Margin="20,16" MaxWidth="1000" HorizontalAlignment="Left">
<!-- ═══ Cosa esporta ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Cosa finisce nel file"
Margin="0,0,0,12"
Help="Un unico file JSON con le aste scelte: esito, prezzo finale, valore del prodotto, puntate di ogni utente, ping e latenza, andamento del prezzo e — dove esiste il dossier — ogni singolo evento registrato mentre l'asta era in corso. Il file si spiega da sé: contiene la legenda dei campi, così resta leggibile anche fra sei mesi."/>
</StackPanel>
</Border>
<!-- ═══ Filtri ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<TextBlock Text="Filtri" Style="{StaticResource BlockTitle}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="230"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Periodo (chiusura)"
Style="{StaticResource FilterLabel}"/>
<StackPanel Grid.Row="0" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
<DatePicker x:Name="FromDatePicker" Width="150"
ToolTip="Dalla data di chiusura (vuoto = dall'inizio)"/>
<TextBlock Text="→" Margin="8,0" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.TextMuted}"/>
<DatePicker x:Name="ToDatePicker" Width="150"
ToolTip="Fino alla data di chiusura (vuoto = fino a oggi)"/>
<Button Content="Ultimi 7 giorni" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Margin="12,0,0,0" Click="Last7Days_Click"/>
<Button Content="Ultimi 30" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Margin="6,0,0,0" Click="Last30Days_Click"/>
</StackPanel>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Prodotto contiene"
Style="{StaticResource FilterLabel}"/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="NameFilterTextBox"
Width="320" HorizontalAlignment="Left" Margin="0,4"
ToolTip="Parte del nome del prodotto, senza distinzione fra maiuscole"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Prezzo finale (€)"
Style="{StaticResource FilterLabel}"/>
<StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
<TextBox x:Name="MinPriceTextBox" Width="100"
ToolTip="Prezzo finale minimo (vuoto = nessun limite)"/>
<TextBlock Text="→" Margin="8,0" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.TextMuted}"/>
<TextBox x:Name="MaxPriceTextBox" Width="100"
ToolTip="Prezzo finale massimo (vuoto = nessun limite)"/>
</StackPanel>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Tetto di aste"
Style="{StaticResource FilterLabel}"/>
<StackPanel Grid.Row="3" Grid.Column="1" Orientation="Horizontal" Margin="0,4">
<TextBox x:Name="MaxAuctionsTextBox" Width="100" Text="0"
ToolTip="0 = tutte. Con gli eventi inclusi, cento aste sono già diversi megabyte."/>
<TextBlock Text="0 = tutte quelle che corrispondono" VerticalAlignment="Center"
Margin="10,0,0,0" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- ═══ Quali aste ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<TextBlock Text="Quali aste" Style="{StaticResource BlockTitle}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<CheckBox x:Name="IncludeWonCheckBox" IsChecked="True" Margin="0,4"
Content="Vinte" Checked="Filter_Changed" Unchecked="Filter_Changed"/>
<CheckBox x:Name="IncludeLostCheckBox" IsChecked="True" Margin="0,4"
Content="Perse (ci avevo puntato)" Checked="Filter_Changed" Unchecked="Filter_Changed"/>
<CheckBox x:Name="IncludeClosedCheckBox" IsChecked="True" Margin="0,4"
Content="Solo osservate (mai puntato)"
ToolTip="Non le ho giocate, ma dicono a quanto chiude il mercato"
Checked="Filter_Changed" Unchecked="Filter_Changed"/>
</StackPanel>
<StackPanel Grid.Column="1">
<CheckBox x:Name="OnlyCompleteCheckBox" Margin="0,4"
Content="Solo quelle seguite dall'inizio alla fine"
ToolTip="Sono le più preziose: su un'asta presa a metà non si sa quante puntate erano già state spese"
Checked="Filter_Changed" Unchecked="Filter_Changed"/>
<CheckBox x:Name="OnlyWithMyBidsCheckBox" Margin="0,4"
Content="Solo quelle su cui ho puntato"
Checked="Filter_Changed" Unchecked="Filter_Changed"/>
<CheckBox x:Name="OnlyWithDossierCheckBox" Margin="0,4"
Content="Solo quelle con il dossier degli eventi"
Checked="Filter_Changed" Unchecked="Filter_Changed"/>
</StackPanel>
</Grid>
<Border Background="{DynamicResource Brush.SurfaceAlt}" CornerRadius="8"
Padding="12,10" Margin="0,12,0,0">
<StackPanel>
<CheckBox x:Name="IncludeEventsCheckBox" IsChecked="True"
Content="Includi tutti gli eventi registrati (dossier completo)"
ToolTip="Senza, il file contiene solo i riepiloghi: molto più leggero, molto meno utile.&#10;&#10;Gli eventi sono ogni interrogazione a Bidoo, ogni puntata di ogni utente con l'ora al millisecondo e ogni mia puntata con anticipo pianificato ed effettivo. È il materiale con cui si tara l'anticipo; è anche ciò che fa pesare il file."/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- ═══ Esito ═══ -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<TextBlock Text="Ultima esportazione" Style="{StaticResource BlockTitle}"/>
<Border Background="{DynamicResource Brush.SurfaceAlt}" CornerRadius="8" Padding="12,10">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8A5;" FontSize="13"
Foreground="{DynamicResource Brush.Accent}" Margin="0,0,8,0"/>
<TextBlock x:Name="ResultText" VerticalAlignment="Center" TextWrapping="Wrap"
Text="Nessuna esportazione in questa sessione."
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</StackPanel>
</Border>
<Button x:Name="OpenLastFileButton" HorizontalAlignment="Left"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Apri il file" Margin="0,10,0,0"
Visibility="Collapsed" Click="OpenLastFileButton_Click"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
-204
View File
@@ -1,204 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using AutoBidder.Utilities;
namespace AutoBidder.Controls
{
/// <summary>
/// Scheda Esporta: sceglie quali aste finiscono in un unico file e lo scrive.
///
/// <para>Come gli altri pannelli non conosce gli archivi: costruisce un
/// <see cref="AuctionExportFilter"/> e lascia il lavoro a
/// <see cref="AuctionExporter"/>. Qui dentro c'è solo la traduzione fra i campi
/// dell'interfaccia e i criteri — e il conteggio in tempo reale, che serve a non
/// scoprire dopo un minuto di scrittura che il filtro non selezionava nulla.</para>
/// </summary>
public partial class ExportControl : UserControl
{
private string? _lastExportPath;
public ExportControl()
{
InitializeComponent();
Loaded += (_, _) => RefreshPreview();
}
/// <summary>I criteri come sono impostati adesso.</summary>
public AuctionExportFilter BuildFilter() => new()
{
From = FromDatePicker.SelectedDate,
To = ToDatePicker.SelectedDate,
NameContains = string.IsNullOrWhiteSpace(NameFilterTextBox.Text) ? null : NameFilterTextBox.Text.Trim(),
IncludeWon = IncludeWonCheckBox.IsChecked == true,
IncludeLost = IncludeLostCheckBox.IsChecked == true,
IncludeClosed = IncludeClosedCheckBox.IsChecked == true,
OnlyComplete = OnlyCompleteCheckBox.IsChecked == true,
OnlyWithMyBids = OnlyWithMyBidsCheckBox.IsChecked == true,
OnlyWithDossier = OnlyWithDossierCheckBox.IsChecked == true,
MinFinalPrice = ParseNumber(MinPriceTextBox.Text),
MaxFinalPrice = ParseNumber(MaxPriceTextBox.Text),
MaxAuctions = (int)(ParseNumber(MaxAuctionsTextBox.Text) ?? 0),
IncludeEvents = IncludeEventsCheckBox.IsChecked == true
};
/// <summary>
/// Ricalcola quante aste corrispondono. Si fa a ogni modifica dei filtri: sapere
/// prima che il risultato è vuoto — o che sono duemila — evita di scoprirlo a file
/// scritto.
/// </summary>
public void RefreshPreview()
{
try
{
var selected = AuctionExporter.Select(BuildFilter());
MatchCountText.Text = selected.Count == 1 ? "1 asta" : $"{selected.Count} aste";
var complete = selected.Count(r => r.IsComplete);
CompleteCountText.Text = $"{complete} complete";
ExportButton.IsEnabled = selected.Count > 0;
}
catch (Exception ex)
{
MatchCountText.Text = "—";
CompleteCountText.Text = ex.Message;
}
}
/// <summary>Mostra l'esito dell'esportazione appena fatta.</summary>
public void ShowResult(AuctionExportResult result)
{
if (result.Success)
{
_lastExportPath = result.Path;
var size = result.Bytes >= 1024 * 1024
? $"{result.Bytes / 1024.0 / 1024.0:F1} MB"
: $"{Math.Max(1, result.Bytes / 1024)} kB";
ResultText.Text = $"{result.AuctionCount} aste ({result.WithEvents} con eventi), {size} — {result.Path}";
ResultText.SetResourceReference(ForegroundProperty, "Brush.Text");
OpenLastFileButton.Visibility = Visibility.Visible;
}
else
{
ResultText.Text = result.Message;
ResultText.SetResourceReference(ForegroundProperty, "Brush.Warning");
OpenLastFileButton.Visibility = Visibility.Collapsed;
}
}
/// <summary>Percorso dell'ultimo file scritto, per il pulsante "Apri il file".</summary>
public string? LastExportPath => _lastExportPath;
// ── Eventi dell'interfaccia ──────────────────────────────────────
private void PreviewButton_Click(object sender, RoutedEventArgs e) => RefreshPreview();
private void Filter_Changed(object sender, RoutedEventArgs e) => RefreshPreview();
private void Last7Days_Click(object sender, RoutedEventArgs e) => SetPeriod(7);
private void Last30Days_Click(object sender, RoutedEventArgs e) => SetPeriod(30);
private void SetPeriod(int days)
{
FromDatePicker.SelectedDate = DateTime.Today.AddDays(-days);
ToDatePicker.SelectedDate = DateTime.Today;
RefreshPreview();
}
private void ResetFiltersButton_Click(object sender, RoutedEventArgs e)
{
FromDatePicker.SelectedDate = null;
ToDatePicker.SelectedDate = null;
NameFilterTextBox.Text = "";
MinPriceTextBox.Text = "";
MaxPriceTextBox.Text = "";
MaxAuctionsTextBox.Text = "0";
IncludeWonCheckBox.IsChecked = true;
IncludeLostCheckBox.IsChecked = true;
IncludeClosedCheckBox.IsChecked = true;
OnlyCompleteCheckBox.IsChecked = false;
OnlyWithMyBidsCheckBox.IsChecked = false;
OnlyWithDossierCheckBox.IsChecked = false;
IncludeEventsCheckBox.IsChecked = true;
RefreshPreview();
}
private void ExportButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ExportRequestedEvent, this));
private void OpenFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenExportFolderClickedEvent, this));
private void ClearExportsButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearExportsClickedEvent, this));
private void OpenLastFileButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenLastFileClickedEvent, this));
/// <summary>
/// Legge un numero accettando sia la virgola sia il punto: chi scrive "12,50" e chi
/// scrive "12.50" intende la stessa cosa, e rifiutarne uno dei due sarebbe pedanteria.
/// </summary>
private static double? ParseNumber(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
var clean = text.Trim().Replace(',', '.');
return double.TryParse(clean, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)
? value
: null;
}
// ── Routed events ────────────────────────────────────────────────
public static readonly RoutedEvent ExportRequestedEvent = EventManager.RegisterRoutedEvent(
"ExportRequested", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ExportControl));
public static readonly RoutedEvent OpenExportFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenExportFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ExportControl));
public static readonly RoutedEvent OpenLastFileClickedEvent = EventManager.RegisterRoutedEvent(
"OpenLastFileClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ExportControl));
public 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); }
remove { RemoveHandler(ExportRequestedEvent, value); }
}
public event RoutedEventHandler OpenExportFolderClicked
{
add { AddHandler(OpenExportFolderClickedEvent, value); }
remove { RemoveHandler(OpenExportFolderClickedEvent, value); }
}
public event RoutedEventHandler OpenLastFileClicked
{
add { AddHandler(OpenLastFileClickedEvent, value); }
remove { RemoveHandler(OpenLastFileClickedEvent, value); }
}
}
}
+22 -25
View File
@@ -71,31 +71,28 @@
</Border> </Border>
</StackPanel> </StackPanel>
<!-- Solo icone, come nel monitor: il tooltip dice esattamente cosa succede. -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="EvaluateButton" Style="{StaticResource SmallRoundedButton}" <Button x:Name="EvaluateButton" Style="{StaticResource IconButton}"
Background="{DynamicResource Brush.Accent}" Foreground="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}" ToolTip="Valuta ora.&#10;Addestra sulle aste più vecchie del database e giudica sulle più recenti, mai viste. Poi la prova prequenziale: ogni asta prima prevista e poi appresa, come fa il motore. Qualche minuto in sottofondo; il rapporto compare nella scheda «Valutazione»."
Content="Valuta ora" Margin="0,0,8,0" Click="EvaluateButton_Click">
ToolTip="Addestra sui dossier più vecchi di questa macchina e giudica sui più recenti, mai visti. Poi la prova prequenziale: ogni asta prima prevista e poi appresa, come fa il motore. Qualche minuto in sottofondo." <TextBlock x:Name="EvaluateGlyph" Style="{StaticResource Glyph}" Text="&#xE9F5;"/>
Click="EvaluateButton_Click"/> </Button>
<Button x:Name="OpenFolderButton" Style="{StaticResource SmallRoundedButton}" <Button x:Name="ExportDecisionsButton" Style="{StaticResource IconButton}"
Background="{DynamicResource Brush.SurfaceAlt}" ToolTip="Esporta le decisioni in CSV.&#10;Tutte le decisioni del motore, dal vivo e in shadow, con esito e motivazione per esteso (reason_detail). Punto e virgola, pronto per un foglio di calcolo. Chiede dove salvare il file."
Foreground="{DynamicResource Brush.Text}" Click="ExportDecisionsButton_Click">
Content="Apri cartella" Margin="0,0,8,0" <TextBlock Style="{StaticResource Glyph}" Text="&#xE9F9;"/>
ToolTip="La cartella con modello, profilo, elenco dei dossier letti e ultima valutazione" </Button>
Click="OpenFolderButton_Click"/>
<Button x:Name="ExportDecisionsButton" Style="{StaticResource SmallRoundedButton}" <Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}" <Button x:Name="RetrainButton" Style="{StaticResource IconButton}"
Content="Esporta decisioni (CSV)" Margin="0,0,8,0"
ToolTip="Tutte le decisioni del motore, dal vivo e in shadow, con esito e motivazione per esteso (reason_detail). CSV con punto e virgola, pronto per un foglio di calcolo."
Click="ExportDecisionsButton_Click"/>
<Button x:Name="RetrainButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Danger}" Foreground="{DynamicResource Brush.Danger}"
Content="Ricomincia da capo" ToolTip="Ricomincia da capo.&#10;Butta modello, profilo, sfidante, bandit e latenza appresa, e ristudia tutte le aste chiuse del database. Non tocca lo storico né i prodotti. Chiede conferma."
ToolTip="Butta modello e profilo e ristudia tutti i dossier. Non tocca i dossier né lo storico." Click="RetrainButton_Click">
Click="RetrainButton_Click"/> <TextBlock Style="{StaticResource Glyph}" Text="&#xE7A6;"/>
</Button>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -144,7 +141,7 @@
</StackPanel> </StackPanel>
<StackPanel Margin="0,0,8,0"> <StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatBootstrap" Style="{StaticResource Stat}" Text="—"/> <TextBlock x:Name="StatBootstrap" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="studio dei dossier"/> <TextBlock Style="{StaticResource StatLabel}" Text="studio delle aste"/>
</StackPanel> </StackPanel>
</UniformGrid> </UniformGrid>
<TextBlock x:Name="StatDecisions" Style="{StaticResource Hint}" Margin="0,10,0,0" Text=""/> <TextBlock x:Name="StatDecisions" Style="{StaticResource Hint}" Margin="0,10,0,0" Text=""/>
@@ -209,7 +206,7 @@
<Border Grid.Row="2" Grid.Column="2" Style="{StaticResource CardBlock}"> <Border Grid.Row="2" Grid.Column="2" Style="{StaticResource CardBlock}">
<StackPanel> <StackPanel>
<ctl:HelpHeader Title="Ultima valutazione" Margin="0,0,0,10" <ctl:HelpHeader Title="Ultima valutazione" Margin="0,0,0,10"
Help="Il rapporto dell'ultima valutazione sui dossier di questa macchina. Le misure che contano: il sollevamento (quanto più spesso le puntate a probabilità alta erano davvero finali, rispetto al caso), la calibrazione per fasce (la probabilità prevista deve somigliare a quella osservata), e cosa il cancello avrebbe fatto sulle tue puntate vere."/> Help="Il rapporto dell'ultima valutazione sulle aste registrate in questo database. Le misure che contano: il sollevamento (quanto più spesso le puntate a probabilità alta erano davvero finali, rispetto al caso), la calibrazione per fasce (la probabilità prevista deve somigliare a quella osservata), e cosa il cancello avrebbe fatto sulle tue puntate vere."/>
<TextBox x:Name="EvaluationBox" IsReadOnly="True" TextWrapping="NoWrap" <TextBox x:Name="EvaluationBox" IsReadOnly="True" TextWrapping="NoWrap"
MinHeight="200" MaxHeight="320" MinHeight="200" MaxHeight="320"
FontFamily="Consolas" FontSize="11.5" FontFamily="Consolas" FontSize="11.5"
+31 -24
View File
@@ -101,8 +101,11 @@ namespace AutoBidder.Controls
PillAuctions.Text = $"{apprese:N0} aste"; PillAuctions.Text = $"{apprese:N0} aste";
PillUpdates.Text = $"{LearningService.ModelUpdates:N0} esempi"; PillUpdates.Text = $"{LearningService.ModelUpdates:N0} esempi";
PillCalibration.Text = $"calibrazione {calib:N2}"; PillCalibration.Text = $"calibrazione {calib:N2}";
PillReady.Text = pronto ? "pronto" : $"in ascolto ({apprese}/{soglia})"; // L'interruttore in barra: spento, il modello studia ma non decide.
PillReady.Foreground = (System.Windows.Media.Brush)FindResource(pronto ? "Brush.Success" : "Brush.Warning"); PillReady.Text = !settings.LearningEnabled ? "SPENTO (interruttore in barra)"
: pronto ? "pronto" : $"in ascolto ({apprese}/{soglia})";
PillReady.Foreground = (System.Windows.Media.Brush)FindResource(
!settings.LearningEnabled ? "Brush.Danger" : pronto ? "Brush.Success" : "Brush.Warning");
StatAuctions.Text = apprese.ToString("N0"); StatAuctions.Text = apprese.ToString("N0");
StatUpdates.Text = LearningService.ModelUpdates.ToString("N0"); StatUpdates.Text = LearningService.ModelUpdates.ToString("N0");
@@ -243,7 +246,23 @@ namespace AutoBidder.Controls
private void ExportDecisionsButton_Click(object sender, RoutedEventArgs e) private void ExportDecisionsButton_Click(object sender, RoutedEventArgs e)
{ {
var result = DecisionExporter.ExportCsv(); string? path;
try
{
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = "Esporta le decisioni in CSV",
FileName = $"decisioni-{DateTime.Now:yyyyMMdd-HHmm}.csv",
Filter = "CSV (foglio di calcolo)|*.csv",
OverwritePrompt = true,
AddExtension = true
};
path = dialog.ShowDialog() == true ? dialog.FileName : null;
}
catch { path = null; }
if (path == null) return;
var result = DecisionExporter.ExportCsv(path);
if (result.Success) if (result.Success)
{ {
try { Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(result.Path)!, UseShellExecute = true }); } catch { } try { Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(result.Path)!, UseShellExecute = true }); } catch { }
@@ -293,21 +312,6 @@ namespace AutoBidder.Controls
private void RefreshButton_Click(object sender, RoutedEventArgs e) => Refresh(); private void RefreshButton_Click(object sender, RoutedEventArgs e) => Refresh();
private void OpenFolderButton_Click(object sender, RoutedEventArgs e)
{
try
{
var folder = Path.Combine(AppPaths.StatsFolder, "Apprendimento");
Directory.CreateDirectory(folder);
Process.Start(new ProcessStartInfo { FileName = folder, UseShellExecute = true });
}
catch (Exception ex)
{
MessageBox.Show($"Cartella non apribile: {ex.Message}", "Apprendimento",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private async void EvaluateButton_Click(object sender, RoutedEventArgs e) private async void EvaluateButton_Click(object sender, RoutedEventArgs e)
{ {
if (_evaluation != null) if (_evaluation != null)
@@ -317,12 +321,14 @@ namespace AutoBidder.Controls
} }
_evaluation = new CancellationTokenSource(); _evaluation = new CancellationTokenSource();
EvaluateButton.Content = "Annulla"; // Il pulsante è solo icona: mentre gira diventa una X, e il tooltip lo dice.
EvaluationBox.Text = "Valutazione in corso: lettura dei dossier…"; EvaluateGlyph.Text = "\uE711";
EvaluateButton.ToolTip = "Annulla la valutazione in corso.";
EvaluationBox.Text = "Valutazione in corso: lettura delle aste…";
try try
{ {
// Al massimo tremila dossier, i più recenti: bastano a giudicare, e la // Al massimo tremila aste, le più recenti: bastano a giudicare, e la
// lettura di tutto l'archivio in sottofondo mentre le aste corrono non serve. // lettura di tutto l'archivio in sottofondo mentre le aste corrono non serve.
var report = await LearningService.EvaluateAsync(3000, var report = await LearningService.EvaluateAsync(3000,
msg => Dispatcher.BeginInvoke(() => EvaluationBox.Text = "Valutazione in corso: " + msg), msg => Dispatcher.BeginInvoke(() => EvaluationBox.Text = "Valutazione in corso: " + msg),
@@ -341,15 +347,16 @@ namespace AutoBidder.Controls
finally finally
{ {
_evaluation = null; _evaluation = null;
EvaluateButton.Content = "Valuta ora"; EvaluateGlyph.Text = "\uE9F5";
EvaluateButton.ToolTip = "Valuta ora.\nAddestra sulle aste più vecchie del database e giudica sulle più recenti, mai viste. Poi la prova prequenziale: ogni asta prima prevista e poi appresa, come fa il motore. Qualche minuto in sottofondo; il rapporto compare nella scheda «Valutazione».";
} }
} }
private async void RetrainButton_Click(object sender, RoutedEventArgs e) private async void RetrainButton_Click(object sender, RoutedEventArgs e)
{ {
var answer = MessageBox.Show( var answer = MessageBox.Show(
"Butto modello, profilo ed elenco dei dossier letti, e ricomincio a studiare tutto l'archivio.\n\n" + "Butto modello, profilo, sfidante, bandit e contatore delle aste studiate, e ricomincio a studiare tutte le aste chiuse del database.\n\n" +
"I dossier e lo storico non vengono toccati. Ci vorranno un paio di minuti in sottofondo.\n\nProcedo?", "Lo storico e i prodotti non vengono toccati. Ci vorranno un paio di minuti in sottofondo.\n\nProcedo?",
"Ricomincia da capo", MessageBoxButton.YesNo, MessageBoxImage.Warning); "Ricomincia da capo", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return; if (answer != MessageBoxResult.Yes) return;
+46 -35
View File
@@ -155,7 +155,7 @@
</Border> </Border>
</StackPanel> </StackPanel>
<!-- Ricerca + legenda "dove posso scrivere?" --> <!-- Ricerca -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"
Margin="14,0,10,0"> Margin="14,0,10,0">
<Border Background="{DynamicResource Brush.Bg}" <Border Background="{DynamicResource Brush.Bg}"
@@ -182,56 +182,67 @@
</Grid> </Grid>
</Border> </Border>
<Border Background="{DynamicResource Brush.Bg}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1"
CornerRadius="4" Width="18" Height="14" VerticalAlignment="Center"/>
<TextBlock Text="= modificabile"
Margin="6,0,0,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextFaint}"/>
</StackPanel> </StackPanel>
<!-- Solo icone, come nel monitor: il tooltip dice esattamente cosa succede. -->
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="ScanNowButton" Style="{StaticResource SmallRoundedButton}" <Button x:Name="ScanNowButton" Style="{StaticResource IconButton}"
Background="{DynamicResource Brush.SurfaceAlt}" Foreground="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.Text}" ToolTip="Cerca aste ora.&#10;Interroga subito il catalogo di Bidoo per i prodotti con la stellina accesa e mette nel monitor le aste nuove che trova, con i limiti del prodotto. Senza aspettare il prossimo giro automatico."
Content="Cerca aste ora" Margin="0,0,8,0" Click="ScanNowButton_Click">
ToolTip="Cerca subito aste nuove dei prodotti seguiti, senza aspettare il prossimo giro" <TextBlock Style="{StaticResource Glyph}" Text="&#xE721;"/>
Click="ScanNowButton_Click"/> </Button>
<Button x:Name="ReapplyButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Accent}"
ToolTip="Riapplica i limiti.&#10;Riscrive prezzo minimo e massimo, puntate massime, avvio e valore reale del prodotto selezionato sulle aste di quel prodotto già presenti nel monitor. Serve dopo aver cambiato i limiti in questa scheda."
Click="ReapplyButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE895;"/>
</Button>
<Button x:Name="ApplySuggestedButton" Style="{StaticResource SmallRoundedButton}" <Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Usa consigliati" Margin="0,0,8,0"
ToolTip="Scrive prezzo minimo e massimo consigliati nel prodotto selezionato.&#10;&#10;Il consiglio nasce da come è andata finora: il massimo copre l'85% delle chiusure passate (regolabile in Impostazioni), abbassato se il budget non lo regge — valore del prodotto meno il costo delle puntate massime. Il minimo è il decimo percentile: sotto quel prezzo l'asta non si chiude quasi mai."
Click="ApplySuggestedButton_Click"/>
<Button x:Name="ApplyAllSuggestedButton" Style="{StaticResource SmallRoundedButton}" <Button x:Name="ApplySuggestedButton" Style="{StaticResource IconButton}"
Background="{DynamicResource Brush.SurfaceAlt}" ToolTip="Usa i limiti consigliati sul prodotto selezionato.&#10;Scrive prezzo minimo e massimo consigliati dallo storico: il massimo copre l'85% delle chiusure passate (regolabile in Impostazioni), abbassato se il budget non lo regge — valore del prodotto meno il costo delle puntate massime. Il minimo è il decimo percentile: sotto quel prezzo l'asta non si chiude quasi mai."
Foreground="{DynamicResource Brush.Text}" Click="ApplySuggestedButton_Click">
Content="Usa consigliati su tutti" Margin="0,0,8,0" <TextBlock Style="{StaticResource Glyph}" Text="&#xE8EF;"/>
ToolTip="Scrive i limiti consigliati su tutti i prodotti che hanno abbastanza aste concluse. Chiede conferma e dice quante righe cambierebbe." </Button>
Click="ApplyAllSuggestedButton_Click"/> <Button x:Name="ApplyAllSuggestedButton" Style="{StaticResource IconButton}"
ToolTip="Usa i limiti consigliati su tutti i prodotti.&#10;Scrive i limiti consigliati su ogni prodotto che ha abbastanza aste concluse per ricavarli. Chiede conferma e dice prima quante righe cambierebbe."
Click="ApplyAllSuggestedButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE762;"/>
</Button>
<Button x:Name="ReapplyButton" Style="{StaticResource SmallRoundedButton}" <Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Content="Riapplica" Margin="0,0,8,0"
ToolTip="Riscrive i limiti del prodotto selezionato sulle aste già presenti nel monitor"
Click="ReapplyButton_Click"/>
<Button x:Name="RemoveButton" Style="{StaticResource IconButton}" <Button x:Name="RemoveButton" Style="{StaticResource IconButton}"
ToolTip="Togli dall'elenco il prodotto selezionato" Foreground="{DynamicResource Brush.Danger}"
ToolTip="Togli il prodotto selezionato.&#10;Lo elimina dall'elenco con i suoi limiti. Le aste già nel monitor restano dove sono; le statistiche del prodotto restano."
Click="RemoveButton_Click"> Click="RemoveButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE738;"/> <TextBlock Style="{StaticResource Glyph}" Text="&#xE738;"/>
</Button> </Button>
<Button x:Name="UnwatchAllButton" Style="{StaticResource IconButton}"
ToolTip="Spegni tutte le stelline.&#10;Nessun prodotto viene più cercato in automatico. I prodotti restano in elenco con i loro limiti: si riaccendono una a una."
Click="UnwatchAllButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE734;"/>
</Button>
<Button x:Name="RemoveUnwatchedButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Togli i prodotti senza stellina.&#10;Elimina dall'elenco tutti i prodotti non seguiti, con i loro limiti. Restano solo quelli con la stellina accesa. Le statistiche per prodotto restano."
Click="RemoveUnwatchedButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE8D9;"/>
</Button>
<Button x:Name="ClearButton" Style="{StaticResource IconButton}" <Button x:Name="ClearButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}" Foreground="{DynamicResource Brush.Danger}"
ToolTip="Svuota l'elenco dei prodotti" ToolTip="Svuota l'elenco.&#10;Toglie tutti i prodotti e i loro limiti. Le statistiche per prodotto (aste concluse, prezzi tipici, consigli) restano: se il prodotto ricompare, i consigli ci sono ancora."
Click="ClearButton_Click"> Click="ClearButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE74D;"/> <TextBlock Style="{StaticResource Glyph}" Text="&#xE74D;"/>
</Button> </Button>
<Button x:Name="ClearAllButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Pulizia completa.&#10;Toglie tutti i prodotti, i loro limiti E le statistiche per prodotto. Lo storico delle aste non viene toccato: le statistiche si possono ricostruire da lì con «Pulisci dati incompleti» nello Storico."
Click="ClearAllButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE894;"/>
</Button>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
+36
View File
@@ -135,6 +135,15 @@ namespace AutoBidder.Controls
private void ClearButton_Click(object sender, RoutedEventArgs e) private void ClearButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearClickedEvent, this)); => RaiseEvent(new RoutedEventArgs(ClearClickedEvent, this));
private void ClearAllButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearAllClickedEvent, this));
private void UnwatchAllButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(UnwatchAllClickedEvent, this));
private void RemoveUnwatchedButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(RemoveUnwatchedClickedEvent, this));
private void ApplySuggestedButton_Click(object sender, RoutedEventArgs e) private void ApplySuggestedButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ApplySuggestedClickedEvent, this)); => RaiseEvent(new RoutedEventArgs(ApplySuggestedClickedEvent, this));
@@ -158,6 +167,15 @@ namespace AutoBidder.Controls
public static readonly RoutedEvent ClearClickedEvent = EventManager.RegisterRoutedEvent( public static readonly RoutedEvent ClearClickedEvent = EventManager.RegisterRoutedEvent(
"ClearClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl)); "ClearClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ClearAllClickedEvent = EventManager.RegisterRoutedEvent(
"ClearAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent UnwatchAllClickedEvent = EventManager.RegisterRoutedEvent(
"UnwatchAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent RemoveUnwatchedClickedEvent = EventManager.RegisterRoutedEvent(
"RemoveUnwatchedClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent ApplySuggestedClickedEvent = EventManager.RegisterRoutedEvent( public static readonly RoutedEvent ApplySuggestedClickedEvent = EventManager.RegisterRoutedEvent(
"ApplySuggestedClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl)); "ApplySuggestedClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
@@ -200,6 +218,24 @@ namespace AutoBidder.Controls
remove { RemoveHandler(ClearClickedEvent, value); } remove { RemoveHandler(ClearClickedEvent, value); }
} }
public event RoutedEventHandler ClearAllClicked
{
add { AddHandler(ClearAllClickedEvent, value); }
remove { RemoveHandler(ClearAllClickedEvent, value); }
}
public event RoutedEventHandler UnwatchAllClicked
{
add { AddHandler(UnwatchAllClickedEvent, value); }
remove { RemoveHandler(UnwatchAllClickedEvent, value); }
}
public event RoutedEventHandler RemoveUnwatchedClicked
{
add { AddHandler(RemoveUnwatchedClickedEvent, value); }
remove { RemoveHandler(RemoveUnwatchedClickedEvent, value); }
}
public event RoutedEventHandler ApplySuggestedClicked public event RoutedEventHandler ApplySuggestedClicked
{ {
add { AddHandler(ApplySuggestedClickedEvent, value); } add { AddHandler(ApplySuggestedClickedEvent, value); }
+47 -140
View File
@@ -53,9 +53,9 @@
<StackPanel Orientation="Horizontal" Margin="0,0,0,10"> <StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<CheckBox x:Name="QuietHoursCheckBox" Content="Sospendi le puntate dalle" VerticalAlignment="Center"/> <CheckBox x:Name="QuietHoursCheckBox" Content="Sospendi le puntate dalle" VerticalAlignment="Center"/>
<TextBox x:Name="QuietHoursStartTextBox" Text="0" Width="40" Margin="8,0" VerticalAlignment="Center"/> <TextBox x:Name="QuietHoursStartTextBox" Text="0" Width="56" Margin="8,0" VerticalAlignment="Center"/>
<TextBlock Text="alle" VerticalAlignment="Center" Foreground="{DynamicResource Brush.Text}"/> <TextBlock Text="alle" VerticalAlignment="Center" Foreground="{DynamicResource Brush.Text}"/>
<TextBox x:Name="QuietHoursEndTextBox" Text="10" Width="40" Margin="8,0" VerticalAlignment="Center"/> <TextBox x:Name="QuietHoursEndTextBox" Text="10" Width="56" Margin="8,0" VerticalAlignment="Center"/>
<TextBlock Text="(ore, 0-23)" VerticalAlignment="Center" Foreground="{DynamicResource Brush.TextMuted}"/> <TextBlock Text="(ore, 0-23)" VerticalAlignment="Center" Foreground="{DynamicResource Brush.TextMuted}"/>
</StackPanel> </StackPanel>
@@ -365,7 +365,7 @@ L'elenco dei prodotti è nella scheda Prodotti: lì ogni articolo ha la stellina
<TextBlock Grid.Row="5" Grid.Column="0" Text="Solo aste non ancora cominciate" <TextBlock Grid.Row="5" Grid.Column="0" Text="Solo aste non ancora cominciate"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"
ToolTip="Aggiungi da solo soltanto le aste su cui nessuno ha ancora puntato, per poterle seguire dal primo istante all&apos;ultimo.&#10;&#10;Un&apos;asta presa a metà si può seguire, ma la sua storia è già scritta per buona parte: non si sa quanto è durata davvero, chi c&apos;era prima, come è salito il prezzo. Le statistiche che ne escono sono monche e i dossier raccontano solo la seconda metà.&#10;&#10;Il prezzo da pagare è che si perdono le occasioni già in corso."/> ToolTip="Aggiungi da solo soltanto le aste su cui nessuno ha ancora puntato, per poterle seguire dal primo istante all&apos;ultimo.&#10;&#10;Un&apos;asta presa a metà si può seguire, ma la sua storia è già scritta per buona parte: non si sa quanto è durata davvero, chi c&apos;era prima, come è salito il prezzo. Le statistiche che ne escono sono monche e la registrazione racconta solo la seconda metà.&#10;&#10;Il prezzo da pagare è che si perdono le occasioni già in corso."/>
<CheckBox Grid.Row="5" Grid.Column="1" x:Name="AutoAddOnlyNotStartedCheckBox" <CheckBox Grid.Row="5" Grid.Column="1" x:Name="AutoAddOnlyNotStartedCheckBox"
Margin="10,10" VerticalAlignment="Center"/> Margin="10,10" VerticalAlignment="Center"/>
@@ -380,59 +380,6 @@ L'elenco dei prodotti è nella scheda Prodotti: lì ogni articolo ha la stellina
</StackPanel> </StackPanel>
</Border> </Border>
<!-- SEZIONE: Anticipo puntata - misura e taratura -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Anticipo Puntata (misura e taratura)"
Margin="0,0,0,14"
Help="L'anticipo impostato è una scommessa; quello che conta è dove la puntata arriva davvero. L'applicazione misura ogni puntata riuscita e, con abbastanza dati, dice se c'è margine per correggere.
Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in mediana 1 puntata per asta, sotto i 2 s ne servono 15, sotto i 3 s 46. Dall'altro lato il ping tocca 444 ms nel p99,9, quindi sotto i 600 ms la puntata rischia di arrivare a giochi chiusi. La fascia sensata è 6002000 ms."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="200"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Misura l'anticipo effettivo"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="BidLeadTrackingCheckBox"
Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Puntate minime per un consiglio"
Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center"/>
<TextBox Grid.Row="1" Grid.Column="1" x:Name="BidLeadMinSamplesTextBox"
Text="15" Margin="10,10"/>
</Grid>
<Border Style="{StaticResource InfoBox}" Margin="0,12,0,10">
<StackPanel>
<TextBlock Text="Misure raccolte" FontWeight="Bold"
Foreground="{DynamicResource Brush.Accent}" Margin="0,0,0,8"/>
<TextBlock x:Name="BidLeadSummaryText" TextWrapping="Wrap"
Foreground="{DynamicResource Brush.Text}" FontSize="12" LineHeight="18"
Text="Nessuna puntata registrata finora."/>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal">
<Button Content="Calcola consiglio" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Margin="0,0,8,0" Click="ComputeBidLeadAdviceButton_Click"/>
<Button Content="Azzera misure" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Danger}"
Click="ClearBidLeadStatsButton_Click"/>
</StackPanel>
</StackPanel>
</Border>
<!-- SEZIONE: Aste programmate --> <!-- SEZIONE: Aste programmate -->
<Border Style="{StaticResource CardBlock}"> <Border Style="{StaticResource CardBlock}">
<StackPanel> <StackPanel>
@@ -491,96 +438,29 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
</StackPanel> </StackPanel>
</Border> </Border>
<!-- SEZIONE: Cartelle dei dati --> <!-- SEZIONE: Database -->
<Border Style="{StaticResource CardBlock}"> <Border Style="{StaticResource CardBlock}">
<StackPanel> <StackPanel>
<ctl:HelpHeader Title="Cartelle dei Dati" <ctl:HelpHeader Title="Database"
Margin="0,0,0,14" Margin="0,0,0,14"
Help="Ogni campo mostra il percorso in uso: cambialo per spostare i dati altrove, svuotalo per tornare alla posizione predefinita. Impostazioni e sessione restano sempre nella cartella dell'applicazione, perché è lì che si legge dove stanno tutti gli altri."/> Help="Tutto ciò che l'applicazione registra sta in due file SQLite in questa cartella: le osservazioni (aste, puntate, interrogazioni, decisioni, misure) e l'esercizio (aste nel monitor, prodotti seguiti, modelli appresi, registri). Le impostazioni e la login restano nelle loro cartelle.&#10;&#10;Se cambi cartella, al salvataggio ti chiedo se spostare i database esistenti; in ogni caso l'applicazione si riavvia. Mai una cartella sincronizzata (OneDrive, Dropbox): un database aperto sotto sincronizzazione si corrompe."/>
<TextBlock Text="Dati d'esercizio (aste monitorate, prodotti seguiti)" <TextBlock Text="Cartella dei database" Style="{StaticResource FieldLabel}"/>
Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,0,0,12"> <Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBox Grid.Column="0" x:Name="DataFolderTextBox"/> <TextBox Grid.Column="0" x:Name="DatabaseFolderTextBox"/>
<Button Grid.Column="1" Content="Sfoglia…" Margin="8,0,0,0" <Button Grid.Column="1" Content="Sfoglia…" Margin="8,0,0,0"
Style="{StaticResource SmallRoundedButton}" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}" Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}" Foreground="{DynamicResource Brush.Text}"
Click="BrowseDataFolderButton_Click"/> Click="BrowseDatabaseFolderButton_Click"/>
<Button Grid.Column="2" Margin="6,0,0,0" <Button Grid.Column="2" Margin="6,0,0,0"
Style="{StaticResource IconButton}" Style="{StaticResource IconButton}"
ToolTip="Apri questa cartella" ToolTip="Apri la cartella dei database"
Click="OpenDataFolderButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button>
</Grid>
<TextBlock Text="Storico ed esportazioni" Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" x:Name="StatsFolderTextBox"/>
<Button Grid.Column="1" Content="Sfoglia…" Margin="8,0,0,0"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Click="BrowseStatsFolderButton_Click"/>
<Button Grid.Column="2" Margin="6,0,0,0"
Style="{StaticResource IconButton}"
ToolTip="Apri questa cartella"
Click="OpenStatsFolderSettingsButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button>
</Grid>
<TextBlock Text="Registri (log applicativo, riscatto puntate)"
Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" x:Name="LogFolderTextBox"/>
<Button Grid.Column="1" Content="Sfoglia…" Margin="8,0,0,0"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Click="BrowseLogFolderButton_Click"/>
<Button Grid.Column="2" Margin="6,0,0,0"
Style="{StaticResource IconButton}"
ToolTip="Apri questa cartella"
Click="OpenLogFolderButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button>
</Grid>
<TextBlock Text="Database (aste, puntate, decisioni, misure)" Margin="0,12,0,4"
Foreground="{DynamicResource Brush.TextMuted}"
ToolTip="Un solo file SQLite. Sta in %LocalAppData%\AutoBidder\Database e non va messo in una cartella sincronizzata (OneDrive, Dropbox): è il modo classico di corrompere un database aperto. Cambiarlo richiede un riavvio."/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" x:Name="DatabaseFileTextBox"/>
<Button Grid.Column="1" Content="Sfoglia…" Margin="8,0,0,0"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Click="BrowseDatabaseFileButton_Click"/>
<Button Grid.Column="2" Margin="6,0,0,0"
Style="{StaticResource IconButton}"
ToolTip="Apri la cartella del database"
Click="OpenDatabaseFolderButton_Click"> Click="OpenDatabaseFolderButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/> <TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button> </Button>
@@ -588,17 +468,41 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
</StackPanel> </StackPanel>
</Border> </Border>
<!-- SEZIONE: Manutenzione -->
<Border Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Manutenzione"
Margin="0,0,0,14"
Help="Le due operazioni estreme. «Elimina tutti i dati» cancella i database, le copie di sicurezza e la vecchia cartella Dati: restano la login e le impostazioni. «Impostazioni di fabbrica» cancella solo il file delle impostazioni: i dati e la login restano. Entrambe chiedono conferma e riavviano l'applicazione."/>
<StackPanel Orientation="Horizontal">
<Button Content="Elimina tutti i dati (tranne la login)" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Danger}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Margin="0,0,8,0" Padding="14,6"
ToolTip="Cancella i due database (aste, puntate, decisioni, prodotti seguiti, aste nel monitor, modelli appresi, registri), le copie di sicurezza e la vecchia cartella Dati. Restano sessione, riferimenti del sito, browser e impostazioni. Doppia conferma, poi riavvio."
Click="WipeAllDataButton_Click"/>
<Button Content="Impostazioni di fabbrica" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Danger}"
Margin="0,0,8,0" Padding="14,6"
ToolTip="Cancella il file delle impostazioni e riavvia: tutto torna ai predefiniti. Dati e login non vengono toccati."
Click="ResetSettingsButton_Click"/>
</StackPanel>
</StackPanel>
</Border>
<!-- SEZIONE: Registri su file --> <!-- SEZIONE: Registri su file -->
<Border Style="{StaticResource CardBlock}"> <Border Style="{StaticResource CardBlock}">
<StackPanel> <StackPanel>
<ctl:HelpHeader Title="Registri su file" <ctl:HelpHeader Title="Registri"
Margin="0,0,0,14" Margin="0,0,0,14"
Help="I registri si scrivono mentre l'applicazione lavora, non alla chiusura: quello che vedi nelle schede è già sul disco. Servono a rileggere cosa è successo mentre non guardavi — e il dossier delle aste è il materiale su cui si studiano le strategie, perché Bidoo non espone nulla di tutto questo a posteriori. È anche ciò che legge backtest.ps1 per dire quanto costerebbe ciascun anticipo."/> Help="I registri si scrivono mentre l'applicazione lavora, nel database d'esercizio: quello che vedi nelle schede è già sul disco. Servono a rileggere cosa è successo mentre non guardavi. Si esportano in testo con il pulsante qui sotto e scadono dopo il numero di giorni indicato."/>
<CheckBox x:Name="WriteAppLogCheckBox" IsChecked="True" Margin="0,4" <CheckBox x:Name="WriteAppLogCheckBox" IsChecked="True" Margin="0,4"
Content="Registro applicativo su file (un file per giorno)"/> Content="Registro applicativo nel database"/>
<CheckBox x:Name="WriteFreeBidsLogCheckBox" IsChecked="True" Margin="0,4" <CheckBox x:Name="WriteFreeBidsLogCheckBox" IsChecked="True" Margin="0,4"
Content="Registro del riscatto puntate su file"/> Content="Registro del riscatto puntate nel database"/>
<CheckBox x:Name="WriteDossiersCheckBox" IsChecked="True" Margin="0,4" <CheckBox x:Name="WriteDossiersCheckBox" IsChecked="True" Margin="0,4"
Content="Registra ogni asta seguita nel database (puntate di ogni utente, reset, mie puntate, decisioni)"/> Content="Registra ogni asta seguita nel database (puntate di ogni utente, reset, mie puntate, decisioni)"/>
<CheckBox x:Name="RawPollsCheckBox" IsChecked="True" Margin="0,4" <CheckBox x:Name="RawPollsCheckBox" IsChecked="True" Margin="0,4"
@@ -617,6 +521,13 @@ Dalla rigiocata di 2.095 aste concluse: con anticipo sotto il secondo serve in m
ToolTip="0 = tienili per sempre"/> ToolTip="0 = tienili per sempre"/>
<TextBox Grid.Column="1" x:Name="LogRetentionTextBox" Text="90" Margin="10,10"/> <TextBox Grid.Column="1" x:Name="LogRetentionTextBox" Text="90" Margin="10,10"/>
</Grid> </Grid>
<Button Content="Esporta i registri in testo…" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
HorizontalAlignment="Left" Margin="0,6,0,0" Padding="14,5"
ToolTip="Scrive il registro applicativo e quello del riscatto puntate in due file di testo, nella cartella che scegli."
Click="ExportAppLogButton_Click"/>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -699,7 +610,7 @@ Se il valore è maggiore di zero, il sistema non punta quando le puntate residue
<StackPanel> <StackPanel>
<ctl:HelpHeader Title="Gestione del rischio" <ctl:HelpHeader Title="Gestione del rischio"
Margin="0,0,0,14" Margin="0,0,0,14"
Help="I paletti duri che nessuna strategia può scavalcare. Ogni puntata dal vivo passa da qui prima di partire.&#10;&#10;Il tetto di spesa del giorno ferma le puntate per oggi e domani si riparte. Lo stop-loss (perdita netta del giorno) e il drawdown (discesa dal picco del saldo cumulato) mettono invece il sistema in HALT: resta fermo anche dopo un riavvio, finché non lo togli tu da qui. Le vincite si contano al valore reale del prodotto (colonna «Valore reale» nella scheda Prodotti), meno prezzo, fee e spedizione.&#10;&#10;Il kill-switch è un file KILL_SWITCH in %LocalAppData%\AutoBidder: finché esiste nessuna puntata dal vivo parte. Lo puoi creare anche da fuori, senza aprire l'applicazione.&#10;&#10;La copertura «Compralo Ora» presume che, perdendo, si compri al listino recuperando le puntate come credito: la perdita si limita a listino valore reale. Per i buoni al nominale copre tutto dal primo centesimo, e il modello punterà più spesso. Se non intendi esercitarla, spegnila."/> Help="I paletti duri che nessuna strategia può scavalcare. Ogni puntata dal vivo passa da qui prima di partire.&#10;&#10;Il tetto di spesa del giorno ferma le puntate per oggi e domani si riparte. Lo stop-loss (perdita netta del giorno) e il drawdown (discesa dal picco del saldo cumulato) mettono invece il sistema in HALT: resta fermo anche dopo un riavvio, finché non lo togli tu da qui. Le vincite si contano al valore reale del prodotto (colonna «Valore reale» nella scheda Prodotti), meno prezzo, fee e spedizione.&#10;&#10;La copertura «Compralo Ora» presume che, perdendo, si compri al listino recuperando le puntate come credito: la perdita si limita a listino valore reale. Per i buoni al nominale copre tutto dal primo centesimo, e il modello punterà più spesso. Se non intendi esercitarla, spegnila."/>
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
@@ -743,12 +654,8 @@ Se il valore è maggiore di zero, il sistema non punta quando le puntate residue
<TextBlock Grid.Row="7" Grid.Column="0" Text="Fee di transazione (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quanto Bidoo aggiunge alla vincita. Entra nel valore atteso di ogni puntata e nel conto delle vincite."/> <TextBlock Grid.Row="7" Grid.Column="0" Text="Fee di transazione (€)" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Quanto Bidoo aggiunge alla vincita. Entra nel valore atteso di ogni puntata e nel conto delle vincite."/>
<TextBox Grid.Row="7" Grid.Column="1" x:Name="TransactionFeeTextBox" Text="1,00" Margin="10,10"/> <TextBox Grid.Row="7" Grid.Column="1" x:Name="TransactionFeeTextBox" Text="1,00" Margin="10,10"/>
<TextBlock Grid.Row="8" Grid.Column="0" Text="Copertura «Compralo Ora»" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Vedi la spiegazione della sezione. Accesa: le puntate oltre la perdita coperta non costano nulla nel ramo di sconfitta."/> <TextBlock Grid.Row="8" Grid.Column="0" Text="Copertura «Compralo Ora»" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Vedi la spiegazione della sezione. Accesa: le puntate oltre la perdita coperta (Compralo Ora meno valore reale) non costano nulla nel ramo di sconfitta.&#10;&#10;Vale solo per i prodotti con «Valore reale €» scritto nella scheda Prodotti: senza, il valore coinciderebbe con il listino, la perdita coperta sarebbe zero e ogni puntata risulterebbe gratis — si punterebbe sempre."/>
<CheckBox Grid.Row="8" Grid.Column="1" x:Name="HedgeCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/> <CheckBox Grid.Row="8" Grid.Column="1" x:Name="HedgeCheckBox" Margin="10,10" VerticalAlignment="Center" IsChecked="True"/>
<TextBlock Grid.Row="9" Grid.Column="0" Text="Kill-switch" Foreground="{DynamicResource Brush.Text}" Margin="0,10" VerticalAlignment="Center" ToolTip="Acceso: nessuna puntata dal vivo. Si applica subito, senza salvare."/>
<CheckBox Grid.Row="9" Grid.Column="1" x:Name="KillSwitchCheckBox" Margin="10,10" VerticalAlignment="Center"
Checked="KillSwitchCheckBox_Changed" Unchecked="KillSwitchCheckBox_Changed"/>
</Grid> </Grid>
<Border Style="{StaticResource InfoBox}" Margin="0,15,0,0"> <Border Style="{StaticResource InfoBox}" Margin="0,15,0,0">
+43 -127
View File
@@ -62,46 +62,22 @@ namespace AutoBidder.Controls
// ===== ANTICIPO, CARTELLE, ESPORTAZIONE ===== // ===== ANTICIPO, CARTELLE, ESPORTAZIONE =====
private void ComputeBidLeadAdviceButton_Click(object sender, RoutedEventArgs e) private void BrowseDatabaseFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ComputeBidLeadAdviceClickedEvent, this)); => RaiseEvent(new RoutedEventArgs(BrowseDatabaseFolderClickedEvent, this));
private void ClearBidLeadStatsButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearBidLeadStatsClickedEvent, this));
private void BrowseDataFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(BrowseDataFolderClickedEvent, this));
private void BrowseStatsFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(BrowseStatsFolderClickedEvent, this));
private void OpenDataFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenDataFolderClickedEvent, this));
private void OpenStatsFolderSettingsButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenStatsFolderClickedEvent, this));
private void BrowseLogFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(BrowseLogFolderClickedEvent, this));
private void OpenLogFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenLogFolderClickedEvent, this));
private void BrowseDatabaseFileButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(BrowseDatabaseFileClickedEvent, this));
private void OpenDatabaseFolderButton_Click(object sender, RoutedEventArgs e) private void OpenDatabaseFolderButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(OpenDatabaseFolderClickedEvent, this)); => RaiseEvent(new RoutedEventArgs(OpenDatabaseFolderClickedEvent, this));
public static readonly RoutedEvent BrowseDatabaseFileClickedEvent = EventManager.RegisterRoutedEvent( public static readonly RoutedEvent BrowseDatabaseFolderClickedEvent = EventManager.RegisterRoutedEvent(
"BrowseDatabaseFileClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl)); "BrowseDatabaseFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent OpenDatabaseFolderClickedEvent = EventManager.RegisterRoutedEvent( public static readonly RoutedEvent OpenDatabaseFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenDatabaseFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl)); "OpenDatabaseFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler BrowseDatabaseFileClicked public event RoutedEventHandler BrowseDatabaseFolderClicked
{ {
add { AddHandler(BrowseDatabaseFileClickedEvent, value); } add { AddHandler(BrowseDatabaseFolderClickedEvent, value); }
remove { RemoveHandler(BrowseDatabaseFileClickedEvent, value); } remove { RemoveHandler(BrowseDatabaseFolderClickedEvent, value); }
} }
public event RoutedEventHandler OpenDatabaseFolderClicked public event RoutedEventHandler OpenDatabaseFolderClicked
@@ -112,14 +88,41 @@ namespace AutoBidder.Controls
// ── Gestione del rischio: kill-switch e HALT si applicano subito ── // ── Gestione del rischio: kill-switch e HALT si applicano subito ──
private bool _riskSyncing;
private void KillSwitchCheckBox_Changed(object sender, RoutedEventArgs e) private void WipeAllDataButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(WipeAllDataClickedEvent, this));
private void ResetSettingsButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ResetSettingsClickedEvent, this));
private void ExportAppLogButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ExportAppLogClickedEvent, this));
public static readonly RoutedEvent WipeAllDataClickedEvent = EventManager.RegisterRoutedEvent(
"WipeAllDataClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent ResetSettingsClickedEvent = EventManager.RegisterRoutedEvent(
"ResetSettingsClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent ExportAppLogClickedEvent = EventManager.RegisterRoutedEvent(
"ExportAppLogClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler WipeAllDataClicked
{ {
if (_riskSyncing) return; add { AddHandler(WipeAllDataClickedEvent, value); }
Services.RiskManager.SetKillSwitch(KillSwitchCheckBox.IsChecked == true); remove { RemoveHandler(WipeAllDataClickedEvent, value); }
RefreshRiskStatus(); }
RaiseEvent(new RoutedEventArgs(KillSwitchChangedEvent, this));
public event RoutedEventHandler ResetSettingsClicked
{
add { AddHandler(ResetSettingsClickedEvent, value); }
remove { RemoveHandler(ResetSettingsClickedEvent, value); }
}
public event RoutedEventHandler ExportAppLogClicked
{
add { AddHandler(ExportAppLogClickedEvent, value); }
remove { RemoveHandler(ExportAppLogClickedEvent, value); }
} }
private void ResumeHaltButton_Click(object sender, RoutedEventArgs e) private void ResumeHaltButton_Click(object sender, RoutedEventArgs e)
@@ -128,111 +131,24 @@ namespace AutoBidder.Controls
RefreshRiskStatus(); RefreshRiskStatus();
} }
/// <summary>Riallinea kill-switch e HALT a quello che dicono il file e il database.</summary> /// <summary>Riallinea lo stato dell'HALT e la contabilità a quello che dice il database.</summary>
public void RefreshRiskStatus() public void RefreshRiskStatus()
{ {
try try
{ {
_riskSyncing = true;
KillSwitchCheckBox.IsChecked = Services.RiskManager.KillSwitchActive;
var halt = Services.RiskManager.HaltReason; var halt = Services.RiskManager.HaltReason;
var s = Services.RiskManager.Snapshot(); var s = Services.RiskManager.Snapshot();
var conto = $"Oggi: spesi {s.TodaySpent:F2} €, incassati {s.TodayGained:F2} € (netto {s.TodayNet:+0.00;-0.00} €). " + var conto = $"Oggi: spesi {s.TodaySpent:F2} €, incassati {s.TodayGained:F2} € (netto {s.TodayNet:+0.00;-0.00} €). " +
$"Saldo cumulato {s.Cumulative:+0.00;-0.00} €, picco {s.Peak:F2} €, drawdown {s.Drawdown:F2} €."; $"Saldo cumulato {s.Cumulative:+0.00;-0.00} €, picco {s.Peak:F2} €, drawdown {s.Drawdown:F2} €.";
RiskStatusText.Text = string.IsNullOrEmpty(halt) RiskStatusText.Text = string.IsNullOrEmpty(halt)
? (Services.RiskManager.KillSwitchActive ? "Kill-switch ATTIVO: nessuna puntata dal vivo.\n" : "Nessun HALT in corso.\n") + conto ? "Nessun HALT in corso.\n" + conto
: $"HALT in corso: {halt}\n{conto}"; : $"HALT in corso: {halt}\n{conto}";
ResumeHaltButton.Visibility = string.IsNullOrEmpty(halt) ? Visibility.Collapsed : Visibility.Visible; ResumeHaltButton.Visibility = string.IsNullOrEmpty(halt) ? Visibility.Collapsed : Visibility.Visible;
} }
catch { } catch { }
finally { _riskSyncing = false; } finally { }
}
public static readonly RoutedEvent KillSwitchChangedEvent = EventManager.RegisterRoutedEvent(
"KillSwitchChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler KillSwitchChanged
{
add { AddHandler(KillSwitchChangedEvent, value); }
remove { RemoveHandler(KillSwitchChangedEvent, value); }
}
/// <summary>Riepilogo delle misure sull'anticipo, mostrato nel riquadro informativo.</summary>
public void SetBidLeadSummary(string text) => BidLeadSummaryText.Text = text;
public static readonly RoutedEvent ComputeBidLeadAdviceClickedEvent = EventManager.RegisterRoutedEvent(
"ComputeBidLeadAdviceClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent ClearBidLeadStatsClickedEvent = EventManager.RegisterRoutedEvent(
"ClearBidLeadStatsClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent BrowseDataFolderClickedEvent = EventManager.RegisterRoutedEvent(
"BrowseDataFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent BrowseStatsFolderClickedEvent = EventManager.RegisterRoutedEvent(
"BrowseStatsFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent OpenDataFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenDataFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent OpenStatsFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenStatsFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent BrowseLogFolderClickedEvent = EventManager.RegisterRoutedEvent(
"BrowseLogFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public static readonly RoutedEvent OpenLogFolderClickedEvent = EventManager.RegisterRoutedEvent(
"OpenLogFolderClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(SettingsControl));
public event RoutedEventHandler ComputeBidLeadAdviceClicked
{
add { AddHandler(ComputeBidLeadAdviceClickedEvent, value); }
remove { RemoveHandler(ComputeBidLeadAdviceClickedEvent, value); }
}
public event RoutedEventHandler ClearBidLeadStatsClicked
{
add { AddHandler(ClearBidLeadStatsClickedEvent, value); }
remove { RemoveHandler(ClearBidLeadStatsClickedEvent, value); }
}
public event RoutedEventHandler BrowseDataFolderClicked
{
add { AddHandler(BrowseDataFolderClickedEvent, value); }
remove { RemoveHandler(BrowseDataFolderClickedEvent, value); }
}
public event RoutedEventHandler BrowseStatsFolderClicked
{
add { AddHandler(BrowseStatsFolderClickedEvent, value); }
remove { RemoveHandler(BrowseStatsFolderClickedEvent, value); }
}
public event RoutedEventHandler OpenDataFolderClicked
{
add { AddHandler(OpenDataFolderClickedEvent, value); }
remove { RemoveHandler(OpenDataFolderClickedEvent, value); }
}
public event RoutedEventHandler OpenStatsFolderClicked
{
add { AddHandler(OpenStatsFolderClickedEvent, value); }
remove { RemoveHandler(OpenStatsFolderClickedEvent, value); }
}
public event RoutedEventHandler BrowseLogFolderClicked
{
add { AddHandler(BrowseLogFolderClickedEvent, value); }
remove { RemoveHandler(BrowseLogFolderClickedEvent, value); }
}
public event RoutedEventHandler OpenLogFolderClicked
{
add { AddHandler(OpenLogFolderClickedEvent, value); }
remove { RemoveHandler(OpenLogFolderClickedEvent, value); }
} }
// ===== AGGIUNTA AUTOMATICA ===== // ===== AGGIUNTA AUTOMATICA =====
@@ -84,10 +84,7 @@ namespace AutoBidder
Settings.CatalogMaxAuctionsTextBox.Text = settings.CatalogMaxAuctions.ToString(); Settings.CatalogMaxAuctionsTextBox.Text = settings.CatalogMaxAuctions.ToString();
Settings.CatalogAutoRefreshCheckBox.IsChecked = settings.CatalogAutoRefresh; Settings.CatalogAutoRefreshCheckBox.IsChecked = settings.CatalogAutoRefresh;
// Anticipo, aste programmate, notifiche, cartelle // Aste programmate, notifiche, cartelle
Settings.BidLeadTrackingCheckBox.IsChecked = settings.BidLeadTrackingEnabled;
Settings.BidLeadMinSamplesTextBox.Text = settings.BidLeadMinSamples.ToString();
Settings.ScheduledBackoffCheckBox.IsChecked = settings.ScheduledAuctionBackoffEnabled; Settings.ScheduledBackoffCheckBox.IsChecked = settings.ScheduledAuctionBackoffEnabled;
Settings.ScheduledPollFarTextBox.Text = settings.ScheduledPollFarSeconds.ToString(); Settings.ScheduledPollFarTextBox.Text = settings.ScheduledPollFarSeconds.ToString();
Settings.ScheduledPollMidTextBox.Text = settings.ScheduledPollMidSeconds.ToString(); Settings.ScheduledPollMidTextBox.Text = settings.ScheduledPollMidSeconds.ToString();
@@ -112,12 +109,12 @@ namespace AutoBidder
Settings.AutoRemoveKeepWonCheckBox.IsChecked = settings.AutoRemoveKeepWon; Settings.AutoRemoveKeepWonCheckBox.IsChecked = settings.AutoRemoveKeepWon;
Settings.AutoRemoveKeepUnclearCheckBox.IsChecked = settings.AutoRemoveKeepUnclear; Settings.AutoRemoveKeepUnclearCheckBox.IsChecked = settings.AutoRemoveKeepUnclear;
Settings.WriteAppLogCheckBox.IsChecked = settings.WriteAppLogFile; Settings.WriteAppLogCheckBox.IsChecked = settings.WriteAppLog;
Settings.WriteFreeBidsLogCheckBox.IsChecked = settings.WriteFreeBidsLogFile; Settings.WriteFreeBidsLogCheckBox.IsChecked = settings.WriteFreeBidsLog;
Settings.WriteDossiersCheckBox.IsChecked = settings.WriteAuctionDossiers; Settings.WriteDossiersCheckBox.IsChecked = settings.RecordAuctions;
Settings.RawPollsCheckBox.IsChecked = settings.DossierIncludeRawPolls; Settings.RawPollsCheckBox.IsChecked = settings.RecordPolls;
Settings.LogRetentionTextBox.Text = settings.LogRetentionDays.ToString(); Settings.LogRetentionTextBox.Text = settings.LogRetentionDays.ToString();
Settings.DatabaseFileTextBox.Text = AppPaths.DatabaseFile; Settings.DatabaseFolderTextBox.Text = AppPaths.DatabaseFolder;
Settings.BankrollEnabledCheckBox.IsChecked = settings.BankrollManagerEnabled; Settings.BankrollEnabledCheckBox.IsChecked = settings.BankrollManagerEnabled;
Settings.MaxBidsPerAuctionTextBox.Text = settings.MaxBidsPerAuction.ToString(); Settings.MaxBidsPerAuctionTextBox.Text = settings.MaxBidsPerAuction.ToString();
@@ -130,8 +127,6 @@ namespace AutoBidder
Settings.HedgeCheckBox.IsChecked = settings.BuyNowHedgeEnabled; Settings.HedgeCheckBox.IsChecked = settings.BuyNowHedgeEnabled;
Settings.RefreshRiskStatus(); Settings.RefreshRiskStatus();
RefreshBidLeadSummary();
// Aggiorna indicatore visivo // Aggiorna indicatore visivo
UpdateMinBidsIndicator(settings.MinimumRemainingBids); UpdateMinBidsIndicator(settings.MinimumRemainingBids);
@@ -403,11 +398,6 @@ namespace AutoBidder
settings.CatalogCacheSeconds = ReadBounded(Settings.CatalogCacheSecondsTextBox.Text, settings.CatalogCacheSeconds = ReadBounded(Settings.CatalogCacheSecondsTextBox.Text,
settings.CatalogCacheSeconds, 0, 3600, "cache catalogo", " s"); settings.CatalogCacheSeconds, 0, 3600, "cache catalogo", " s");
// === SEZIONE: Anticipo puntata ===
settings.BidLeadTrackingEnabled = Settings.BidLeadTrackingCheckBox.IsChecked ?? true;
settings.BidLeadMinSamples = ReadBounded(Settings.BidLeadMinSamplesTextBox.Text,
settings.BidLeadMinSamples, 5, 500, "puntate minime per il consiglio", "");
// === SEZIONE: Aste programmate === // === SEZIONE: Aste programmate ===
settings.ScheduledAuctionBackoffEnabled = Settings.ScheduledBackoffCheckBox.IsChecked ?? true; settings.ScheduledAuctionBackoffEnabled = Settings.ScheduledBackoffCheckBox.IsChecked ?? true;
settings.ScheduledPollFarSeconds = ReadBounded(Settings.ScheduledPollFarTextBox.Text, settings.ScheduledPollFarSeconds = ReadBounded(Settings.ScheduledPollFarTextBox.Text,
@@ -422,15 +412,7 @@ namespace AutoBidder
settings.NotifyOnLoss = Settings.NotifyOnLossCheckBox.IsChecked ?? false; settings.NotifyOnLoss = Settings.NotifyOnLossCheckBox.IsChecked ?? false;
// === SEZIONE: Cartelle dei dati === // === SEZIONE: Cartelle dei dati ===
var previousData = AppPaths.DataFolder; var previousDatabaseFolder = AppPaths.DatabaseFolder;
var previousStats = AppPaths.StatsFolder;
// Il campo contiene il percorso risolto: se coincide con il predefinito si
// salva vuoto, così spostando la cartella dati i registri la seguono invece
// di restare inchiodati a un percorso che nessuno ha mai scelto davvero.
settings.DataFolder = NormalizeFolderChoice(Settings.DataFolderTextBox.Text, AppPaths.DataFolder, settings.DataFolder);
settings.StatsFolder = NormalizeFolderChoice(Settings.StatsFolderTextBox.Text, AppPaths.StatsFolder, settings.StatsFolder);
settings.LogFolder = NormalizeFolderChoice(Settings.LogFolderTextBox.Text, AppPaths.LogFolder, settings.LogFolder);
settings.QuietHoursEnabled = Settings.QuietHoursCheckBox.IsChecked ?? true; settings.QuietHoursEnabled = Settings.QuietHoursCheckBox.IsChecked ?? true;
@@ -444,18 +426,15 @@ namespace AutoBidder
settings.AutoRemoveKeepWon = Settings.AutoRemoveKeepWonCheckBox.IsChecked ?? true; settings.AutoRemoveKeepWon = Settings.AutoRemoveKeepWonCheckBox.IsChecked ?? true;
settings.AutoRemoveKeepUnclear = Settings.AutoRemoveKeepUnclearCheckBox.IsChecked ?? true; settings.AutoRemoveKeepUnclear = Settings.AutoRemoveKeepUnclearCheckBox.IsChecked ?? true;
settings.WriteAppLogFile = Settings.WriteAppLogCheckBox.IsChecked ?? true; settings.WriteAppLog = Settings.WriteAppLogCheckBox.IsChecked ?? true;
settings.WriteFreeBidsLogFile = Settings.WriteFreeBidsLogCheckBox.IsChecked ?? true; settings.WriteFreeBidsLog = Settings.WriteFreeBidsLogCheckBox.IsChecked ?? true;
settings.WriteAuctionDossiers = Settings.WriteDossiersCheckBox.IsChecked ?? true; settings.RecordAuctions = Settings.WriteDossiersCheckBox.IsChecked ?? true;
settings.DossierIncludeRawPolls = Settings.RawPollsCheckBox.IsChecked ?? true; settings.RecordPolls = Settings.RawPollsCheckBox.IsChecked ?? true;
if (int.TryParse(Settings.LogRetentionTextBox.Text?.Trim(), out var retention) && retention >= 0) if (int.TryParse(Settings.LogRetentionTextBox.Text?.Trim(), out var retention) && retention >= 0)
settings.LogRetentionDays = retention; settings.LogRetentionDays = retention;
var previousDb = AppPaths.DatabaseFile; settings.DatabaseFolder = NormalizeFolderChoice(Settings.DatabaseFolderTextBox.Text, AppPaths.DatabaseFolder, settings.DatabaseFolder);
settings.DatabasePath = NormalizeFolderChoice(Settings.DatabaseFileTextBox.Text, AppPaths.DatabaseFile, settings.DatabasePath);
if (!string.Equals(previousDb, settings.DatabasePath.Length > 0 ? settings.DatabasePath : AppPaths.DatabaseFile, StringComparison.OrdinalIgnoreCase))
Log("[DATI] Percorso del database cambiato: vale dal prossimo avvio", LogLevel.Warning);
settings.BankrollManagerEnabled = Settings.BankrollEnabledCheckBox.IsChecked ?? true; settings.BankrollManagerEnabled = Settings.BankrollEnabledCheckBox.IsChecked ?? true;
settings.MaxBidsPerAuction = ReadBounded(Settings.MaxBidsPerAuctionTextBox.Text, settings.MaxBidsPerAuction, 0, 100000, "puntate massime per asta", ""); settings.MaxBidsPerAuction = ReadBounded(Settings.MaxBidsPerAuctionTextBox.Text, settings.MaxBidsPerAuction, 0, 100000, "puntate massime per asta", "");
@@ -469,7 +448,7 @@ namespace AutoBidder
Utilities.SettingsManager.Save(settings); Utilities.SettingsManager.Save(settings);
ApplyDataFolderSettings(settings, previousData, previousStats); ApplyDatabaseFolderSetting(settings, previousDatabaseFolder);
// Il limite di ritmo si applica a caldo, senza ricreare il trasporto. // Il limite di ritmo si applica a caldo, senza ricreare il trasporto.
_auctionMonitor.ApplyTransportSettings(settings); _auctionMonitor.ApplyTransportSettings(settings);
-25
View File
@@ -456,31 +456,6 @@ namespace AutoBidder
} }
} }
private void ExportAuctionButton_Click(object sender, RoutedEventArgs e)
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta dalla griglia", "Nessuna Selezione", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
try
{
MessageBox.Show(
$"Esportazione singola asta:\n\n{_selectedAuction.Name}\n(ID: {_selectedAuction.AuctionId})\n\nFunzionalit in sviluppo.\nUsa 'Esporta' dalla toolbar per esportare tutte le aste.",
"Export Asta",
MessageBoxButton.OK,
MessageBoxImage.Information);
Log($"[INFO] Richiesto export singolo per asta: {_selectedAuction.Name} (funzionalit in sviluppo)", LogLevel.Info);
}
catch (Exception ex)
{
Log($"[ERRORE] Export asta: {ex.Message}", LogLevel.Error);
MessageBox.Show($"Errore: {ex.Message}", "Errore", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async void RefreshProductInfoButton_Click(object sender, RoutedEventArgs e) private async void RefreshProductInfoButton_Click(object sender, RoutedEventArgs e)
{ {
try try
+1 -39
View File
@@ -39,15 +39,6 @@ namespace AutoBidder
RefreshFreeBidsPanel(); RefreshFreeBidsPanel();
} }
private void TabEsporta_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(Export);
// Il conteggio si rifà all'apertura: nel frattempo possono essersi concluse
// altre aste, e un numero vecchio farebbe scegliere i filtri al buio.
Export.RefreshPreview();
}
private void TabDatiStatistici_Checked(object sender, RoutedEventArgs e) private void TabDatiStatistici_Checked(object sender, RoutedEventArgs e)
{ {
ShowPanel(StatisticsPanel); ShowPanel(StatisticsPanel);
@@ -80,7 +71,7 @@ namespace AutoBidder
{ {
// Prevent NullReferenceException during initialization // Prevent NullReferenceException during initialization
if (AuctionMonitor == null || Browser == null || StatisticsPanel == null || if (AuctionMonitor == null || Browser == null || StatisticsPanel == null ||
Settings == null || FreeBids == null || Products == null || Export == null || Settings == null || FreeBids == null || Products == null ||
Learning == null) Learning == null)
return; return;
@@ -90,7 +81,6 @@ namespace AutoBidder
Products.Visibility = Visibility.Collapsed; Products.Visibility = Visibility.Collapsed;
FreeBids.Visibility = Visibility.Collapsed; FreeBids.Visibility = Visibility.Collapsed;
StatisticsPanel.Visibility = Visibility.Collapsed; StatisticsPanel.Visibility = Visibility.Collapsed;
Export.Visibility = Visibility.Collapsed;
Settings.Visibility = Visibility.Collapsed; Settings.Visibility = Visibility.Collapsed;
Learning.Visibility = Visibility.Collapsed; Learning.Visibility = Visibility.Collapsed;
@@ -116,29 +106,6 @@ namespace AutoBidder
StopButton_Click(sender, e); StopButton_Click(sender, e);
} }
private void AuctionMonitor_ExportClicked(object sender, RoutedEventArgs e)
{
// Chiama il metodo di export esistente
try
{
// Esporta tutte le aste monitorate
var auctions = _auctionMonitor.GetAuctions();
if (auctions.Count == 0)
{
System.Windows.MessageBox.Show("Nessuna asta da esportare", "Export", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Information);
return;
}
// TODO: Implementare dialog export con scelta formato
System.Windows.MessageBox.Show($"Export di {auctions.Count} aste.\n\nFunzionalit in sviluppo.\nUsa le impostazioni nella scheda Impostazioni per configurare l'export.", "Export Aste", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Information);
Log($"[INFO] Richiesto export di {auctions.Count} aste (funzionalit in sviluppo)", Utilities.LogLevel.Info);
}
catch (System.Exception ex)
{
Log($"[ERRORE] Export: {ex.Message}", Utilities.LogLevel.Error);
}
}
private void AuctionMonitor_AddUrlClicked(object sender, RoutedEventArgs e) private void AuctionMonitor_AddUrlClicked(object sender, RoutedEventArgs e)
{ {
AddUrlButton_Click(sender, e); AddUrlButton_Click(sender, e);
@@ -207,11 +174,6 @@ namespace AutoBidder
OpenAuctionExternalButton_Click(sender, e); OpenAuctionExternalButton_Click(sender, e);
} }
private void AuctionMonitor_ExportAuctionClicked(object sender, RoutedEventArgs e)
{
ExportAuctionButton_Click(sender, e);
}
private void AuctionMonitor_ResetSettingsClicked(object sender, RoutedEventArgs e) private void AuctionMonitor_ResetSettingsClicked(object sender, RoutedEventArgs e)
{ {
ResetSettingsButton_Click(sender, e); ResetSettingsButton_Click(sender, e);
+131 -147
View File
@@ -2,121 +2,29 @@ using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Windows; using System.Windows;
using AutoBidder.Data;
using AutoBidder.Utilities; using AutoBidder.Utilities;
namespace AutoBidder namespace AutoBidder
{ {
/// <summary> /// <summary>
/// Cartelle dei dati, misura dell'anticipo ed esportazione dello storico. /// La cartella dei database (con la migrazione quando cambia) e le esportazioni dello
/// storico, che chiedono sempre dove salvare.
/// </summary> /// </summary>
public partial class MainWindow public partial class MainWindow
{ {
// ── Anticipo: misura e consiglio ───────────────────────────────── // ── Cartella dei database ────────────────────────────────────────
private void RefreshBidLeadSummary() /// <summary>Il campo mostra sempre il percorso in uso: selezionabile, copiabile.</summary>
{
try
{
var s = BidLeadStats.Summarize();
if (!s.HasData)
{
Settings.SetBidLeadSummary(
"Nessuna puntata registrata finora. Le misure si raccolgono da sole a ogni puntata.");
return;
}
Settings.SetBidLeadSummary(
$"{s.Count} puntate misurate — {s.Successes} riuscite ({s.SuccessRate:F0}%), {s.TooLate} tardive.\n" +
$"Arrivano in media {s.AverageActualMs:F0} ms prima della scadenza " +
$"(mediana {s.MedianActualMs:F0} ms, caso peggiore {s.MinActualMs:F0} ms).\n" +
$"Ping medio {s.AveragePingMs:F0} ms, anticipo impostato di norma {s.TypicalConfiguredMs} ms.");
}
catch { }
}
private void Settings_ComputeBidLeadAdviceClicked(object sender, RoutedEventArgs e)
{
try
{
var settings = SettingsManager.Load();
var current = settings.DefaultBidBeforeDeadlineMs;
var advice = BidLeadStats.Suggest(current, settings.BidLeadMinSamples);
RefreshBidLeadSummary();
if (!advice.HasAdvice)
{
MessageBox.Show(this, advice.Reason, "Anticipo puntata",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
// Il consiglio non si applica mai da solo: cambia il parametro che decide
// se una puntata arriva o no, e la decisione resta dell'utente.
var answer = MessageBox.Show(this,
$"{advice.Reason}\n\nVuoi impostare l'anticipo predefinito a {advice.SuggestedLeadMs} ms?\n\n" +
"Le aste già nel monitor mantengono il proprio valore.",
"Anticipo puntata", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
settings.DefaultBidBeforeDeadlineMs = advice.SuggestedLeadMs;
SettingsManager.Save(settings);
DefaultBidBeforeDeadlineMs.Text = advice.SuggestedLeadMs.ToString();
Log($"[ANTICIPO] Anticipo predefinito portato a {advice.SuggestedLeadMs} ms", LogLevel.Success);
}
catch (Exception ex)
{
Log($"[ANTICIPO] {ex.Message}", LogLevel.Error);
}
}
private void Settings_ClearBidLeadStatsClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Vuoi azzerare tutte le misure sull'anticipo?\n\n" +
"Serviranno di nuovo diverse puntate prima di poter dare un consiglio.",
"Misure anticipo", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
BidLeadStats.Clear();
RefreshBidLeadSummary();
Log("[ANTICIPO] Misure azzerate", LogLevel.Info);
}
// ── Cartelle dei dati ────────────────────────────────────────────
/// <summary>
/// Riempie i campi dei percorsi con quelli davvero in uso.
///
/// <para>I campi non restano mai vuoti: un campo vuoto costringeva a cercare altrove
/// dove finiscono i file, ed è il motivo per cui sotto c'era una riga che ripeteva i
/// percorsi. Scriverli direttamente nel campo toglie la ripetizione e rende il valore
/// selezionabile e copiabile. Chi vuole tornare al predefinito lo svuota: al
/// salvataggio si ricompila da sé.</para>
/// </summary>
private void RefreshDataFolderFields() private void RefreshDataFolderFields()
{ {
try try { Settings.DatabaseFolderTextBox.Text = AppPaths.DatabaseFolder; }
{
Settings.DataFolderTextBox.Text = AppPaths.DataFolder;
Settings.StatsFolderTextBox.Text = AppPaths.StatsFolder;
Settings.LogFolderTextBox.Text = AppPaths.LogFolder;
}
catch { } catch { }
} }
/// <summary> /// <summary>
/// Traduce quello che c'è nel campo in ciò che va salvato. /// Traduce quello che c'è nel campo in ciò che va salvato: vuoto = predefinito,
/// /// uguale al predefinito = vuoto, un percorso davvero diverso = quel percorso.
/// <para>Il campo mostra sempre un percorso pieno, ma nelle impostazioni "vuoto"
/// ha un significato che non si vuole perdere: <i>usa il predefinito</i>. Se il
/// testo coincide con il predefinito corrente si salva vuoto — così spostando la
/// cartella dati le sottocartelle la seguono — e se il campo è stato svuotato a mano
/// si torna al predefinito. Solo un percorso davvero diverso viene registrato.</para>
/// </summary> /// </summary>
private static string NormalizeFolderChoice(string? typed, string resolvedDefault, string previouslySaved) private static string NormalizeFolderChoice(string? typed, string resolvedDefault, string previouslySaved)
{ {
@@ -124,7 +32,6 @@ namespace AutoBidder
if (text.Length == 0) return ""; if (text.Length == 0) return "";
// Uguale a ciò che è in uso, e in uso c'era il predefinito: resta predefinito.
if (string.Equals(text, resolvedDefault, StringComparison.OrdinalIgnoreCase) && if (string.Equals(text, resolvedDefault, StringComparison.OrdinalIgnoreCase) &&
string.IsNullOrWhiteSpace(previouslySaved)) string.IsNullOrWhiteSpace(previouslySaved))
return ""; return "";
@@ -132,36 +39,17 @@ namespace AutoBidder
return text; return text;
} }
private void Settings_BrowseDataFolderClicked(object sender, RoutedEventArgs e) private void Settings_BrowseDatabaseFolderClicked(object sender, RoutedEventArgs e)
{ {
var chosen = PickFolder("Scegli la cartella dei dati d'esercizio", AppPaths.DataFolder); var chosen = PickFolder("Scegli la cartella dei database (non una cartella sincronizzata)", AppPaths.DatabaseFolder);
if (chosen != null) Settings.DataFolderTextBox.Text = chosen; if (chosen != null) Settings.DatabaseFolderTextBox.Text = chosen;
} }
private void Settings_BrowseStatsFolderClicked(object sender, RoutedEventArgs e) private void Settings_OpenDatabaseFolderClicked(object sender, RoutedEventArgs e)
{ => OpenFolder(AppPaths.DatabaseFolder);
var chosen = PickFolder("Scegli la cartella delle statistiche", AppPaths.StatsFolder);
if (chosen != null) Settings.StatsFolderTextBox.Text = chosen;
}
private void Settings_BrowseLogFolderClicked(object sender, RoutedEventArgs e)
{
var chosen = PickFolder("Scegli la cartella dei registri", AppPaths.LogFolder);
if (chosen != null) Settings.LogFolderTextBox.Text = chosen;
}
private void Settings_OpenDataFolderClicked(object sender, RoutedEventArgs e)
=> OpenFolder(AppPaths.DataFolder);
private void Settings_OpenStatsFolderClicked(object sender, RoutedEventArgs e)
=> OpenFolder(AppPaths.StatsFolder);
private void Settings_OpenLogFolderClicked(object sender, RoutedEventArgs e)
=> OpenFolder(AppPaths.LogFolder);
/// <summary> /// <summary>
/// Selettore di cartella. Usa quello di WinForms perché WPF non ne offre uno: /// Selettore di cartella. Usa quello di WinForms perché WPF non ne offre uno.
/// l'alternativa sarebbe una finestra scritta a mano, molto peggio.
/// </summary> /// </summary>
private static string? PickFolder(string description, string startFrom) private static string? PickFolder(string description, string startFrom)
{ {
@@ -199,38 +87,134 @@ namespace AutoBidder
} }
/// <summary> /// <summary>
/// Applica i percorsi dopo un salvataggio. Se sono cambiati serve un riavvio: gli /// Dopo un salvataggio con la cartella dei database cambiata: chiede se spostare i
/// archivi hanno già in memoria i dati letti dalla posizione precedente, e /// file nella nuova posizione, li sposta a database chiusi, e riavvia. Senza
/// spostarli a caldo rischierebbe di mescolarli. /// riavvio gli archivi terrebbero in memoria i dati letti dalla posizione vecchia.
/// </summary> /// </summary>
private void ApplyDataFolderSettings(AppSettings settings, string previousData, string previousStats) private void ApplyDatabaseFolderSetting(AppSettings settings, string previousFolder)
{ {
AppPaths.Configure(settings.DataFolder, settings.StatsFolder, settings.LogFolder, settings.DatabasePath); var newFolder = settings.DatabaseFolder.Length > 0 ? settings.DatabaseFolder : AppPaths.DefaultDatabaseFolder;
AppPaths.EnsureFolders();
// I campi tornano a mostrare i percorsi effettivi: chi ne ha svuotato uno deve try { newFolder = Path.GetFullPath(newFolder); } catch { }
// vedere subito qual è il predefinito che ha appena scelto.
RefreshDataFolderFields();
if (previousData == AppPaths.DataFolder && previousStats == AppPaths.StatsFolder) return; if (string.Equals(previousFolder.TrimEnd('\\'), newFolder.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
{
RefreshDataFolderFields();
return;
}
MessageBox.Show(this, var answer = MessageBox.Show(this,
"Le cartelle dei dati sono cambiate.\n\n" + $"La cartella dei database cambia da\n{previousFolder}\na\n{newFolder}\n\n" +
$"Dati: {AppPaths.DataFolder}\nStatistiche: {AppPaths.StatsFolder}\n\n" + "Vuoi spostare i database esistenti nella nuova cartella?\n\n" +
"Riavvia l'applicazione perché vengano usate davvero. I file già scritti " + "Sì: sposto i file e riavvio l'applicazione.\n" +
"restano dove sono: puoi spostarli a mano nella nuova posizione.", "No: la nuova cartella parte vuota (i vecchi file restano dove sono) e riavvio.\n" +
"Cartelle dei dati", MessageBoxButton.OK, MessageBoxImage.Information); "Annulla: torno alla cartella di prima.",
"Cartella dei database", MessageBoxButton.YesNoCancel, MessageBoxImage.Question);
Log($"[DATI] Nuovi percorsi impostati (riavvio necessario)", LogLevel.Warning); if (answer == MessageBoxResult.Cancel)
{
settings.DatabaseFolder = string.Equals(previousFolder, AppPaths.DefaultDatabaseFolder, StringComparison.OrdinalIgnoreCase) ? "" : previousFolder;
SettingsManager.Save(settings);
RefreshDataFolderFields();
return;
}
try
{
if (answer == MessageBoxResult.Yes)
{
// Prima si chiudono: un file SQLite aperto non si sposta, e il giornale
// WAL va con lui.
AuctionDatabase.CloseInstance();
OperationalDatabase.CloseInstance();
Directory.CreateDirectory(newFolder);
var moved = 0;
foreach (var db in new[] { AppPaths.DatabaseFile, AppPaths.OperationalDatabaseFile })
{
foreach (var f in AppPaths.DatabaseFiles(db))
{
var target = Path.Combine(newFolder, Path.GetFileName(f));
if (File.Exists(target)) File.Delete(target);
File.Move(f, target);
moved++;
}
}
Log($"[DATI] Database spostati in {newFolder} ({moved} file). Riavvio.", LogLevel.Warning);
}
else
{
Log($"[DATI] Nuova cartella dei database: {newFolder} (vuota). Riavvio.", LogLevel.Warning);
}
}
catch (Exception ex)
{
MessageBox.Show(this, $"Spostamento non riuscito: {ex.Message}\n\nLa cartella resta quella di prima.",
"Cartella dei database", MessageBoxButton.OK, MessageBoxImage.Error);
settings.DatabaseFolder = string.Equals(previousFolder, AppPaths.DefaultDatabaseFolder, StringComparison.OrdinalIgnoreCase) ? "" : previousFolder;
SettingsManager.Save(settings);
RefreshDataFolderFields();
return;
}
RestartApplication();
} }
// ── Esportazione dello storico ─────────────────────────────────── /// <summary>Riavvia l'applicazione: lo stesso eseguibile, e questa istanza si chiude.</summary>
private void RestartApplication()
{
try
{
var exe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exe))
Process.Start(new ProcessStartInfo { FileName = exe, UseShellExecute = true });
}
catch (Exception ex)
{
Log($"[APP] Riavvio non riuscito: {ex.Message}. Riapri l'applicazione a mano.", LogLevel.Error);
}
Application.Current.Shutdown();
}
// ── Esportazione dello storico: chiede sempre dove ───────────────
private void ExportStatsCsvButton_Click(object sender, RoutedEventArgs e) private void ExportStatsCsvButton_Click(object sender, RoutedEventArgs e)
=> RunExport(StatsExporter.ExportCsv(), "CSV"); {
var path = AskWhereToSave("Esporta lo storico in CSV", $"storico-aste-{DateTime.Now:yyyyMMdd-HHmm}.csv", "CSV (foglio di calcolo)|*.csv");
if (path == null) return;
RunExport(StatsExporter.ExportCsv(path), "CSV");
}
private void ExportStatsJsonButton_Click(object sender, RoutedEventArgs e) private void ExportStatsJsonButton_Click(object sender, RoutedEventArgs e)
=> RunExport(StatsExporter.ExportJson(), "JSON"); {
var path = AskWhereToSave("Esporta lo storico in JSON", $"storico-aste-{DateTime.Now:yyyyMMdd-HHmm}.json", "JSON|*.json");
if (path == null) return;
RunExport(StatsExporter.ExportJson(path), "JSON");
}
/// <summary>La finestra «Salva con nome»: nessun percorso predefinito, decide l'utente.</summary>
private string? AskWhereToSave(string title, string fileName, string filter)
{
try
{
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = title,
FileName = fileName,
Filter = filter,
OverwritePrompt = true,
AddExtension = true
};
return dialog.ShowDialog(this) == true ? dialog.FileName : null;
}
catch (Exception ex)
{
Log($"[ESPORTA] Finestra di salvataggio non disponibile: {ex.Message}", LogLevel.Error);
return null;
}
}
private void RunExport(StatsExporter.Result result, string format) private void RunExport(StatsExporter.Result result, string format)
{ {
@@ -250,7 +234,7 @@ namespace AutoBidder
if (answer == MessageBoxResult.Yes) if (answer == MessageBoxResult.Yes)
{ {
OpenFolder(Path.GetDirectoryName(result.Path) ?? AppPaths.ExportFolder); OpenFolder(Path.GetDirectoryName(result.Path) ?? AppPaths.ConfigRoot);
} }
} }
} }
-115
View File
@@ -1,115 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Windows;
using AutoBidder.Utilities;
namespace AutoBidder
{
/// <summary>
/// Scheda Esporta: raccoglie in un unico file le aste che rispondono ai filtri.
///
/// <para>Qui c'è solo il collegamento fra il pannello e
/// <see cref="AuctionExporter"/>. La scrittura può durare qualche secondo — con gli
/// eventi inclusi si leggono decine di dossier — quindi gira su un thread di lavoro:
/// bloccare l'interfaccia mentre il motore sta seguendo un'asta non è accettabile.</para>
/// </summary>
public partial class MainWindow
{
private async void Export_ExportRequested(object sender, RoutedEventArgs e)
{
try
{
var filter = Export.BuildFilter();
Log("[ESPORTA] Esportazione in corso…", LogLevel.Info);
var result = await System.Threading.Tasks.Task
.Run(() => AuctionExporter.Export(filter))
.ConfigureAwait(true);
Export.ShowResult(result);
Log(result.Success
? $"[ESPORTA] {result.Message} → {result.Path}"
: $"[ESPORTA] {result.Message}",
result.Success ? LogLevel.Success : LogLevel.Warning);
}
catch (Exception ex)
{
Log($"[ESPORTA] Esportazione non riuscita: {ex.Message}", LogLevel.Error);
}
}
private void Export_OpenFolderClicked(object sender, RoutedEventArgs e)
{
try
{
Directory.CreateDirectory(AppPaths.ExportFolder);
OpenInExplorer(AppPaths.ExportFolder);
}
catch (Exception ex)
{
Log($"[ESPORTA] Apertura cartella non riuscita: {ex.Message}", LogLevel.Error);
}
}
private void Export_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;
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return;
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
Log($"[ESPORTA] Apertura file non riuscita: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Apre una cartella in Esplora risorse. Sta qui perché la usano più schede: i
/// pulsanti "apri" dei percorsi, le esportazioni e i registri.
/// </summary>
internal void OpenInExplorer(string folder)
{
try
{
if (string.IsNullOrWhiteSpace(folder)) return;
Directory.CreateDirectory(folder);
Process.Start(new ProcessStartInfo
{
FileName = folder,
UseShellExecute = true
});
}
catch (Exception ex)
{
Log($"[CARTELLE] Impossibile aprire {folder}: {ex.Message}", LogLevel.Error);
}
}
}
}
+67
View File
@@ -217,6 +217,73 @@ namespace AutoBidder
Log("[PRODOTTI] Elenco svuotato", LogLevel.Info); Log("[PRODOTTI] Elenco svuotato", LogLevel.Info);
} }
private void Products_ClearAllClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Pulizia completa dei prodotti:\n\n" +
"• tolgo tutti i prodotti dall'elenco, con i loro limiti\n" +
"• azzero le statistiche per prodotto (aste concluse, prezzi tipici, consigli)\n\n" +
"Lo storico delle aste non viene toccato. Le aste già nel monitor restano dove sono.\n\nProcedo?",
"Prodotti", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
WatchedProductsStore.Clear();
ProductStatsStore.ClearStatistics();
LoadProducts();
MarkWatchedProducts(_catalogAuctions);
Log("[PRODOTTI] Pulizia completa: elenco e statistiche per prodotto azzerati", LogLevel.Warning);
}
private void Products_UnwatchAllClicked(object sender, RoutedEventArgs e)
{
var accese = _productViewModels.Count(p => p.IsWatched);
if (accese == 0)
{
MessageBox.Show(this, "Nessun prodotto ha la stellina accesa.",
"Prodotti", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Spengo la stellina di {accese} prodotti?\n\n" +
"Nessun prodotto verrà più cercato in automatico. Restano tutti in elenco con i loro limiti.",
"Prodotti", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
var n = WatchedProductsStore.UnwatchAll();
LoadProducts();
MarkWatchedProducts(_catalogAuctions);
Log($"[PRODOTTI] Stelline spente su {n} prodotti", LogLevel.Info);
}
private void Products_RemoveUnwatchedClicked(object sender, RoutedEventArgs e)
{
var spente = _productViewModels.Count(p => !p.IsWatched);
if (spente == 0)
{
MessageBox.Show(this, "Tutti i prodotti in elenco hanno la stellina accesa: non c'è nulla da togliere.",
"Prodotti", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Tolgo dall'elenco i {spente} prodotti senza stellina?\n\n" +
"Si perdono i limiti impostati su quei prodotti. Le statistiche per prodotto restano.",
"Prodotti", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
var n = WatchedProductsStore.RemoveUnwatched();
LoadProducts();
MarkWatchedProducts(_catalogAuctions);
Log($"[PRODOTTI] Tolti {n} prodotti senza stellina", LogLevel.Info);
}
/// <summary> /// <summary>
/// Scrive nei limiti del prodotto i valori consigliati dallo storico — minimo e /// Scrive nei limiti del prodotto i valori consigliati dallo storico — minimo e
/// massimo insieme, perché è la coppia a definire la finestra in cui ha senso /// massimo insieme, perché è la coppia a definire la finestra in cui ha senso
+89 -35
View File
@@ -1,63 +1,117 @@
using System; using System;
using System.IO; using System.IO;
using System.Windows; using System.Windows;
using AutoBidder.Services; using AutoBidder.Data;
using AutoBidder.Utilities; using AutoBidder.Utilities;
namespace AutoBidder namespace AutoBidder
{ {
/// <summary> /// <summary>
/// Kill-switch dalla barra del monitor e percorso del database dalle impostazioni. /// L'interruttore dell'apprendimento nella barra del monitor, e le due operazioni
/// estreme delle Impostazioni: cancellare tutti i dati tranne la login, e tornare alle
/// impostazioni di fabbrica.
/// </summary> /// </summary>
public partial class MainWindow public partial class MainWindow
{ {
private void AuctionMonitor_KillSwitchChanged(object sender, RoutedEventArgs e) // ── Apprendimento acceso / spento ────────────────────────────────
private bool _syncingLearningToggle;
private void AuctionMonitor_LearningToggled(object sender, RoutedEventArgs e)
{ {
var on = AuctionMonitor.KillSwitchToggle.IsChecked == true; if (_syncingLearningToggle) return;
RiskManager.SetKillSwitch(on);
var on = AuctionMonitor.LearningToggle.IsChecked == true;
var settings = SettingsManager.Load();
if (settings.LearningEnabled == on) return;
settings.LearningEnabled = on;
SettingsManager.Save(settings);
Log(on Log(on
? "[RISCHIO] Kill-switch ATTIVO: nessuna puntata dal vivo finché resta acceso (file KILL_SWITCH)" ? "[APPRENDIMENTO] Acceso: il motore decide con valore atteso, regime, duello e anticipo adattivo"
: "[RISCHIO] Kill-switch tolto: le puntate dal vivo possono ripartire", : "[APPRENDIMENTO] SPENTO: si punta solo entro i limiti (prezzo, puntate, budget, fascia oraria) con l'anticipo fisso",
on ? LogLevel.Warning : LogLevel.Success); on ? LogLevel.Success : LogLevel.Warning);
try { Settings.RefreshRiskStatus(); } catch { } try { Learning.Refresh(); } catch { }
} }
/// <summary>Il kill-switch cambiato dalle Impostazioni: l'interruttore in barra deve seguirlo.</summary> /// <summary>Allinea l'interruttore in barra a ciò che dicono le impostazioni.</summary>
private void Settings_KillSwitchChanged(object sender, RoutedEventArgs e) => RefreshKillSwitchToggle(); private void RefreshLearningToggle()
/// <summary>Allinea l'interruttore in barra allo stato vero del file sentinella.</summary>
private void RefreshKillSwitchToggle()
{
try { AuctionMonitor.KillSwitchToggle.IsChecked = RiskManager.KillSwitchActive; } catch { }
}
private void Settings_BrowseDatabaseFileClicked(object sender, RoutedEventArgs e)
{ {
try try
{ {
var current = AppPaths.DatabaseFile; _syncingLearningToggle = true;
var dialog = new Microsoft.Win32.SaveFileDialog AuctionMonitor.LearningToggle.IsChecked = SettingsManager.Load().LearningEnabled;
{ }
Title = "Scegli dove tenere il database", catch { }
FileName = Path.GetFileName(current), finally { _syncingLearningToggle = false; }
InitialDirectory = Path.GetDirectoryName(current), }
Filter = "Database SQLite (*.sqlite)|*.sqlite|Tutti i file|*.*",
OverwritePrompt = false,
CheckPathExists = true
};
if (dialog.ShowDialog(this) == true) // ── Manutenzione ─────────────────────────────────────────────────
Settings.DatabaseFileTextBox.Text = dialog.FileName;
private void Settings_WipeAllDataClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Cancello TUTTI i dati dell'applicazione:\n\n" +
"• i due database (aste, puntate, decisioni, prodotti seguiti, aste nel monitor, modelli appresi, registri)\n" +
"• le copie di sicurezza\n" +
"• la vecchia cartella Dati delle versioni precedenti\n\n" +
"Restano la login (sessione, riferimenti del sito, browser) e le impostazioni.\n" +
"L'operazione non è reversibile e l'applicazione si riavvia subito.\n\nProcedo?",
"Elimina tutti i dati", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
var conferma = MessageBox.Show(this,
"Ultima conferma: cancellare tutto tranne la login?",
"Elimina tutti i dati", MessageBoxButton.YesNo, MessageBoxImage.Stop);
if (conferma != MessageBoxResult.Yes) return;
try { _auctionMonitor.Stop(); } catch { }
var errors = AppDataWipe.WipeAllExceptLogin();
if (errors.Count > 0)
{
MessageBox.Show(this, "Alcuni file non si sono potuti cancellare:\n\n" + string.Join("\n", errors) +
"\n\nL'applicazione si riavvia comunque.", "Elimina tutti i dati", MessageBoxButton.OK, MessageBoxImage.Warning);
}
RestartApplication();
}
private void Settings_ResetSettingsClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
"Riporto tutte le impostazioni ai valori di fabbrica e riavvio l'applicazione.\n\n" +
"I dati (database, prodotti, storico, apprendimento) e la login non vengono toccati.\n\nProcedo?",
"Impostazioni di fabbrica", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
try { _auctionMonitor.Stop(); } catch { }
AppDataWipe.ResetSettings();
RestartApplication();
}
private void Settings_ExportAppLogClicked(object sender, RoutedEventArgs e)
{
var path = AskWhereToSave("Esporta il registro applicativo", $"registro-{DateTime.Now:yyyyMMdd-HHmm}.txt", "Testo|*.txt");
if (path == null) return;
try
{
var n = OperationalDatabase.Instance.ExportLog(OperationalDatabase.AppLog, path);
var m = OperationalDatabase.Instance.ExportLog(OperationalDatabase.FreeBidsLog,
Path.Combine(Path.GetDirectoryName(path)!, Path.GetFileNameWithoutExtension(path) + "-puntate.txt"));
Log($"[REGISTRI] Esportate {n} righe del registro applicativo e {m} del riscatto puntate in {Path.GetDirectoryName(path)}", LogLevel.Success);
OpenFolder(Path.GetDirectoryName(path)!);
} }
catch (Exception ex) catch (Exception ex)
{ {
Log($"[DATI] Scelta del database non riuscita: {ex.Message}", LogLevel.Error); Log($"[REGISTRI] Esportazione non riuscita: {ex.Message}", LogLevel.Error);
} }
} }
private void Settings_OpenDatabaseFolderClicked(object sender, RoutedEventArgs e)
=> OpenFolder(Path.GetDirectoryName(AppPaths.DatabaseFile) ?? AppPaths.Root);
} }
} }
+102 -2
View File
@@ -3,6 +3,7 @@ using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using AutoBidder.Models;
using AutoBidder.Utilities; using AutoBidder.Utilities;
namespace AutoBidder namespace AutoBidder
@@ -48,12 +49,12 @@ namespace AutoBidder
LoadStatistics(); LoadStatistics();
} }
/// <summary>Apre la cartella contenente il file storico JSON.</summary> /// <summary>Apre la cartella dei database.</summary>
private void OpenStatsFolderButton_Click(object sender, RoutedEventArgs e) private void OpenStatsFolderButton_Click(object sender, RoutedEventArgs e)
{ {
try try
{ {
var folder = AppPaths.StatsFolder; var folder = AppPaths.DatabaseFolder;
if (!string.IsNullOrEmpty(folder) && System.IO.Directory.Exists(folder)) if (!string.IsNullOrEmpty(folder) && System.IO.Directory.Exists(folder))
{ {
Process.Start(new ProcessStartInfo Process.Start(new ProcessStartInfo
@@ -69,6 +70,105 @@ namespace AutoBidder
} }
} }
// ── Selezione multipla ───────────────────────────────────────────
private void SelectAllStatsButton_Click(object sender, RoutedEventArgs e)
{
StatsDetailGrid.Focus();
StatsDetailGrid.SelectAll();
}
private void DeleteSelectedStatsButton_Click(object sender, RoutedEventArgs e)
=> DeleteSelectedHistoryRows();
private void StatsDetailGrid_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != System.Windows.Input.Key.Delete) return;
e.Handled = true;
DeleteSelectedHistoryRows();
}
/// <summary>
/// Toglie dallo storico le righe selezionate. Prima la copia di sicurezza, poi la
/// cancellazione, poi le statistiche per prodotto ricalcolate da ciò che resta.
/// </summary>
private void DeleteSelectedHistoryRows()
{
var selected = StatsDetailGrid.SelectedItems.OfType<CompletedAuctionRecord>().ToList();
if (selected.Count == 0)
{
MessageBox.Show(this, "Seleziona prima una o più righe dello storico (Ctrl+clic, Maiusc+clic, o «Seleziona tutte»).",
"Storico", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
(selected.Count == 1
? $"Elimino dallo storico \"{selected[0].Name}\"?"
: $"Elimino dallo storico {selected.Count} aste?") +
"\n\nCon loro vanno via puntate, interrogazioni, reset e decisioni registrate. " +
"Prima salvo una copia nei backup e poi ricalcolo le statistiche per prodotto.",
"Storico", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
try
{
var backup = CompletedAuctionsStore.BackupNow();
var n = CompletedAuctionsStore.Remove(selected.Select(r => r.AuctionId).ToList());
var ricostruite = ProductStatsStore.RebuildFromHistory();
LoadStatistics();
LoadProducts();
Log($"[STORICO] Eliminate {n} aste; statistiche per prodotto ricalcolate su {ricostruite} aste rimaste. Copia di sicurezza in {backup}",
LogLevel.Success);
}
catch (Exception ex)
{
Log($"[STORICO] Eliminazione non riuscita: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Svuota tutto lo storico in un colpo: osservazioni, decisioni, misure, contabilità
/// del rischio e statistiche per prodotto. Prodotti seguiti e apprendimento restano.
/// </summary>
private void WipeHistoryButton_Click(object sender, RoutedEventArgs e)
{
var count = CompletedAuctionsStore.LoadAll().Count;
if (count == 0)
{
MessageBox.Show(this, "Lo storico è già vuoto.", "Storico", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Svuoto completamente lo storico ({count} aste)?\n\n" +
"Vanno via tutte le aste concluse con puntate, interrogazioni, reset, decisioni, misure di rete " +
"e contabilità del rischio, e le statistiche per prodotto tornano a zero.\n" +
"Prima salvo una copia nei backup. Prodotti seguiti, aste nel monitor e apprendimento restano.\n\nProcedo?",
"Storico", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
try
{
var backup = CompletedAuctionsStore.BackupNow();
CompletedAuctionsStore.Clear();
ProductStatsStore.ClearStatistics();
LoadStatistics();
LoadProducts();
Log($"[STORICO] Svuotato: {count} aste tolte. Copia di sicurezza in {backup}", LogLevel.Warning);
}
catch (Exception ex)
{
Log($"[STORICO] Svuotamento non riuscito: {ex.Message}", LogLevel.Error);
}
}
/// <summary> /// <summary>
/// Azzera le statistiche registrate: il dialogo dice cosa c'è e quanto pesa, e /// Azzera le statistiche registrate: il dialogo dice cosa c'è e quanto pesa, e
/// l'utente sceglie voce per voce. Vedi <see cref="StatsWipe"/>. /// l'utente sceglie voce per voce. Vedi <see cref="StatsWipe"/>.
+43 -446
View File
@@ -1,64 +1,46 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading; using System.Threading;
using AutoBidder.Utilities; using AutoBidder.Utilities;
namespace AutoBidder.Data namespace AutoBidder.Data
{ {
/// <summary> /// <summary>
/// L'archivio dell'applicazione: un solo file SQLite con tutto ciò che si osserva e /// L'archivio delle osservazioni: un solo file SQLite con tutto ciò che si osserva e
/// si decide — aste, puntate, interrogazioni, reset, nostre puntate, decisioni del /// si decide — aste, puntate, interrogazioni, reset, nostre puntate, decisioni del
/// motore, misure di rete, puntatori, sessioni. /// motore, misure di rete, puntatori, sessioni, contabilità del rischio.
/// ///
/// <para><b>Perché un database.</b> Prima c'erano un file JSON per asta (i dossier), /// <para><b>Perché un database.</b> Prima c'erano un file JSON per asta (i dossier),
/// un file JSON con lo storico compatto, uno con le statistiche per prodotto, uno con /// un file JSON con lo storico compatto, uno con le statistiche per prodotto: copie
/// le misure dell'anticipo: quattro copie parziali degli stessi fatti, da tenere /// parziali degli stessi fatti, da tenere allineate, e nessuna interrogabile. Qui ogni
/// allineate, e nessuna interrogabile. Qui ogni fatto sta in una riga sola, con un /// fatto sta in una riga sola, con un indice, e una domanda come «quante puntate
/// indice, e una domanda come «quante puntate manuali fra le 10 e le 13 sui buoni da /// manuali fra le 10 e le 13 sui buoni da 10 € sono rimaste senza risposta?» è una
/// 10 € sono rimaste senza risposta?» è una riga di SQL invece di una lettura di otto /// riga di SQL invece di una lettura di otto gigabyte.</para>
/// gigabyte.</para>
/// ///
/// <para><b>Come scrive.</b> Mai sul thread che segue le aste. Ogni scrittura finisce /// <para>Il motore (coda di scrittura, letture in WAL) è in <see cref="SqliteDatabase"/>.
/// in una coda e un thread dedicato la svuota a blocchi, in una transazione ogni /// I dati d'esercizio e i registri stanno nell'altro file, <see cref="OperationalDatabase"/>.</para>
/// qualche centinaio di righe: SQLite fa migliaia di inserimenti al secondo così, e
/// nessuno sul percorso della puntata aspetta il disco. Chi deve leggere ciò che ha
/// appena scritto chiama <see cref="Flush"/>.</para>
///
/// <para><b>Come legge.</b> Su una seconda connessione, sotto un lucchetto. Con il
/// giornale in modalità WAL le letture non bloccano la scrittura e viceversa.</para>
///
/// <para><b>Dove sta.</b> In <c>%LocalAppData%\AutoBidder\Database</c>: non in
/// Documenti, che può essere sincronizzata da OneDrive — un file SQLite sotto
/// sincronizzazione è il modo classico di corromperlo.</para>
/// </summary> /// </summary>
public sealed class AuctionDatabase : IDisposable public sealed class AuctionDatabase : SqliteDatabase
{ {
public const int SchemaVersion = 1; public const int SchemaVersion = 1;
private static readonly object InstanceSync = new(); private static readonly object InstanceSync = new();
private static AuctionDatabase? _instance; private static AuctionDatabase? _instance;
/// <summary>Righe di registro (errori di scrittura, migrazioni): chi si aggancia le scrive dove vuole.</summary> /// <summary>L'archivio dell'applicazione, aperto al primo uso in <see cref="AppPaths.DatabaseFile"/>.</summary>
public static event Action<string>? OnLog;
/// <summary>L'archivio dell'applicazione, aperto al primo uso nel percorso di <see cref="AppPaths.DatabaseFile"/>.</summary>
public static AuctionDatabase Instance public static AuctionDatabase Instance
{ {
get get
{ {
lock (InstanceSync) lock (InstanceSync)
{ {
if (_instance == null || _instance._disposed) if (_instance == null || _instance.IsDisposed)
_instance = Open(AppPaths.DatabaseFile); _instance = Open(AppPaths.DatabaseFile);
return _instance; return _instance;
} }
} }
} }
/// <summary>Chiude l'archivio dell'applicazione svuotando la coda. Da chiamare all'uscita.</summary> /// <summary>Chiude l'archivio dell'applicazione svuotando la coda. All'uscita, o prima di spostarlo.</summary>
public static void CloseInstance() public static void CloseInstance()
{ {
lock (InstanceSync) lock (InstanceSync)
@@ -68,118 +50,48 @@ namespace AutoBidder.Data
} }
} }
private readonly SqliteConnection _writer; private long _nextDecisionId;
private readonly SqliteConnection _reader; private long _auctionsVersion;
private readonly object _readSync = new();
// ── Coda di scrittura ────────────────────────────────────────────
private abstract class Pending
{
public long Seq;
}
private sealed class Statement : Pending
{
public string Sql = "";
public object?[] Args = Array.Empty<object?>();
}
private sealed class Batch : Pending
{
public List<(string Sql, object?[] Args)> Ops = new();
}
private sealed class Custom : Pending
{
public Action<SqliteConnection> Action = _ => { };
public Exception? Error;
public ManualResetEventSlim Done = new(false);
}
private readonly Queue<Pending> _queue = new();
private readonly object _queueSync = new();
private long _lastEnqueued;
private long _lastCommitted;
private readonly Thread _writerThread;
private volatile bool _disposed;
private volatile bool _stop;
/// <summary>Scritture eseguite dal momento dell'apertura.</summary>
public long WritesCommitted => Interlocked.Read(ref _writesCommitted);
private long _writesCommitted;
/// <summary>Scritture rifiutate da SQLite (registrate nel log).</summary>
public long WritesFailed => Interlocked.Read(ref _writesFailed);
private long _writesFailed;
public int QueueLength { get { lock (_queueSync) return _queue.Count; } }
/// <summary>Cambia a ogni scrittura sulle aste: chi tiene una copia in memoria la confronta con questo.</summary> /// <summary>Cambia a ogni scrittura sulle aste: chi tiene una copia in memoria la confronta con questo.</summary>
public long AuctionsVersion => Interlocked.Read(ref _auctionsVersion); public long AuctionsVersion => Interlocked.Read(ref _auctionsVersion);
private long _auctionsVersion;
public string Path { get; } private AuctionDatabase(string path) : base(path, SchemaV1, SchemaVersion, "AutoBidder.Database") { }
public static string LibraryVersion => SqliteConnection.LibraryVersion; public static AuctionDatabase Open(string path) => new(path);
private long _nextDecisionId; protected override void OnOpened(SqliteConnection writer)
{
// Gli id delle decisioni si assegnano qui, non da SQLite: chi registra una
// decisione deve poterla legare alla puntata senza aspettare il disco.
try { _nextDecisionId = writer.Query("SELECT COALESCE(MAX(decision_id), 0) AS n FROM bot_decisions")[0].Long("n"); }
catch { _nextDecisionId = 0; }
}
protected override void OnStatementExecuted(string sql)
{
if (sql.Contains("auctions", StringComparison.Ordinal)) Interlocked.Increment(ref _auctionsVersion);
}
/// <summary>Il prossimo id per <c>bot_decisions</c>: unico, crescente, senza aspettare la scrittura.</summary> /// <summary>Il prossimo id per <c>bot_decisions</c>: unico, crescente, senza aspettare la scrittura.</summary>
public long NextDecisionId() => Interlocked.Increment(ref _nextDecisionId); public long NextDecisionId() => Interlocked.Increment(ref _nextDecisionId);
private AuctionDatabase(string path) /// <summary>
/// Svuota le tabelle delle osservazioni e delle decisioni. Restano i prodotti
/// (portano il valore reale scelto dall'utente) e le sessioni.
/// </summary>
public void WipeObservations()
{ {
Path = path; EnqueueBatch(new (string, object?[])[]
var folder = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
_writer = new SqliteConnection(path);
Configure(_writer);
Migrate(_writer);
_reader = new SqliteConnection(path);
Configure(_reader);
// Gli id delle decisioni si assegnano qui, non da SQLite: chi registra una
// decisione deve poterla legare alla puntata senza aspettare il disco.
try { _nextDecisionId = _writer.Query("SELECT COALESCE(MAX(decision_id), 0) AS n FROM bot_decisions")[0].Long("n"); }
catch { _nextDecisionId = 0; }
_writerThread = new Thread(WriterLoop)
{ {
Name = "AutoBidder.Database", ("DELETE FROM auctions", Array.Empty<object?>()),
IsBackground = true, ("DELETE FROM bot_decisions", Array.Empty<object?>()),
Priority = ThreadPriority.BelowNormal ("DELETE FROM network_metrics", Array.Empty<object?>()),
}; ("DELETE FROM bidders", Array.Empty<object?>()),
_writerThread.Start(); ("DELETE FROM risk_ledger", Array.Empty<object?>())
} });
Flush();
public static AuctionDatabase Open(string path) => new(path); Vacuum();
private static void Configure(SqliteConnection c)
{
c.Execute("PRAGMA journal_mode=WAL");
c.Execute("PRAGMA synchronous=NORMAL");
c.Execute("PRAGMA foreign_keys=ON");
c.Execute("PRAGMA temp_store=MEMORY");
}
// ── Schema ───────────────────────────────────────────────────────
private static void Migrate(SqliteConnection c)
{
var version = (int)c.Query("PRAGMA user_version")[0].Long("user_version");
if (version >= SchemaVersion) return;
if (version < 1)
{
c.ExecuteScript(SchemaV1);
c.Execute("PRAGMA user_version=1");
OnLog?.Invoke($"[DATABASE] Schema creato (versione 1) in {c.Path} — SQLite {LibraryVersion}");
}
} }
private const string SchemaV1 = """ private const string SchemaV1 = """
@@ -412,320 +324,5 @@ namespace AutoBidder.Data
note TEXT); note TEXT);
CREATE INDEX IF NOT EXISTS ix_ledger_ts ON risk_ledger(ts); CREATE INDEX IF NOT EXISTS ix_ledger_ts ON risk_ledger(ts);
"""; """;
// ── Scrittura ────────────────────────────────────────────────────
/// <summary>Accoda una scrittura. Torna subito: la esegue il thread del database.</summary>
public void Enqueue(string sql, params object?[] args)
{
if (_disposed) return;
Push(new Statement { Sql = sql, Args = args });
}
/// <summary>Accoda più scritture da eseguire insieme, tutte o nessuna.</summary>
public void EnqueueBatch(IEnumerable<(string Sql, object?[] Args)> ops)
{
if (_disposed) return;
var batch = new Batch();
batch.Ops.AddRange(ops);
if (batch.Ops.Count == 0) return;
Push(batch);
}
/// <summary>
/// Esegue subito, sul thread del database, un'operazione che ha bisogno della
/// connessione (per esempio VACUUM INTO, che non può stare in una transazione).
/// Aspetta che sia finita e rilancia l'eventuale errore.
/// </summary>
public void RunOnWriter(Action<SqliteConnection> action, int timeoutMs = 60000)
{
if (_disposed) throw new ObjectDisposedException(nameof(AuctionDatabase));
var custom = new Custom { Action = action };
Push(custom);
if (!custom.Done.Wait(timeoutMs)) throw new TimeoutException("il database non ha risposto in tempo");
if (custom.Error != null) throw custom.Error;
}
/// <summary>Accoda ed aspetta che sia scritta: per le operazioni amministrative.</summary>
public void ExecuteNow(string sql, params object?[] args)
{
Enqueue(sql, args);
Flush();
}
private void Push(Pending p)
{
lock (_queueSync)
{
p.Seq = ++_lastEnqueued;
_queue.Enqueue(p);
Monitor.PulseAll(_queueSync);
}
}
/// <summary>
/// Aspetta che tutto ciò che è stato accodato finora sia sul disco. Da chiamare
/// prima di leggere qualcosa appena scritto, o prima di chiudere.
/// </summary>
public bool Flush(int timeoutMs = 15000)
{
long target;
lock (_queueSync) target = _lastEnqueued;
var deadline = Environment.TickCount64 + timeoutMs;
lock (_queueSync)
{
while (Interlocked.Read(ref _lastCommitted) < target)
{
var left = deadline - Environment.TickCount64;
if (left <= 0 || _disposed) return Interlocked.Read(ref _lastCommitted) >= target;
Monitor.Wait(_queueSync, (int)Math.Min(left, 500));
}
}
return true;
}
private void WriterLoop()
{
var buffer = new List<Pending>(512);
while (!_stop)
{
buffer.Clear();
lock (_queueSync)
{
while (_queue.Count == 0 && !_stop) Monitor.Wait(_queueSync, 250);
while (_queue.Count > 0 && buffer.Count < 2000) buffer.Add(_queue.Dequeue());
}
if (buffer.Count == 0) continue;
// Le istruzioni semplici si raggruppano in una transazione sola; un
// blocco atomico o un'operazione speciale chiudono il gruppo corrente e
// vanno per conto proprio.
var i = 0;
while (i < buffer.Count)
{
switch (buffer[i])
{
case Statement:
var j = i;
while (j < buffer.Count && buffer[j] is Statement) j++;
RunStatements(buffer, i, j);
i = j;
break;
case Batch b:
RunBatch(b);
i++;
break;
case Custom c:
try { c.Action(_writer); }
catch (Exception ex) { c.Error = ex; }
finally { MarkCommitted(c.Seq); c.Done.Set(); }
i++;
break;
default:
i++;
break;
}
}
}
}
private void RunStatements(List<Pending> buffer, int from, int to)
{
long lastSeq = 0;
var inTx = false;
try
{
_writer.Execute("BEGIN IMMEDIATE");
inTx = true;
for (var k = from; k < to; k++)
{
var s = (Statement)buffer[k];
lastSeq = s.Seq;
try
{
var stmt = _writer.Cached(s.Sql);
stmt.Bind(s.Args);
stmt.RunToEnd();
Interlocked.Increment(ref _writesCommitted);
if (s.Sql.Contains("auctions", StringComparison.Ordinal)) Interlocked.Increment(ref _auctionsVersion);
}
catch (SqliteException ex)
{
// Una riga rifiutata non deve far perdere le altre del gruppo.
Interlocked.Increment(ref _writesFailed);
OnLog?.Invoke($"[DATABASE] Scrittura rifiutata: {ex.Message}");
}
}
_writer.Execute("COMMIT");
inTx = false;
}
catch (Exception ex)
{
OnLog?.Invoke($"[DATABASE] Transazione non riuscita: {ex.Message}");
if (inTx) { try { _writer.Execute("ROLLBACK"); } catch { } }
Interlocked.Add(ref _writesFailed, to - from);
}
finally
{
MarkCommitted(Math.Max(lastSeq, ((Pending)buffer[to - 1]).Seq));
}
}
private void RunBatch(Batch b)
{
var inTx = false;
try
{
_writer.Execute("BEGIN IMMEDIATE");
inTx = true;
foreach (var (sql, args) in b.Ops)
{
var stmt = _writer.Cached(sql);
stmt.Bind(args);
stmt.RunToEnd();
if (sql.Contains("auctions", StringComparison.Ordinal)) Interlocked.Increment(ref _auctionsVersion);
}
_writer.Execute("COMMIT");
inTx = false;
Interlocked.Add(ref _writesCommitted, b.Ops.Count);
}
catch (Exception ex)
{
OnLog?.Invoke($"[DATABASE] Blocco annullato: {ex.Message}");
if (inTx) { try { _writer.Execute("ROLLBACK"); } catch { } }
Interlocked.Add(ref _writesFailed, b.Ops.Count);
}
finally
{
MarkCommitted(b.Seq);
}
}
private void MarkCommitted(long seq)
{
lock (_queueSync)
{
if (seq > _lastCommitted) Interlocked.Exchange(ref _lastCommitted, seq);
Monitor.PulseAll(_queueSync);
}
}
// ── Lettura ──────────────────────────────────────────────────────
public List<SqliteRow> Query(string sql, params object?[] args)
{
if (_disposed) return new List<SqliteRow>();
lock (_readSync) return _reader.Query(sql, args);
}
public SqliteRow? QueryOne(string sql, params object?[] args)
{
var rows = Query(sql, args);
return rows.Count > 0 ? rows[0] : null;
}
public long ScalarLong(string sql, params object?[] args)
{
var v = QueryOne(sql, args)?.At(0);
return v switch { long l => l, double d => (long)d, string s when long.TryParse(s, out var x) => x, _ => 0 };
}
public double ScalarDouble(string sql, params object?[] args)
{
var v = QueryOne(sql, args)?.At(0);
return v switch { long l => l, double d => d, string s when double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) => x, _ => 0 };
}
public string? ScalarString(string sql, params object?[] args) => QueryOne(sql, args)?.At(0)?.ToString();
// ── Manutenzione ─────────────────────────────────────────────────
/// <summary>Copia coerente dell'intero archivio in un altro file (VACUUM INTO).</summary>
public void BackupTo(string destination)
{
Flush();
if (File.Exists(destination)) File.Delete(destination);
var folder = System.IO.Path.GetDirectoryName(destination);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
RunOnWriter(c => c.Execute("VACUUM INTO ?1", destination));
}
/// <summary>Byte occupati sul disco (file principale più giornale WAL).</summary>
public long SizeBytes
{
get
{
long total = 0;
foreach (var f in new[] { Path, Path + "-wal" })
{
try { if (File.Exists(f)) total += new FileInfo(f).Length; } catch { }
}
return total;
}
}
/// <summary>Numero di righe di una tabella.</summary>
public long Count(string table) => ScalarLong($"SELECT COUNT(*) AS n FROM {table}");
/// <summary>
/// Svuota le tabelle delle osservazioni e delle decisioni. Restano i prodotti
/// (portano il valore reale scelto dall'utente) e le sessioni.
/// </summary>
public void WipeObservations()
{
EnqueueBatch(new (string, object?[])[]
{
("DELETE FROM auctions", Array.Empty<object?>()),
("DELETE FROM bot_decisions", Array.Empty<object?>()),
("DELETE FROM network_metrics", Array.Empty<object?>()),
("DELETE FROM bidders", Array.Empty<object?>()),
("DELETE FROM risk_ledger", Array.Empty<object?>())
});
Flush();
try { RunOnWriter(c => c.Execute("VACUUM")); } catch { /* lo spazio si recupera la prossima volta */ }
}
// ── Attrezzi ─────────────────────────────────────────────────────
/// <summary>Le date si scrivono in ISO 8601 UTC: ordinano bene e non dipendono dal fuso.</summary>
public static string Iso(DateTime dt)
{
var utc = dt.Kind switch
{
DateTimeKind.Utc => dt,
DateTimeKind.Local => dt.ToUniversalTime(),
_ => DateTime.SpecifyKind(dt, DateTimeKind.Local).ToUniversalTime()
};
return utc.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
}
public static string Now() => Iso(DateTime.UtcNow);
public void Dispose()
{
if (_disposed) return;
Flush(5000);
_stop = true;
lock (_queueSync) Monitor.PulseAll(_queueSync);
try { _writerThread.Join(3000); } catch { }
_disposed = true;
lock (_readSync) _reader.Dispose();
_writer.Dispose();
}
} }
} }
+2 -2
View File
@@ -90,7 +90,7 @@ namespace AutoBidder.Data
/// </summary> /// </summary>
public static AuctionRecorder? OpenFor(AuctionInfo auction, AppSettings settings) public static AuctionRecorder? OpenFor(AuctionInfo auction, AppSettings settings)
{ {
if (auction == null || !settings.WriteAuctionDossiers) return null; if (auction == null || !settings.RecordAuctions) return null;
if (string.IsNullOrEmpty(auction.AuctionId)) return null; if (string.IsNullOrEmpty(auction.AuctionId)) return null;
return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings)); return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings));
@@ -190,7 +190,7 @@ namespace AutoBidder.Data
leadMaxMs = settings.LeadMaxMs, leadMaxMs = settings.LeadMaxMs,
valueCheckEnabled = settings.ValueCheckEnabled, valueCheckEnabled = settings.ValueCheckEnabled,
minSavingsPercentage = settings.MinSavingsPercentage, minSavingsPercentage = settings.MinSavingsPercentage,
rawPollsIncluded = settings.DossierIncludeRawPolls rawPollsIncluded = settings.RecordPolls
}, Json); }, Json);
var me = string.IsNullOrEmpty(CurrentUsername) ? null : CurrentUsername; var me = string.IsNullOrEmpty(CurrentUsername) ? null : CurrentUsername;
+202
View File
@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AutoBidder.Utilities;
namespace AutoBidder.Data
{
/// <summary>
/// Il database d'esercizio: tutto ciò che l'applicazione scrive per sé e non per
/// l'analisi. Sta in un file a parte (<c>esercizio.sqlite</c>) accanto a quello delle
/// osservazioni, così l'archivio delle aste resta un archivio e questo resta piccolo.
///
/// <para><b>Documenti.</b> Le aste nel monitor, i prodotti seguiti, le statistiche per
/// prodotto, le promozioni riscattate, il modello appreso, il profilo, la latenza:
/// ognuno era un file JSON riscritto per intero a ogni modifica. Qui sono righe di una
/// tabella <c>documents</c>, chiave → JSON: la scrittura è atomica (una riga in una
/// transazione, niente file a metà), e c'è un solo file da salvare.</para>
///
/// <para><b>Registri.</b> Il registro applicativo e quello del riscatto puntate sono
/// tabelle: una riga per evento, con data e livello. Si interrogano, si esportano in
/// testo dalle Impostazioni, e si cancellano per età senza girare cartelle.</para>
/// </summary>
public sealed class OperationalDatabase : SqliteDatabase
{
public const int SchemaVersion = 1;
private static readonly object InstanceSync = new();
private static OperationalDatabase? _instance;
public static OperationalDatabase Instance
{
get
{
lock (InstanceSync)
{
if (_instance == null || _instance.IsDisposed)
_instance = Open(AppPaths.OperationalDatabaseFile);
return _instance;
}
}
}
public static void CloseInstance()
{
lock (InstanceSync)
{
_instance?.Dispose();
_instance = null;
}
}
/// <summary>Vero quando l'istanza è aperta e pronta: chi scrive nei registri lo controlla prima.</summary>
public static bool IsOpen
{
get { lock (InstanceSync) return _instance != null && !_instance.IsDisposed; }
}
private OperationalDatabase(string path) : base(path, SchemaV1, SchemaVersion, "AutoBidder.Esercizio") { }
public static OperationalDatabase Open(string path) => new(path);
private const string SchemaV1 = """
-- Un documento JSON per chiave: aste nel monitor, prodotti seguiti,
-- statistiche per prodotto, promozioni riscattate, modelli appresi.
CREATE TABLE IF NOT EXISTS documents(
key TEXT PRIMARY KEY,
json TEXT NOT NULL,
updated_at TEXT NOT NULL);
-- Il registro applicativo: una riga per evento.
CREATE TABLE IF NOT EXISTS app_log(
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS ix_app_log_ts ON app_log(ts);
-- Il registro del riscatto puntate.
CREATE TABLE IF NOT EXISTS freebids_log(
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS ix_freebids_log_ts ON freebids_log(ts);
""";
// ── Documenti ────────────────────────────────────────────────────
/// <summary>Il JSON del documento, o null se non c'è.</summary>
public string? GetDocument(string key)
{
Flush();
return ScalarString("SELECT json FROM documents WHERE key = ?1", key);
}
/// <summary>
/// Il documento, oppure — se manca — il contenuto del vecchio file JSON indicato,
/// che viene importato una volta e da allora vive qui. Il file resta dov'è.
/// </summary>
public string? GetDocumentOrImport(string key, string? legacyFile)
{
var json = GetDocument(key);
if (json != null) return json;
try
{
if (string.IsNullOrEmpty(legacyFile) || !File.Exists(legacyFile)) return null;
json = File.ReadAllText(legacyFile);
if (string.IsNullOrWhiteSpace(json)) return null;
SetDocument(key, json);
Log($"[DATABASE] Importato {System.IO.Path.GetFileName(legacyFile)} come documento «{key}»");
return json;
}
catch (Exception ex)
{
Log($"[DATABASE] Importazione di {legacyFile} non riuscita: {ex.Message}");
return null;
}
}
public void SetDocument(string key, string json)
{
Enqueue("INSERT INTO documents(key, json, updated_at) VALUES(?1, ?2, ?3) " +
"ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at",
key, json, Now());
}
public void DeleteDocument(string key) => Enqueue("DELETE FROM documents WHERE key = ?1", key);
/// <summary>Cancella tutti i documenti con quel prefisso (es. «ml/»).</summary>
public void DeleteDocuments(string prefix) => Enqueue("DELETE FROM documents WHERE key LIKE ?1", prefix + "%");
public IReadOnlyList<(string Key, long Bytes, DateTime UpdatedAt)> Documents()
{
Flush();
return Query("SELECT key, LENGTH(json) AS bytes, updated_at FROM documents ORDER BY key")
.Select(r => (r.Str("key"), r.Long("bytes"), r.Date("updated_at") ?? DateTime.MinValue))
.ToList();
}
// ── Registri ─────────────────────────────────────────────────────
public const string AppLog = "app_log";
public const string FreeBidsLog = "freebids_log";
public void WriteLog(string table, string level, string message)
{
if (table != AppLog && table != FreeBidsLog) return;
Enqueue($"INSERT INTO {table}(ts, level, message) VALUES(?1, ?2, ?3)", Now(), level, message ?? "");
}
/// <summary>Cancella le righe più vecchie di tanti giorni. Restituisce quante.</summary>
public int PurgeLogs(int days)
{
if (days <= 0) return 0;
var cutoff = Iso(DateTime.Now.AddDays(-days));
var removed = 0;
foreach (var table in new[] { AppLog, FreeBidsLog })
{
removed += (int)ScalarLong($"SELECT COUNT(*) AS n FROM {table} WHERE ts < ?1", cutoff);
Enqueue($"DELETE FROM {table} WHERE ts < ?1", cutoff);
}
Flush();
return removed;
}
/// <summary>Le ultime righe di un registro, dalla più recente.</summary>
public List<(DateTime Ts, string Level, string Message)> ReadLog(string table, int max = 2000, DateTime? since = null)
{
Flush();
var rows = since is { } s
? Query($"SELECT ts, level, message FROM {table} WHERE ts >= ?1 ORDER BY id DESC LIMIT ?2", Iso(s), max)
: Query($"SELECT ts, level, message FROM {table} ORDER BY id DESC LIMIT ?1", max);
return rows.Select(r => (r.Date("ts") ?? DateTime.MinValue, r.Str("level"), r.Str("message"))).ToList();
}
/// <summary>Scrive un registro in un file di testo leggibile (una riga per evento, ora locale).</summary>
public int ExportLog(string table, string destination, int days = 0)
{
Flush();
var rows = days > 0
? Query($"SELECT ts, level, message FROM {table} WHERE ts >= ?1 ORDER BY id", Iso(DateTime.Now.AddDays(-days)))
: Query($"SELECT ts, level, message FROM {table} ORDER BY id");
var sb = new StringBuilder();
foreach (var r in rows)
{
var ts = r.Date("ts")?.ToLocalTime() ?? DateTime.MinValue;
sb.Append(ts.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append(" [").Append(r.Str("level")).Append("] ").AppendLine(r.Str("message"));
}
var folder = System.IO.Path.GetDirectoryName(destination);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
File.WriteAllText(destination, sb.ToString(), new UTF8Encoding(true));
return rows.Count;
}
}
}
+408
View File
@@ -0,0 +1,408 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading;
namespace AutoBidder.Data
{
/// <summary>
/// Il motore comune dei due database dell'applicazione: un file SQLite, una coda di
/// scrittura svuotata a blocchi da un thread dedicato, una connessione di lettura a
/// parte con giornale WAL.
///
/// <para><b>Come scrive.</b> Mai sul thread che chiama. Ogni scrittura finisce in coda
/// e il thread del database la esegue in una transazione ogni qualche centinaio di
/// righe: SQLite fa migliaia di inserimenti al secondo così, e nessuno sul percorso
/// della puntata aspetta il disco. Chi deve leggere ciò che ha appena scritto chiama
/// <see cref="Flush"/>.</para>
///
/// <para><b>Come legge.</b> Su una seconda connessione, sotto un lucchetto. Con il
/// giornale WAL le letture non bloccano la scrittura e viceversa.</para>
/// </summary>
public abstract class SqliteDatabase : IDisposable
{
/// <summary>Righe di registro (errori di scrittura, migrazioni): chi si aggancia le scrive dove vuole.</summary>
public static event Action<string>? OnLog;
protected static void Log(string message) => OnLog?.Invoke(message);
private readonly SqliteConnection _writer;
private readonly SqliteConnection _reader;
private readonly object _readSync = new();
private abstract class Pending { public long Seq; }
private sealed class Statement : Pending
{
public string Sql = "";
public object?[] Args = Array.Empty<object?>();
}
private sealed class Batch : Pending
{
public List<(string Sql, object?[] Args)> Ops = new();
}
private sealed class Custom : Pending
{
public Action<SqliteConnection> Action = _ => { };
public Exception? Error;
public ManualResetEventSlim Done = new(false);
}
private readonly Queue<Pending> _queue = new();
private readonly object _queueSync = new();
private long _lastEnqueued;
private long _lastCommitted;
private readonly Thread _writerThread;
private volatile bool _disposed;
private volatile bool _stop;
private long _writesCommitted;
private long _writesFailed;
public long WritesCommitted => Interlocked.Read(ref _writesCommitted);
public long WritesFailed => Interlocked.Read(ref _writesFailed);
public int QueueLength { get { lock (_queueSync) return _queue.Count; } }
public bool IsDisposed => _disposed;
public string Path { get; }
public static string LibraryVersion => SqliteConnection.LibraryVersion;
protected SqliteDatabase(string path, string schemaScript, int schemaVersion, string threadName)
{
Path = path;
var folder = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
_writer = new SqliteConnection(path);
Configure(_writer);
Migrate(_writer, schemaScript, schemaVersion);
OnOpened(_writer);
_reader = new SqliteConnection(path);
Configure(_reader);
_writerThread = new Thread(WriterLoop)
{
Name = threadName,
IsBackground = true,
Priority = ThreadPriority.BelowNormal
};
_writerThread.Start();
}
/// <summary>Chiamato con la connessione di scrittura, dopo lo schema, prima del thread.</summary>
protected virtual void OnOpened(SqliteConnection writer) { }
/// <summary>Chiamato dal thread di scrittura per ogni istruzione eseguita.</summary>
protected virtual void OnStatementExecuted(string sql) { }
private static void Configure(SqliteConnection c)
{
c.Execute("PRAGMA journal_mode=WAL");
c.Execute("PRAGMA synchronous=NORMAL");
c.Execute("PRAGMA foreign_keys=ON");
c.Execute("PRAGMA temp_store=MEMORY");
}
private void Migrate(SqliteConnection c, string schema, int target)
{
var version = (int)c.Query("PRAGMA user_version")[0].Long("user_version");
if (version >= target) return;
c.ExecuteScript(schema);
c.Execute($"PRAGMA user_version={target}");
Log($"[DATABASE] Schema versione {target} in {c.Path} — SQLite {LibraryVersion}");
}
// ── Scrittura ────────────────────────────────────────────────────
public void Enqueue(string sql, params object?[] args)
{
if (_disposed) return;
Push(new Statement { Sql = sql, Args = args });
}
public void EnqueueBatch(IEnumerable<(string Sql, object?[] Args)> ops)
{
if (_disposed) return;
var batch = new Batch();
batch.Ops.AddRange(ops);
if (batch.Ops.Count == 0) return;
Push(batch);
}
public void RunOnWriter(Action<SqliteConnection> action, int timeoutMs = 60000)
{
if (_disposed) throw new ObjectDisposedException(GetType().Name);
var custom = new Custom { Action = action };
Push(custom);
if (!custom.Done.Wait(timeoutMs)) throw new TimeoutException("il database non ha risposto in tempo");
if (custom.Error != null) throw custom.Error;
}
public void ExecuteNow(string sql, params object?[] args)
{
Enqueue(sql, args);
Flush();
}
private void Push(Pending p)
{
lock (_queueSync)
{
p.Seq = ++_lastEnqueued;
_queue.Enqueue(p);
Monitor.PulseAll(_queueSync);
}
}
public bool Flush(int timeoutMs = 15000)
{
long target;
lock (_queueSync) target = _lastEnqueued;
var deadline = Environment.TickCount64 + timeoutMs;
lock (_queueSync)
{
while (Interlocked.Read(ref _lastCommitted) < target)
{
var left = deadline - Environment.TickCount64;
if (left <= 0 || _disposed) return Interlocked.Read(ref _lastCommitted) >= target;
Monitor.Wait(_queueSync, (int)Math.Min(left, 500));
}
}
return true;
}
private void WriterLoop()
{
var buffer = new List<Pending>(512);
while (!_stop)
{
buffer.Clear();
lock (_queueSync)
{
while (_queue.Count == 0 && !_stop) Monitor.Wait(_queueSync, 250);
while (_queue.Count > 0 && buffer.Count < 2000) buffer.Add(_queue.Dequeue());
}
if (buffer.Count == 0) continue;
var i = 0;
while (i < buffer.Count)
{
switch (buffer[i])
{
case Statement:
var j = i;
while (j < buffer.Count && buffer[j] is Statement) j++;
RunStatements(buffer, i, j);
i = j;
break;
case Batch b:
RunBatch(b);
i++;
break;
case Custom c:
try { c.Action(_writer); }
catch (Exception ex) { c.Error = ex; }
finally { MarkCommitted(c.Seq); c.Done.Set(); }
i++;
break;
default:
i++;
break;
}
}
}
}
private void RunStatements(List<Pending> buffer, int from, int to)
{
long lastSeq = 0;
var inTx = false;
try
{
_writer.Execute("BEGIN IMMEDIATE");
inTx = true;
for (var k = from; k < to; k++)
{
var s = (Statement)buffer[k];
lastSeq = s.Seq;
try
{
var stmt = _writer.Cached(s.Sql);
stmt.Bind(s.Args);
stmt.RunToEnd();
Interlocked.Increment(ref _writesCommitted);
OnStatementExecuted(s.Sql);
}
catch (SqliteException ex)
{
Interlocked.Increment(ref _writesFailed);
Log($"[DATABASE] Scrittura rifiutata: {ex.Message}");
}
}
_writer.Execute("COMMIT");
inTx = false;
}
catch (Exception ex)
{
Log($"[DATABASE] Transazione non riuscita: {ex.Message}");
if (inTx) { try { _writer.Execute("ROLLBACK"); } catch { } }
Interlocked.Add(ref _writesFailed, to - from);
}
finally
{
MarkCommitted(Math.Max(lastSeq, buffer[to - 1].Seq));
}
}
private void RunBatch(Batch b)
{
var inTx = false;
try
{
_writer.Execute("BEGIN IMMEDIATE");
inTx = true;
foreach (var (sql, args) in b.Ops)
{
var stmt = _writer.Cached(sql);
stmt.Bind(args);
stmt.RunToEnd();
OnStatementExecuted(sql);
}
_writer.Execute("COMMIT");
inTx = false;
Interlocked.Add(ref _writesCommitted, b.Ops.Count);
}
catch (Exception ex)
{
Log($"[DATABASE] Blocco annullato: {ex.Message}");
if (inTx) { try { _writer.Execute("ROLLBACK"); } catch { } }
Interlocked.Add(ref _writesFailed, b.Ops.Count);
}
finally
{
MarkCommitted(b.Seq);
}
}
private void MarkCommitted(long seq)
{
lock (_queueSync)
{
if (seq > _lastCommitted) Interlocked.Exchange(ref _lastCommitted, seq);
Monitor.PulseAll(_queueSync);
}
}
// ── Lettura ──────────────────────────────────────────────────────
public List<SqliteRow> Query(string sql, params object?[] args)
{
if (_disposed) return new List<SqliteRow>();
lock (_readSync) return _reader.Query(sql, args);
}
public SqliteRow? QueryOne(string sql, params object?[] args)
{
var rows = Query(sql, args);
return rows.Count > 0 ? rows[0] : null;
}
public long ScalarLong(string sql, params object?[] args)
{
var v = QueryOne(sql, args)?.At(0);
return v switch { long l => l, double d => (long)d, string s when long.TryParse(s, out var x) => x, _ => 0 };
}
public double ScalarDouble(string sql, params object?[] args)
{
var v = QueryOne(sql, args)?.At(0);
return v switch { long l => l, double d => d, string s when double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) => x, _ => 0 };
}
public string? ScalarString(string sql, params object?[] args) => QueryOne(sql, args)?.At(0)?.ToString();
public long Count(string table) => ScalarLong($"SELECT COUNT(*) AS n FROM {table}");
// ── Manutenzione ─────────────────────────────────────────────────
/// <summary>Copia coerente dell'intero archivio in un altro file (VACUUM INTO).</summary>
public void BackupTo(string destination)
{
Flush();
if (File.Exists(destination)) File.Delete(destination);
var folder = System.IO.Path.GetDirectoryName(destination);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
RunOnWriter(c => c.Execute("VACUUM INTO ?1", destination));
}
/// <summary>Byte occupati sul disco (file principale più giornale WAL).</summary>
public long SizeBytes
{
get
{
long total = 0;
foreach (var f in new[] { Path, Path + "-wal" })
{
try { if (File.Exists(f)) total += new FileInfo(f).Length; } catch { }
}
return total;
}
}
/// <summary>Compatta il file. Fuori da ogni transazione, sul thread del database.</summary>
public void Vacuum()
{
try { RunOnWriter(c => c.Execute("VACUUM")); } catch { /* lo spazio si recupera la prossima volta */ }
}
// ── Attrezzi ─────────────────────────────────────────────────────
/// <summary>Le date si scrivono in ISO 8601 UTC: ordinano bene e non dipendono dal fuso.</summary>
public static string Iso(DateTime dt)
{
var utc = dt.Kind switch
{
DateTimeKind.Utc => dt,
DateTimeKind.Local => dt.ToUniversalTime(),
_ => DateTime.SpecifyKind(dt, DateTimeKind.Local).ToUniversalTime()
};
return utc.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
}
public static string Now() => Iso(DateTime.UtcNow);
public void Dispose()
{
if (_disposed) return;
Flush(5000);
_stop = true;
lock (_queueSync) Monitor.PulseAll(_queueSync);
try { _writerThread.Join(3000); } catch { }
_disposed = true;
lock (_readSync) _reader.Dispose();
_writer.Dispose();
}
}
}
+4 -14
View File
@@ -26,24 +26,14 @@
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." 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"/> Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkBidLead" Margin="0,4" IsChecked="True" <CheckBox x:Name="ChkLegacy" Margin="0,4" IsChecked="False"
Foreground="{DynamicResource Brush.Text}" Foreground="{DynamicResource Brush.Text}"
ToolTip="Le misure dell'anticipo effettivo di ogni puntata (bid-lead-stats.json)." ToolTip="La vecchia cartella Dati delle versioni precedenti: i file JSON già importati nel database, le statistiche, i registri di testo e i dossier delle aste (possono essere gigabyte). L'applicazione non li legge più."
Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="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="I file JSON delle versioni precedenti (storico compatto completed-auctions.json e archivi mensili aste-AAAA-MM): il database li ha sostituiti e l'applicazione non li legge più."
Checked="Option_Changed" Unchecked="Option_Changed"/> Checked="Option_Changed" Unchecked="Option_Changed"/>
<CheckBox x:Name="ChkLearning" Margin="0,4" IsChecked="False" <CheckBox x:Name="ChkLearning" Margin="0,4" IsChecked="False"
Foreground="{DynamicResource Brush.Text}" 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)." ToolTip="Il modello appreso, il profilo per prodotto e ora, lo sfidante, il bandit, il modello di latenza e il contatore delle aste già studiate. Non si perde niente di irrecuperabile: al prossimo avvio l'applicazione ristudia da capo tutte le aste chiuse del database (qualche minuto in sottofondo)."
Checked="Option_Changed" Unchecked="Option_Changed"/> Checked="Option_Changed" Unchecked="Option_Changed"/>
<Border Style="{StaticResource InfoBox}" Margin="0,14,0,0"> <Border Style="{StaticResource InfoBox}" Margin="0,14,0,0">
@@ -55,7 +45,7 @@
<TextBlock TextWrapping="Wrap" Margin="0,8,0,0" <TextBlock TextWrapping="Wrap" Margin="0,8,0,0"
Foreground="{DynamicResource Brush.TextMuted}" Foreground="{DynamicResource Brush.TextMuted}"
FontSize="{StaticResource Font.Size.Sm}" LineHeight="17" 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."/> Text="Per cancellare tutto — database, copie di sicurezza, cartella vecchia — c'è «Elimina tutti i dati» nelle Impostazioni."/>
</StackPanel> </StackPanel>
</Border> </Border>
+2 -8
View File
@@ -29,8 +29,6 @@ namespace AutoBidder.Dialogs
{ {
History = ChkHistory.IsChecked == true, History = ChkHistory.IsChecked == true,
ProductStats = ChkProducts.IsChecked == true, ProductStats = ChkProducts.IsChecked == true,
BidLeadMeasures = ChkBidLead.IsChecked == true,
Exports = ChkExports.IsChecked == true,
LegacyArchives = ChkLegacy.IsChecked == true, LegacyArchives = ChkLegacy.IsChecked == true,
Learning = ChkLearning.IsChecked == true Learning = ChkLearning.IsChecked == true
}; };
@@ -43,17 +41,13 @@ namespace AutoBidder.Dialogs
ChkHistory.Content = $"Osservazioni nel database: aste, puntate, interrogazioni, decisioni ({_sizes.HistoryAuctions} aste, {Fmt(_sizes.HistoryBytes)})"; ChkHistory.Content = $"Osservazioni nel database: aste, puntate, interrogazioni, decisioni ({_sizes.HistoryAuctions} aste, {Fmt(_sizes.HistoryBytes)})";
ChkProducts.Content = $"Statistiche per prodotto ({Fmt(_sizes.ProductsBytes)})"; ChkProducts.Content = $"Statistiche per prodotto ({Fmt(_sizes.ProductsBytes)})";
ChkBidLead.Content = $"Misure dell'anticipo effettivo ({_sizes.BidLeadSamples} misure, {Fmt(_sizes.BidLeadBytes)})"; ChkLegacy.Content = $"Vecchia cartella Dati delle versioni precedenti ({_sizes.LegacyFiles} file, {Fmt(_sizes.LegacyBytes)})";
ChkExports.Content = $"Esportazioni ({_sizes.ExportFiles} file, {Fmt(_sizes.ExportBytes)})";
ChkLegacy.Content = $"Archivi JSON delle versioni precedenti ({_sizes.LegacyFiles} file, {Fmt(_sizes.LegacyBytes)})";
ChkLearning.Content = $"Apprendimento: modello, profilo, latenza ({Fmt(_sizes.LearningBytes)})"; ChkLearning.Content = $"Apprendimento: modello, profilo, latenza ({Fmt(_sizes.LearningBytes)})";
var o = ReadOptions(); var o = ReadOptions();
long totale = 0; long totale = 0;
if (o.History) totale += _sizes.HistoryBytes; if (o.History) totale += _sizes.HistoryBytes;
if (o.ProductStats) totale += _sizes.ProductsBytes; 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.LegacyArchives) totale += _sizes.LegacyBytes;
if (o.Learning) totale += _sizes.LearningBytes; if (o.Learning) totale += _sizes.LearningBytes;
@@ -61,7 +55,7 @@ namespace AutoBidder.Dialogs
? "Nessuna voce scelta." ? "Nessuna voce scelta."
: $"Si liberano circa {Fmt(totale)}." + : $"Si liberano circa {Fmt(totale)}." +
(o.History ? " Lo storico viene prima copiato nei backup." : "") + (o.History ? " Lo storico viene prima copiato nei backup." : "") +
(o.Learning ? " L'apprendimento ripartirà da zero e ristudierà i dossier al prossimo avvio." : ""); (o.Learning ? " L'apprendimento ripartirà da zero e ristudierà le aste al prossimo avvio." : "");
WipeButton.IsEnabled = !o.Nothing; WipeButton.IsEnabled = !o.Nothing;
} }
+2 -1
View File
@@ -242,7 +242,8 @@ namespace AutoBidder.Engine
if (_auction.BidLeadIsManual && _auction.BidBeforeDeadlineMs > 0) if (_auction.BidLeadIsManual && _auction.BidBeforeDeadlineMs > 0)
return _auction.BidBeforeDeadlineMs; return _auction.BidBeforeDeadlineMs;
var lead = settings.AdaptiveLeadEnabled // Con l'apprendimento spento vale l'anticipo fisso: è l'interruttore in barra.
var lead = settings.AdaptiveLeadEnabled && settings.LearningEnabled
? Ml.LatencyModel.RecommendedLeadMs(settings) ? Ml.LatencyModel.RecommendedLeadMs(settings)
: settings.DefaultBidBeforeDeadlineMs; : settings.DefaultBidBeforeDeadlineMs;
+52 -47
View File
@@ -69,12 +69,6 @@
Style="{StaticResource VerticalTabButton}" Style="{StaticResource VerticalTabButton}"
Checked="TabDatiStatistici_Checked"/> Checked="TabDatiStatistici_Checked"/>
<RadioButton x:Name="TabEsporta"
Content="Esporta"
Tag="&#xEDE1;"
Style="{StaticResource VerticalTabButton}"
Checked="TabEsporta_Checked"/>
<RadioButton x:Name="TabApprendimento" <RadioButton x:Name="TabApprendimento"
Content="Apprendimento" Content="Apprendimento"
Tag="&#xE945;" Tag="&#xE945;"
@@ -148,8 +142,7 @@
StartClicked="AuctionMonitor_StartClicked" StartClicked="AuctionMonitor_StartClicked"
PauseAllClicked="AuctionMonitor_PauseAllClicked" PauseAllClicked="AuctionMonitor_PauseAllClicked"
StopClicked="AuctionMonitor_StopClicked" StopClicked="AuctionMonitor_StopClicked"
KillSwitchChanged="AuctionMonitor_KillSwitchChanged" LearningToggled="AuctionMonitor_LearningToggled"
ExportClicked="AuctionMonitor_ExportClicked"
AddUrlClicked="AuctionMonitor_AddUrlClicked" AddUrlClicked="AuctionMonitor_AddUrlClicked"
RemoveUrlClicked="AuctionMonitor_RemoveUrlClicked" RemoveUrlClicked="AuctionMonitor_RemoveUrlClicked"
RemoveAllClicked="AuctionMonitor_RemoveAllClicked" RemoveAllClicked="AuctionMonitor_RemoveAllClicked"
@@ -160,7 +153,6 @@
CopyUrlClicked="AuctionMonitor_CopyUrlClicked" CopyUrlClicked="AuctionMonitor_CopyUrlClicked"
OpenAuctionInternalClicked="AuctionMonitor_OpenAuctionInternalClicked" OpenAuctionInternalClicked="AuctionMonitor_OpenAuctionInternalClicked"
OpenAuctionExternalClicked="AuctionMonitor_OpenAuctionExternalClicked" OpenAuctionExternalClicked="AuctionMonitor_OpenAuctionExternalClicked"
ExportAuctionClicked="AuctionMonitor_ExportAuctionClicked"
ResetSettingsClicked="AuctionMonitor_ResetSettingsClicked" ResetSettingsClicked="AuctionMonitor_ResetSettingsClicked"
ClearBiddersClicked="AuctionMonitor_ClearBiddersClicked" ClearBiddersClicked="AuctionMonitor_ClearBiddersClicked"
ClearLogClicked="AuctionMonitor_ClearLogClicked" ClearLogClicked="AuctionMonitor_ClearLogClicked"
@@ -194,6 +186,9 @@
ReapplyClicked="Products_ReapplyClicked" ReapplyClicked="Products_ReapplyClicked"
RemoveClicked="Products_RemoveClicked" RemoveClicked="Products_RemoveClicked"
ClearClicked="Products_ClearClicked" ClearClicked="Products_ClearClicked"
ClearAllClicked="Products_ClearAllClicked"
UnwatchAllClicked="Products_UnwatchAllClicked"
RemoveUnwatchedClicked="Products_RemoveUnwatchedClicked"
ApplySuggestedClicked="Products_ApplySuggestedClicked" ApplySuggestedClicked="Products_ApplySuggestedClicked"
ApplyAllSuggestedClicked="Products_ApplyAllSuggestedClicked" ApplyAllSuggestedClicked="Products_ApplyAllSuggestedClicked"
ProductLimitsEdited="Products_ProductLimitsEdited" ProductLimitsEdited="Products_ProductLimitsEdited"
@@ -216,14 +211,6 @@
OpenConfigClicked="FreeBids_OpenConfigClicked" OpenConfigClicked="FreeBids_OpenConfigClicked"
OptionsChanged="FreeBids_OptionsChanged"/> OptionsChanged="FreeBids_OptionsChanged"/>
<!-- Esporta Panel -->
<controls:ExportControl x:Name="Export"
Visibility="Collapsed"
ExportRequested="Export_ExportRequested"
OpenExportFolderClicked="Export_OpenFolderClicked"
OpenLastFileClicked="Export_OpenLastFileClicked"
ClearExportsClicked="Export_ClearExportsClicked"/>
<!-- Apprendimento Panel --> <!-- Apprendimento Panel -->
<controls:LearningControl x:Name="Learning" Visibility="Collapsed"/> <controls:LearningControl x:Name="Learning" Visibility="Collapsed"/>
@@ -271,41 +258,63 @@
<!-- La lettura per prodotto vive nella scheda Prodotti, insieme ai <!-- La lettura per prodotto vive nella scheda Prodotti, insieme ai
limiti: qui resta lo storico asta per asta. --> limiti: qui resta lo storico asta per asta. -->
<!-- Solo icone, come nel monitor: il tooltip dice esattamente cosa succede. -->
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="CleanupStatsButton" <Button x:Name="BackfillWinnerBidsButton" Style="{StaticResource IconButton}"
Style="{StaticResource SmallRoundedButton}" Foreground="{DynamicResource Brush.Accent}"
Background="{DynamicResource Brush.SurfaceAlt}" ToolTip="Recupera le puntate dei vincitori.&#10;Chiede al server quante puntate ha speso il vincitore di ogni asta conclusa a cui manca il dato. Sta in data.php e non scade: le aste registrate prima che l&apos;applicazione sapesse leggerlo non sono perse. Va piano di proposito, una richiesta alla volta in corsia di fondo, per non rubare banda alle aste in corso."
Foreground="{DynamicResource Brush.Text}" Click="BackfillWinnerBidsButton_Click">
Content="Pulisci dati incompleti" Margin="0,0,8,0" <TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE896;"/>
ToolTip="Analizza lo storico e toglie le aste che non superano i controlli che scegli tu.&#10;&#10;Il conto si aggiorna mentre scegli, e prima di scrivere viene salvata una copia dello storico." </Button>
Click="CleanupStatsButton_Click"/> <Button x:Name="CleanupStatsButton" Style="{StaticResource IconButton}"
<Button x:Name="BackfillWinnerBidsButton" ToolTip="Pulisci i dati incompleti.&#10;Analizza lo storico e toglie le aste che non superano i controlli che scegli tu (senza vincitore, senza prezzo, osservate solo in parte…). Il conto si aggiorna mentre scegli; prima di scrivere viene salvata una copia. Le statistiche per prodotto vengono ricalcolate."
Style="{StaticResource SmallRoundedButton}" Click="CleanupStatsButton_Click">
Background="{DynamicResource Brush.SurfaceAlt}" <TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE90F;"/>
Foreground="{DynamicResource Brush.Text}" </Button>
Content="Recupera puntate dei vincitori" Margin="0,0,8,0"
ToolTip="Chiede al server quante puntate ha speso il vincitore di ogni asta già conclusa.&#10;&#10;Il dato sta in data.php e non scade: le aste registrate prima che l&apos;applicazione sapesse leggerlo non sono perse, basta richiederle. È quello che il sito mostra come «Puntate utilizzate» sulla pagina dell&apos;asta chiusa.&#10;&#10;Va piano di proposito, una richiesta alla volta in corsia di fondo: non deve rubare banda alle aste in corso." <Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
Click="BackfillWinnerBidsButton_Click"/>
<Button Style="{StaticResource IconButton}" <Button Style="{StaticResource IconButton}"
ToolTip="Esporta tutto in CSV (foglio di calcolo)" ToolTip="Esporta lo storico in CSV (foglio di calcolo).&#10;Chiede dove salvare il file."
Click="ExportStatsCsvButton_Click"> Click="ExportStatsCsvButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE9F9;"/> <TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE9F9;"/>
</Button> </Button>
<Button Style="{StaticResource IconButton}" <Button Style="{StaticResource IconButton}"
ToolTip="Esporta tutto in JSON (con serie prezzi e puntate per utente)" ToolTip="Esporta lo storico in JSON, con serie dei prezzi e puntate per utente.&#10;Chiede dove salvare il file."
Click="ExportStatsJsonButton_Click"> Click="ExportStatsJsonButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE8B5;"/> <TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE8B5;"/>
</Button> </Button>
<Button Style="{StaticResource IconButton}" ToolTip="Apri la cartella dei dati" <Button Style="{StaticResource IconButton}"
ToolTip="Apri la cartella dei database."
Click="OpenStatsFolderButton_Click"> Click="OpenStatsFolderButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/> <TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE838;"/>
</Button> </Button>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
<Button Style="{StaticResource IconButton}"
ToolTip="Seleziona tutte le righe.&#10;Poi «Elimina le selezionate». Ctrl+clic e Maiusc+clic scelgono righe singole o intervalli."
Click="SelectAllStatsButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE8B3;"/>
</Button>
<Button x:Name="DeleteSelectedStatsButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Elimina le aste selezionate.&#10;Toglie dallo storico le righe selezionate con puntate, interrogazioni, reset e decisioni collegate. Le statistiche per prodotto vengono ricalcolate. Prima viene salvata una copia nei backup. Vale anche il tasto Canc sulla griglia."
Click="DeleteSelectedStatsButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE738;"/>
</Button>
<Button Style="{StaticResource IconButton}" <Button Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}" Foreground="{DynamicResource Brush.Danger}"
ToolTip="Azzera le statistiche registrate: scegli cosa (storico, prodotti, misure, esportazioni, archivi vecchi, apprendimento). Lo storico viene prima copiato nei backup." ToolTip="Azzera a scelta.&#10;Un dialogo dice cosa c'è e quanto pesa, e scegli voce per voce: storico, statistiche per prodotto, vecchia cartella Dati, apprendimento. Lo storico viene prima copiato nei backup."
Click="ClearStatsButton_Click"> Click="ClearStatsButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE74D;"/> <TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE74D;"/>
</Button> </Button>
<Button Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Svuota completamente lo storico.&#10;Toglie tutte le aste concluse con puntate, interrogazioni, reset, decisioni, misure di rete e contabilità del rischio, e azzera le statistiche per prodotto. Prima viene salvata una copia nei backup. I prodotti seguiti, le aste nel monitor e l'apprendimento restano."
Click="WipeHistoryButton_Click">
<TextBlock FontFamily="Segoe MDL2 Assets" Text="&#xE894;"/>
</Button>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -316,7 +325,9 @@
Background="{DynamicResource Brush.Surface}" Background="{DynamicResource Brush.Surface}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1" CornerRadius="12"> BorderBrush="{DynamicResource Brush.Border}" BorderThickness="1" CornerRadius="12">
<DataGrid x:Name="StatsDetailGrid" AutoGenerateColumns="False" IsReadOnly="True" <DataGrid x:Name="StatsDetailGrid" AutoGenerateColumns="False" IsReadOnly="True"
Background="Transparent" Margin="4"> SelectionMode="Extended" SelectionUnit="FullRow"
Background="Transparent" Margin="4"
PreviewKeyDown="StatsDetailGrid_PreviewKeyDown">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Chiusa il" Binding="{Binding EndedAtDisplay}" Width="Auto" MinWidth="94"/> <DataGridTextColumn Header="Chiusa il" Binding="{Binding EndedAtDisplay}" Width="Auto" MinWidth="94"/>
<DataGridTextColumn Header="Nome" Binding="{Binding Name}" Width="*" MinWidth="160"/> <DataGridTextColumn Header="Nome" Binding="{Binding Name}" Width="*" MinWidth="160"/>
@@ -355,17 +366,11 @@
ConnectSessionClicked="Settings_ConnectSessionClicked" ConnectSessionClicked="Settings_ConnectSessionClicked"
DisconnectSessionClicked="Settings_DisconnectSessionClicked" DisconnectSessionClicked="Settings_DisconnectSessionClicked"
OpenBrowserLoginClicked="Settings_OpenBrowserLoginClicked" OpenBrowserLoginClicked="Settings_OpenBrowserLoginClicked"
ComputeBidLeadAdviceClicked="Settings_ComputeBidLeadAdviceClicked" BrowseDatabaseFolderClicked="Settings_BrowseDatabaseFolderClicked"
ClearBidLeadStatsClicked="Settings_ClearBidLeadStatsClicked" WipeAllDataClicked="Settings_WipeAllDataClicked"
BrowseDataFolderClicked="Settings_BrowseDataFolderClicked" ResetSettingsClicked="Settings_ResetSettingsClicked"
BrowseStatsFolderClicked="Settings_BrowseStatsFolderClicked" ExportAppLogClicked="Settings_ExportAppLogClicked"
OpenDataFolderClicked="Settings_OpenDataFolderClicked" OpenDatabaseFolderClicked="Settings_OpenDatabaseFolderClicked"/>
OpenStatsFolderClicked="Settings_OpenStatsFolderClicked"
BrowseLogFolderClicked="Settings_BrowseLogFolderClicked"
OpenLogFolderClicked="Settings_OpenLogFolderClicked"
BrowseDatabaseFileClicked="Settings_BrowseDatabaseFileClicked"
OpenDatabaseFolderClicked="Settings_OpenDatabaseFolderClicked"
KillSwitchChanged="Settings_KillSwitchChanged"/>
</Grid> </Grid>
</Grid> </Grid>
</Window> </Window>
+16 -6
View File
@@ -102,7 +102,7 @@ namespace AutoBidder
// Inizializza servizi // Inizializza servizi
_auctionMonitor = new AuctionMonitor(); _auctionMonitor = new AuctionMonitor();
Learning.AuctionsProvider = () => _auctionMonitor.GetAuctions(); Learning.AuctionsProvider = () => _auctionMonitor.GetAuctions();
RefreshKillSwitchToggle(); RefreshLearningToggle();
// ? NUOVO: Inizializza SessionService // ? NUOVO: Inizializza SessionService
InitializeSessionService(); InitializeSessionService();
@@ -220,25 +220,35 @@ namespace AutoBidder
/// <summary> /// <summary>
/// Costruisce le schede prodotto la prima volta, ricavandole dallo storico già /// Costruisce le schede prodotto la prima volta, ricavandole dallo storico già
/// raccolto. /// raccolto, e le ricostruisce una volta sola quando cambia il modo di contare.
/// ///
/// <para>Senza, chi ha già mesi di aste alle spalle aprirebbe un file vuoto e /// <para>Senza, chi ha già mesi di aste alle spalle aprirebbe un file vuoto e
/// dovrebbe aspettare le aste future per vedere qualcosa — proprio i numeri che /// dovrebbe aspettare le aste future per vedere qualcosa — proprio i numeri che
/// servono adesso per decidere i limiti. Gira una sola volta e su un thread di /// servono adesso per decidere i limiti. Gira su un thread di lavoro: rileggere
/// lavoro: rileggere l'archivio all'avvio non deve ritardare la finestra.</para> /// l'archivio all'avvio non deve ritardare la finestra.</para>
///
/// <para>Il contrassegno nel database d'esercizio dice con quale «generazione» le
/// schede sono state contate. La 2 è quella in cui la chiusura di un'asta arriva
/// una volta sola: prima ogni asta conclusa veniva contata due volte, e le schede
/// costruite allora vanno rifatte dallo storico, che è sempre stato giusto.</para>
/// </summary> /// </summary>
private void EnsureProductCardsBuilt() private void EnsureProductCardsBuilt()
{ {
const string Marker = "products-generation";
const string Generation = "2";
try try
{ {
if (System.IO.File.Exists(AppPaths.ProductsFile)) return; var op = Data.OperationalDatabase.Instance;
if (ProductStatsStore.GetAll().Count > 0 && op.GetDocument(Marker) == Generation) return;
_ = System.Threading.Tasks.Task.Run(() => _ = System.Threading.Tasks.Task.Run(() =>
{ {
var count = ProductStatsStore.RebuildFromHistory(); var count = ProductStatsStore.RebuildFromHistory();
op.SetDocument(Marker, Generation);
Dispatcher.BeginInvoke(() => Dispatcher.BeginInvoke(() =>
Log($"[PRODOTTI] Schede prodotto costruite dallo storico ({count} aste)", LogLevel.Info)); Log($"[PRODOTTI] Schede prodotto ricostruite dallo storico ({count} aste)", LogLevel.Info));
}); });
} }
catch (Exception ex) catch (Exception ex)
+35 -16
View File
@@ -40,10 +40,18 @@ obbligatoria; le aste in *Attiva* puntano davvero, dentro i paletti del gestore
## 1. Cosa si registra: il database ## 1. Cosa si registra: il database
Un solo file SQLite (`%LocalAppData%\AutoBidder\Database\autobidder.sqlite`, cambiabile Due file SQLite in `%LocalAppData%\AutoBidder\Database` (cartella cambiabile dalle
dalle Impostazioni; mai in una cartella sincronizzata). Le scritture vanno in coda e un Impostazioni, con spostamento dei file e riavvio; mai una cartella sincronizzata):
thread dedicato le svuota a blocchi in transazioni: nulla sul percorso della puntata
aspetta il disco. Le letture usano una seconda connessione con giornale WAL. - `autobidder.sqlite` — le **osservazioni**: le tabelle qui sotto.
- `esercizio.sqlite` — l'**esercizio**: i documenti JSON dell'applicazione (aste nel
monitor, prodotti seguiti, statistiche per prodotto, promozioni riscattate, modelli
appresi con prefisso `ml/`) e i registri applicativo e del riscatto puntate. I file
JSON delle versioni precedenti vengono importati la prima volta e lasciati dove sono.
Le scritture vanno in coda e un thread dedicato per database le svuota a blocchi in
transazioni: nulla sul percorso della puntata aspetta il disco. Le letture usano una
seconda connessione con giornale WAL. Il motore comune è `SqliteDatabase`.
| tabella | cosa contiene | | tabella | cosa contiene |
|---|---| |---|---|
@@ -85,9 +93,11 @@ il «Compralo Ora»), `Δ = 0,01 €`, `fee ≈ 1 €`, `c` il costo della punta
**Copertura «Compralo Ora».** Chi perde può comprare al listino `L` recuperando le puntate **Copertura «Compralo Ora».** Chi perde può comprare al listino `L` recuperando le puntate
spese come credito: la perdita finale, se si esercita, è `L V` qualunque sia il numero di spese come credito: la perdita finale, se si esercita, è `L V` qualunque sia il numero di
puntate. Quindi `c_perdita = c` finché `spese + c ≤ L V`, zero dopo. Per un buono al puntate. Quindi `c_perdita = c` finché `spese + c ≤ L V`, zero dopo. Con `V = L` la
nominale (`V = L`) la copertura è totale dal primo centesimo. È un'ipotesi forte, e per copertura è totale dal primo centesimo e si punterebbe sempre, a qualunque probabilità: in
questo è un'impostazione (`Copertura «Compralo Ora»`), accesa per scelta dell'utente. shadow è successo (BID con P dell'1%). Per questo la copertura è un'impostazione
(`Copertura «Compralo Ora»`) **e vale solo per i prodotti con «Valore reale €» scritto**:
con V lasciato al listino la puntata si conta persa, come senza copertura.
**Null-model.** Profitto atteso per puntata = 0; numero critico di puntatori `V/c` **Null-model.** Profitto atteso per puntata = 0; numero critico di puntatori `V/c`
(Hinnosaar). **Sopravvivenza empirica:** dalla tabella `resets`, P(vittoria) a un dato (Hinnosaar). **Sopravvivenza empirica:** dalla tabella `resets`, P(vittoria) a un dato
@@ -109,13 +119,19 @@ dell'istante di fuoco:
QUANDO: anticipo adattivo QUANDO: anticipo adattivo
``` ```
1. **Rischio**`RiskManager.Authorize`: kill-switch (file `KILL_SWITCH` in 1. **Rischio**`RiskManager.Authorize`: HALT persistente, stop-loss del giorno,
`%LocalAppData%\AutoBidder`, anche dalla barra del monitor), HALT persistente, stop-loss drawdown dal picco, tetto di spesa del giorno, aste in gioco insieme. Lo stop-loss e
del giorno, drawdown dal picco, tetto di spesa del giorno, aste in gioco insieme. Lo il drawdown mettono in HALT, che sopravvive al riavvio e si toglie a mano. La
stop-loss e il drawdown mettono in HALT, che sopravvive al riavvio e si toglie a mano. contabilità è in `risk_ledger`: ogni puntata riuscita un'uscita, ogni vincita
La contabilità è in `risk_ledger`: ogni puntata riuscita un'uscita, ogni vincita
un'entrata al valore reale meno prezzo, fee e spedizione. un'entrata al valore reale meno prezzo, fee e spedizione.
2. **Paletti** — fascia oraria sospesa (globale, per prodotto, per asta), tetti di puntate. 2. **Paletti** — fascia oraria sospesa (globale, per prodotto, per asta), tetti di puntate.
**L'interruttore dell'apprendimento** (il robot nella barra del monitor, accanto ad
Avvia/Osserva/Ferma; `LearningEnabled`) taglia qui la pipeline: spento, i passi 35 non
girano e l'anticipo è quello fisso delle impostazioni. Si punta solo entro i limiti
dell'utente — prezzo minimo e massimo, puntate, budget, fascia oraria, rischio — come
un cecchino senza cervello. Le aste vengono comunque registrate e studiate: quando lo
si riaccende il modello non ha perso niente.
3. **Duello** — cinque risposte di fila con la firma dell'autopuntata (6 ± 2,5 s) = macchina 3. **Duello** — cinque risposte di fila con la firma dell'autopuntata (6 ± 2,5 s) = macchina
armata dall'altra parte; si smette e si informa il regime. armata dall'altra parte; si smette e si informa il regime.
4. **Valore atteso** — il modello dà P(senza risposta); `Theory` la trasforma in euro. 4. **Valore atteso** — il modello dà P(senza risposta); `Theory` la trasforma in euro.
@@ -217,8 +233,11 @@ si spegne il vivo. La posizione di default è che un vantaggio consistente sia i
Scheda **Apprendimento**: stato e pesi del modello, ultime decisioni, profilo per prodotto, Scheda **Apprendimento**: stato e pesi del modello, ultime decisioni, profilo per prodotto,
valutazione, autonomia sul momento (latenza e regime per asta), shadow (policy a confronto, valutazione, autonomia sul momento (latenza e regime per asta), shadow (policy a confronto,
campione/sfidante, bandit), simulatore. Scheda **Impostazioni Gestione del rischio**: campione/sfidante, bandit), simulatore. Barra del **monitor**: l'interruttore
paletti, kill-switch, HALT e contabilità. Scheda **Prodotti**: colonna «Valore reale €». dell'apprendimento. Scheda **Impostazioni Gestione del rischio**: paletti, HALT e
contabilità; **Impostazioni Database** e **Manutenzione**: cartella dei database, «Elimina
tutti i dati (tranne la login)», «Impostazioni di fabbrica». Scheda **Prodotti**: colonna
«Valore reale €».
Nel registro di ogni asta: `[SHADOW] Avrei puntato…`, `[REGIME] Calmo → Sfogo…`, `[TIMING] Nel registro di ogni asta: `[SHADOW] Avrei puntato…`, `[REGIME] Calmo → Sfogo…`, `[TIMING]
… anticipo adattivo…`, `⛔ Strategia blocca: …`. … anticipo adattivo…`, `⛔ Strategia blocca: …`.
@@ -238,8 +257,8 @@ Nel registro di ogni asta: `[SHADOW] Avrei puntato…`, `[REGIME] Calmo → Sfog
| | stato | | | stato |
|---|---| |---|---|
| M1 rischio, kill-switch, HALT, shadow per asta | fatto | | M1 rischio, HALT, interruttore dell'apprendimento, shadow per asta | fatto |
| M2 database SQLite, registrazione, sessioni, misure di rete | fatto (si parte da zero) | | M2 database SQLite (osservazioni + esercizio), registrazione, sessioni, misure di rete | fatto (si parte da zero) |
| M3 teoria, simulatore con avversari tarati, rigiocata | fatto | | M3 teoria, simulatore con avversari tarati, rigiocata | fatto |
| M4 modelli: logistico, rete neurale, profilo, hazard/Kaplan-Meier, Brier | fatto | | M4 modelli: logistico, rete neurale, profilo, hazard/Kaplan-Meier, Brier | fatto |
| M5 policy: valore atteso + regime, bandit Thompson, agente Q conservativo | fatto (Q solo nel simulatore) | | M5 policy: valore atteso + regime, bandit Thompson, agente Q conservativo | fatto (Q solo nel simulatore) |
+6 -6
View File
@@ -51,7 +51,7 @@ namespace AutoBidder.Ml
private static int _bidsTotal; private static int _bidsTotal;
private static bool _loaded; private static bool _loaded;
private static string FilePath => Path.Combine(AppPaths.StatsFolder, "Apprendimento", "latenza.json"); private const string DocKey = "ml/latenza";
// ── Alimentazione ──────────────────────────────────────────────── // ── Alimentazione ────────────────────────────────────────────────
@@ -163,8 +163,9 @@ namespace AutoBidder.Ml
_loaded = true; _loaded = true;
try try
{ {
if (!File.Exists(FilePath)) return; var json = Data.OperationalDatabase.Instance.GetDocumentOrImport(DocKey, Path.Combine(AppPaths.LegacyLearningFolder, "latenza.json"));
var s = JsonSerializer.Deserialize<Snap>(File.ReadAllText(FilePath)); if (json == null) return;
var s = JsonSerializer.Deserialize<Snap>(json);
if (s == null) return; if (s == null) return;
_marginMs = Math.Clamp(s.MarginMs, 150, 2000); _marginMs = Math.Clamp(s.MarginMs, 150, 2000);
_lateBids = s.LateBids; _lateBids = s.LateBids;
@@ -190,8 +191,7 @@ namespace AutoBidder.Ml
RoundTrips = _roundTrips.ToArray() RoundTrips = _roundTrips.ToArray()
}); });
} }
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); Data.OperationalDatabase.Instance.SetDocument(DocKey, json);
File.WriteAllText(FilePath, json);
} }
catch { /* la prossima puntata riproverà */ } catch { /* la prossima puntata riproverà */ }
} }
@@ -200,7 +200,7 @@ namespace AutoBidder.Ml
public static void Forget() public static void Forget()
{ {
ResetForTests(); ResetForTests();
try { if (File.Exists(FilePath)) File.Delete(FilePath); } catch { } try { Data.OperationalDatabase.Instance.DeleteDocument(DocKey); } catch { }
} }
/// <summary>Solo per i test.</summary> /// <summary>Solo per i test.</summary>
+82 -44
View File
@@ -90,14 +90,21 @@ namespace AutoBidder.Ml
// ── Percorsi ───────────────────────────────────────────────────── // ── Percorsi ─────────────────────────────────────────────────────
private static string Folder => Path.Combine(AppPaths.StatsFolder, "Apprendimento"); // Tutto ciò che il modello sa vive nel database d'esercizio, come documenti con
private static string ModelFile => Path.Combine(Folder, "modello-puntate.json"); // prefisso «ml/». I file della cartella Apprendimento delle versioni precedenti
private static string ProfileFile => Path.Combine(Folder, "profilo-aste.json"); // vengono importati la prima volta.
private static string ChallengerFile => Path.Combine(Folder, "sfidante-rete.json"); private const string DocModel = "ml/modello-puntate";
private static string BanditFile => Path.Combine(Folder, "bandit.json"); private const string DocProfile = "ml/profilo-aste";
private static string BrierFile => Path.Combine(Folder, "brier.txt"); private const string DocChallenger = "ml/sfidante-rete";
private static string CounterFile => Path.Combine(Folder, "aste-apprese.txt"); private const string DocBandit = "ml/bandit";
private static string LegacyManifestFile => Path.Combine(Folder, "dossier-appresi.txt"); private const string DocBrier = "ml/brier";
private const string DocCounter = "ml/aste-apprese";
private const string DocEvaluation = "ml/valutazione";
private static OperationalDatabase Docs => OperationalDatabase.Instance;
private static string? Doc(string key, string legacyFileName) =>
Docs.GetDocumentOrImport(key, Path.Combine(AppPaths.LegacyLearningFolder, legacyFileName));
// ── Stato, per l'interfaccia e il registro ─────────────────────── // ── Stato, per l'interfaccia e il registro ───────────────────────
@@ -178,12 +185,10 @@ namespace AutoBidder.Ml
// ── Valutazione dall'applicazione ──────────────────────────────── // ── Valutazione dall'applicazione ────────────────────────────────
private static string EvaluationFile => Path.Combine(Folder, "valutazione.txt");
/// <summary>L'ultimo rapporto di valutazione salvato, se c'è.</summary> /// <summary>L'ultimo rapporto di valutazione salvato, se c'è.</summary>
public static string? LastEvaluation() public static string? LastEvaluation()
{ {
try { return File.Exists(EvaluationFile) ? File.ReadAllText(EvaluationFile) : null; } try { return Doc(DocEvaluation, "valutazione.txt"); }
catch { return null; } catch { return null; }
} }
@@ -200,8 +205,7 @@ namespace AutoBidder.Ml
try try
{ {
Directory.CreateDirectory(Folder); Docs.SetDocument(DocEvaluation, $"Valutazione del {DateTime.Now:dd/MM/yyyy HH:mm}\n\n" + report.Text);
lock (SaveSync) File.WriteAllText(EvaluationFile, $"Valutazione del {DateTime.Now:dd/MM/yyyy HH:mm}\n\n" + report.Text);
} }
catch { /* il rapporto e' comunque restituito */ } catch { /* il rapporto e' comunque restituito */ }
@@ -235,6 +239,7 @@ namespace AutoBidder.Ml
/// </summary> /// </summary>
public static void Forget() public static void Forget()
{ {
lock (Sync) _closedSeen.Clear();
lock (Sync) lock (Sync)
{ {
_model = new OnlineLogit(BidFeatures.Size); _model = new OnlineLogit(BidFeatures.Size);
@@ -249,11 +254,7 @@ namespace AutoBidder.Ml
try try
{ {
lock (SaveSync) lock (SaveSync) Docs.DeleteDocuments("ml/");
{
foreach (var f in new[] { ModelFile, ProfileFile, CounterFile, LegacyManifestFile, EvaluationFile, ChallengerFile, BanditFile, BrierFile })
if (File.Exists(f)) File.Delete(f);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -282,17 +283,17 @@ namespace AutoBidder.Ml
try try
{ {
if (File.Exists(ModelFile)) if (Doc(DocModel, "modello-puntate.json") is { } model)
_model = OnlineLogit.FromJson(File.ReadAllText(ModelFile), BidFeatures.Size); _model = OnlineLogit.FromJson(model, BidFeatures.Size);
if (File.Exists(ProfileFile)) if (Doc(DocProfile, "profilo-aste.json") is { } profile)
_profile = AuctionProfileStats.FromJson(File.ReadAllText(ProfileFile)); _profile = AuctionProfileStats.FromJson(profile);
if (File.Exists(ChallengerFile)) if (Doc(DocChallenger, "sfidante-rete.json") is { } challenger)
_challenger = NeuralNet.FromJson(File.ReadAllText(ChallengerFile), new[] { BidFeatures.Size, 32, 16, 1 }); _challenger = NeuralNet.FromJson(challenger, new[] { BidFeatures.Size, 32, 16, 1 });
if (File.Exists(BanditFile)) if (Doc(DocBandit, "bandit.json") is { } bandit)
_bandit = ThompsonBandit.FromJson(File.ReadAllText(BanditFile)); _bandit = ThompsonBandit.FromJson(bandit);
if (File.Exists(BrierFile)) if (Doc(DocBrier, "brier.txt") is { } brierText)
{ {
var parts = File.ReadAllText(BrierFile).Split(';'); var parts = brierText.Split(';');
if (parts.Length >= 3 && if (parts.Length >= 3 &&
double.TryParse(parts[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var bl) && double.TryParse(parts[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var bl) &&
double.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var bn) && double.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var bn) &&
@@ -322,15 +323,26 @@ namespace AutoBidder.Ml
/// </summary> /// </summary>
private static int ReadCounter() private static int ReadCounter()
{ {
foreach (var f in new[] { CounterFile, LegacyManifestFile }) // Il contatore nuovo, oppure quello delle versioni precedenti, che stava in
// testa all'elenco dei dossier letti: non si azzera un modello che ha già
// studiato migliaia di aste.
var text = Doc(DocCounter, "aste-apprese.txt");
if (text == null)
{ {
if (!File.Exists(f)) continue; try
foreach (var line in File.ReadLines(f))
{ {
var l = line.Trim(); var legacy = Path.Combine(AppPaths.LegacyLearningFolder, "dossier-appresi.txt");
if (l.StartsWith("#appresi=", StringComparison.Ordinal) && int.TryParse(l["#appresi=".Length..], out var n)) return n; if (File.Exists(legacy)) text = File.ReadAllText(legacy);
if (int.TryParse(l, out var plain)) return plain;
} }
catch { }
}
if (string.IsNullOrEmpty(text)) return 0;
foreach (var line in text.Split('\n'))
{
var l = line.Trim();
if (l.StartsWith("#appresi=", StringComparison.Ordinal) && int.TryParse(l["#appresi=".Length..], out var n)) return n;
if (int.TryParse(l, out var plain)) return plain;
} }
return 0; return 0;
} }
@@ -356,13 +368,12 @@ namespace AutoBidder.Ml
lock (SaveSync) lock (SaveSync)
{ {
Directory.CreateDirectory(Folder); Docs.SetDocument(DocModel, model);
File.WriteAllText(ModelFile, model); Docs.SetDocument(DocProfile, profile);
File.WriteAllText(ProfileFile, profile); Docs.SetDocument(DocChallenger, challenger);
File.WriteAllText(ChallengerFile, challenger); Docs.SetDocument(DocBandit, bandit);
File.WriteAllText(BanditFile, bandit); Docs.SetDocument(DocBrier, brier);
File.WriteAllText(BrierFile, brier); Docs.SetDocument(DocCounter, "#appresi=" + learned);
File.WriteAllText(CounterFile, "#appresi=" + learned + Environment.NewLine);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -527,6 +538,19 @@ namespace AutoBidder.Ml
// ── Chiusura di un'asta: si impara subito ──────────────────────── // ── Chiusura di un'asta: si impara subito ────────────────────────
private static readonly HashSet<string> _closedSeen = new(StringComparer.Ordinal);
/// <summary>Vero se l'asta ha già <c>learned_at</c> nel database: studiata in una sessione precedente.</summary>
private static bool AlreadyLearned(string auctionId)
{
try
{
Db.Flush();
return Db.ScalarString("SELECT learned_at FROM auctions WHERE auction_id = ?1", auctionId) != null;
}
catch { return false; }
}
/// <summary> /// <summary>
/// Da chiamare quando un'asta è chiusa e la sua registrazione è stata completata. /// Da chiamare quando un'asta è chiusa e la sua registrazione è stata completata.
/// Aggiorna il profilo con lo storico appena registrato e il modello con le /// Aggiorna il profilo con lo storico appena registrato e il modello con le
@@ -534,7 +558,21 @@ namespace AutoBidder.Ml
/// </summary> /// </summary>
public static void OnAuctionClosed(CompletedAuctionRecord record) public static void OnAuctionClosed(CompletedAuctionRecord record)
{ {
if (record == null) return; if (record == null || string.IsNullOrEmpty(record.AuctionId)) return;
// Una volta per asta. La chiusura arrivava due volte (fine e rimozione dal
// monitor) e, al riavvio, di nuovo per le aste concluse rimaste in elenco:
// il modello, il profilo e il bandit le contavano due volte.
lock (Sync)
{
if (!_closedSeen.Add(record.AuctionId)) return;
}
if (AlreadyLearned(record.AuctionId))
{
OnLog?.Invoke($"[APPRENDIMENTO] '{record.Name}': già appresa, non la ristudio");
return;
}
try try
{ {
@@ -546,8 +584,8 @@ namespace AutoBidder.Ml
// Le righe dell'asta sono in coda: prima devono essere sul disco. // Le righe dell'asta sono in coda: prima devono essere sul disco.
Db.Flush(); Db.Flush();
var learned = !string.IsNullOrEmpty(record.AuctionId) && IngestAuction(record.AuctionId); var learned = IngestAuction(record.AuctionId);
var decisioni = string.IsNullOrEmpty(record.AuctionId) ? 0 : LearnBanditFromAuction(record.AuctionId); var decisioni = LearnBanditFromAuction(record.AuctionId);
Save(); Save();
+7 -5
View File
@@ -67,11 +67,13 @@ namespace AutoBidder.Ml
/// già coperto. Quindi il costo marginale nel ramo di sconfitta è c finché /// già coperto. Quindi il costo marginale nel ramo di sconfitta è c finché
/// <c>spese + c ≤ L V</c>, zero dopo.</para> /// <c>spese + c ≤ L V</c>, zero dopo.</para>
/// ///
/// <para>Per i buoni Bidoo Shop con V = L la copertura è totale dal primo /// <para>Con V = L la copertura è totale dal primo centesimo: ogni puntata ha
/// centesimo: ogni puntata ha valore atteso non negativo appena il margine è /// valore atteso non negativo appena il margine è positivo, a qualunque
/// positivo. È un'ipotesi forte — presume che si eserciti davvero il Compralo Ora /// probabilità — cioè si punta sempre. È un'ipotesi forte — presume che si
/// e che il buono valga davvero il suo nominale — e per questo è un'impostazione /// eserciti davvero il Compralo Ora e che il bene valga davvero il listino — e per
/// dell'utente, non un dato.</para> /// questo la strategia la applica solo quando l'utente ha scritto il valore reale
/// del prodotto (<c>BidStrategyService.HasExplicitValue</c>): con V lasciato al
/// listino la puntata si conta persa, come senza copertura.</para>
/// </summary> /// </summary>
public static double BidExpectedValue(in BidSituation s) public static double BidExpectedValue(in BidSituation s)
{ {
+7
View File
@@ -644,6 +644,13 @@ namespace AutoBidder.Models
[JsonIgnore] [JsonIgnore]
public int LastUsedOffsetMs { get; set; } = 0; public int LastUsedOffsetMs { get; set; } = 0;
/// <summary>
/// La chiusura è già stata comunicata a storico, statistiche e apprendimento.
/// Si salva con l'asta: un'asta conclusa che resta in elenco fino al riavvio non
/// deve essere contata di nuovo.
/// </summary>
public bool CompletionReported { get; set; }
/// <summary> /// <summary>
/// Indica se questa asta stata seguita dall'inizio (per salvare storia completa) /// Indica se questa asta stata seguita dall'inizio (per salvare storia completa)
/// </summary> /// </summary>
+22 -18
View File
@@ -152,7 +152,7 @@ namespace AutoBidder.Services
try try
{ {
var won = finalState.Status == AuctionStatus.EndedWon || IsAuctionWonByUser(auction); var won = finalState.Status == AuctionStatus.EndedWon || IsAuctionWonByUser(auction);
OnAuctionCompleted?.Invoke(auction, finalState, won); RaiseCompleted(auction, finalState, won);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -456,7 +456,7 @@ namespace AutoBidder.Services
// Il dossier registra tutto ciò che passa di qui: è materiale che esiste // Il dossier registra tutto ciò che passa di qui: è materiale che esiste
// solo adesso. // solo adesso.
var pollSettings = Settings; var pollSettings = Settings;
AuctionRecorder.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls); AuctionRecorder.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.RecordPolls);
if (state.RecentBidsHistory != null && state.RecentBidsHistory.Count > 0) if (state.RecentBidsHistory != null && state.RecentBidsHistory.Count > 0)
{ {
@@ -583,21 +583,6 @@ namespace AutoBidder.Services
rttMs: result?.LatencyMs ?? 0, rttMs: result?.LatencyMs ?? 0,
decisionId: auction.PendingDecisionId); decisionId: auction.PendingDecisionId);
// L'anticipo effettivo dice se il valore impostato e' quello giusto: da solo
// nel log non serviva a nulla, sommato su molte puntate diventa una taratura.
if (Settings.BidLeadTrackingEnabled && result != null)
{
BidLeadStats.Record(new BidLeadStats.Sample
{
At = DateTime.Now,
AuctionId = auction.AuctionId,
ConfiguredMs = leadMs,
ActualMs = actualLeadMs,
PingMs = auction.AverageLatencyMs,
Success = result.Success,
TooLate = !result.Success && IsLateBidError(result.Error)
});
}
} }
/// <summary>Il server ha rifiutato perche' il tempo era gia' scaduto.</summary> /// <summary>Il server ha rifiutato perche' il tempo era gia' scaduto.</summary>
@@ -686,7 +671,7 @@ namespace AutoBidder.Services
try try
{ {
OnAuctionCompleted?.Invoke(auction, state, won); RaiseCompleted(auction, state, won);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -694,6 +679,25 @@ namespace AutoBidder.Services
} }
} }
private readonly object _completionGate = new();
/// <summary>
/// Comunica la chiusura di un'asta una volta sola. L'asta finisce (stato
/// terminale) e poco dopo viene tolta dall'elenco, e tutti e due i passaggi
/// arrivavano qui: storico, statistiche per prodotto, profilo e modello la
/// contavano due volte, e il bandit rivedeva gli stessi esiti.
/// </summary>
private void RaiseCompleted(AuctionInfo auction, AuctionState state, bool won)
{
lock (_completionGate)
{
if (auction.CompletionReported) return;
auction.CompletionReported = true;
}
OnAuctionCompleted?.Invoke(auction, state, won);
}
/// <summary> /// <summary>
/// Aggiorna il valore calcolato del prodotto /// Aggiorna il valore calcolato del prodotto
/// </summary> /// </summary>
+30 -1
View File
@@ -47,6 +47,23 @@ namespace AutoBidder.Services
return scheda?.MarketValue is > 0 ? scheda.MarketValue : auction.BuyNowPrice; return scheda?.MarketValue is > 0 ? scheda.MarketValue : auction.BuyNowPrice;
} }
/// <summary>
/// Vero se l'utente ha scritto il valore reale del prodotto nella scheda Prodotti.
///
/// <para>Conta per la copertura «Compralo Ora»: senza un valore scelto, V coincide
/// con il listino L, la perdita coperta L V è zero e ogni puntata risulterebbe
/// gratis nel ramo di sconfitta — cioè si punterebbe sempre, a qualunque
/// probabilità. Nelle osservazioni in shadow è successo esattamente questo: BID
/// con P(senza risposta) dell'1%. La copertura vale solo quando l'utente ha
/// detto quanto vale davvero il bene.</para>
/// </summary>
public static bool HasExplicitValue(AuctionInfo auction)
{
if (auction == null) return false;
var scheda = WatchedProductsStore.FindByName(auction.Name);
return scheda?.MarketValue is > 0;
}
public BidDecision ShouldPlaceBid( public BidDecision ShouldPlaceBid(
AuctionInfo auction, AuctionInfo auction,
AuctionState state, AuctionState state,
@@ -92,6 +109,18 @@ namespace AutoBidder.Services
} }
} }
// ── Apprendimento spento: solo i limiti ────────────────────────
//
// L'interruttore in barra. Spento, da qui in poi non si decide più niente:
// né duello, né valore atteso, né regime, né bandit. Si punta, entro i limiti
// già controllati sopra, come un cecchino senza cervello.
if (!settings.LearningEnabled)
{
decision.Policy = "solo-limiti";
decision.Reason = "apprendimento spento: si punta entro i limiti (prezzo, puntate, budget, fascia oraria)";
return decision;
}
// ── 3. Duello con l'autopuntata ──────────────────────────────── // ── 3. Duello con l'autopuntata ────────────────────────────────
if (auction.AutoBidDuelDetected) if (auction.AutoBidDuelDetected)
{ {
@@ -134,7 +163,7 @@ namespace AutoBidder.Services
Shipping: auction.ShippingCost ?? 0, Shipping: auction.ShippingCost ?? 0,
ListPrice: auction.BuyNowPrice, ListPrice: auction.BuyNowPrice,
BidsSpentEuro: speso, BidsSpentEuro: speso,
HedgeEnabled: settings.BuyNowHedgeEnabled); HedgeEnabled: settings.BuyNowHedgeEnabled && HasExplicitValue(auction));
var valore = Theory.BidExpectedValue(situazione); var valore = Theory.BidExpectedValue(situazione);
+10 -3
View File
@@ -47,11 +47,18 @@ namespace AutoBidder.Services
if (html.Length < 200) if (html.Length < 200)
return FreeBidsPageScan.Unrecognised($"risposta troppo corta ({html.Length} caratteri)"); return FreeBidsPageScan.Unrecognised($"risposta troppo corta ({html.Length} caratteri)");
if (LooksLikeLogin(html, config)) // Prima si guarda se è la pagina delle ricompense: anche da collegati la
// pagina porta un collegamento a /login.php (menu, modulo nascosto), e
// giudicarla «pagina di accesso» per quello dava una sessione scaduta che
// non c'era. Solo una pagina senza nessun segno delle ricompense, e con i
// segni dell'accesso, è davvero il modulo di login.
var markers = config.PageRecognition.PageMarkers;
var looksLikeRewards = markers.Count > 0 && markers.Any(m => Contains(html, m));
if (!looksLikeRewards && LooksLikeLogin(html, config))
return FreeBidsPageScan.Unrecognised("sessione scaduta: Bidoo ha risposto con la pagina di accesso"); return FreeBidsPageScan.Unrecognised("sessione scaduta: Bidoo ha risposto con la pagina di accesso");
var markers = config.PageRecognition.PageMarkers; if (markers.Count > 0 && !looksLikeRewards)
if (markers.Count > 0 && !markers.Any(m => Contains(html, m)))
return FreeBidsPageScan.Unrecognised("pagina delle ricompense non riconosciuta (formato cambiato?)"); return FreeBidsPageScan.Unrecognised("pagina delle ricompense non riconosciuta (formato cambiato?)");
var patterns = PatternsFor(config); var patterns = PatternsFor(config);
+5 -51
View File
@@ -15,10 +15,7 @@ namespace AutoBidder.Services
/// <para><b>Quattro vincoli, tutti in euro o in numero, tutti dell'utente.</b> /// <para><b>Quattro vincoli, tutti in euro o in numero, tutti dell'utente.</b>
/// Il tetto di spesa giornaliero, lo stop-loss giornaliero (quando la perdita netta /// Il tetto di spesa giornaliero, lo stop-loss giornaliero (quando la perdita netta
/// del giorno supera la soglia si entra in HALT), il drawdown massimo dal picco del /// del giorno supera la soglia si entra in HALT), il drawdown massimo dal picco del
/// saldo cumulato, e il numero massimo di aste su cui puntare insieme. Sopra a tutti, /// saldo cumulato, e il numero massimo di aste su cui puntare insieme.</para>
/// il <b>kill-switch</b>: un file sentinella in <c>%LocalAppData%\AutoBidder</c> che,
/// finché esiste, ferma ogni puntata dal vivo — lo si può creare anche da fuori,
/// senza aprire l'applicazione.</para>
/// ///
/// <para><b>HALT è persistente.</b> Si scrive nel database e sopravvive al riavvio: /// <para><b>HALT è persistente.</b> Si scrive nel database e sopravvive al riavvio:
/// una regola di sicurezza che si azzera chiudendo e riaprendo il programma non è una /// una regola di sicurezza che si azzera chiudendo e riaprendo il programma non è una
@@ -47,45 +44,6 @@ namespace AutoBidder.Services
private static string? _haltReason; private static string? _haltReason;
private static bool _haltLoaded; private static bool _haltLoaded;
private static DateTime _killSwitchCheckedAt = DateTime.MinValue;
private static bool _killSwitchState;
// ── Kill-switch ──────────────────────────────────────────────────
/// <summary>Il file sentinella: finché esiste, nessuna puntata dal vivo.</summary>
public static string KillSwitchFile => Path.Combine(AppPaths.Root, "KILL_SWITCH");
/// <summary>Vero se il file c'è. Si rilegge al massimo una volta al secondo.</summary>
public static bool KillSwitchActive
{
get
{
lock (Sync)
{
if ((DateTime.UtcNow - _killSwitchCheckedAt).TotalSeconds >= 1)
{
_killSwitchState = File.Exists(KillSwitchFile);
_killSwitchCheckedAt = DateTime.UtcNow;
}
return _killSwitchState;
}
}
}
public static void SetKillSwitch(bool on)
{
lock (Sync)
{
try
{
if (on) File.WriteAllText(KillSwitchFile, $"creato il {DateTime.Now:yyyy-MM-dd HH:mm:ss}\n");
else if (File.Exists(KillSwitchFile)) File.Delete(KillSwitchFile);
}
catch { }
_killSwitchState = on;
_killSwitchCheckedAt = DateTime.UtcNow;
}
}
// ── HALT ───────────────────────────────────────────────────────── // ── HALT ─────────────────────────────────────────────────────────
@@ -206,16 +164,13 @@ namespace AutoBidder.Services
// ── Autorizzazione ─────────────────────────────────────────────── // ── Autorizzazione ───────────────────────────────────────────────
/// <summary> /// <summary>
/// Può partire una puntata dal vivo su quest'asta? Controlla, nell'ordine: il /// Può partire una puntata dal vivo su quest'asta? Controlla, nell'ordine:
/// kill-switch, l'HALT, lo stop-loss del giorno, il drawdown, il tetto di spesa /// l'HALT, lo stop-loss del giorno, il drawdown, il tetto di spesa del giorno, le
/// del giorno, le aste in gioco insieme. Le prime due sono assolute; le altre /// aste in gioco insieme. Il primo è assoluto; gli altri valgono solo se impostati
/// valgono solo se impostate (0 = nessun limite). /// (0 = nessun limite).
/// </summary> /// </summary>
public static Verdict Authorize(AuctionInfo auction, AppSettings settings, IReadOnlyList<AuctionInfo>? allAuctions = null) public static Verdict Authorize(AuctionInfo auction, AppSettings settings, IReadOnlyList<AuctionInfo>? allAuctions = null)
{ {
if (KillSwitchActive)
return new Verdict(false, "kill-switch attivo: nessuna puntata dal vivo finché esiste il file KILL_SWITCH");
if (Halted) if (Halted)
return new Verdict(false, $"HALT del gestore del rischio: {HaltReason}. Si toglie dalle Impostazioni"); return new Verdict(false, $"HALT del gestore del rischio: {HaltReason}. Si toglie dalle Impostazioni");
@@ -271,7 +226,6 @@ namespace AutoBidder.Services
_ledgerLoaded = false; _ledgerLoaded = false;
_haltLoaded = false; _haltLoaded = false;
_haltReason = null; _haltReason = null;
_killSwitchCheckedAt = DateTime.MinValue;
} }
} }
} }
+3 -3
View File
@@ -21,8 +21,8 @@ public class AuctionRecorderTests
private static AppSettings Settings(bool rawPolls = true) => new() private static AppSettings Settings(bool rawPolls = true) => new()
{ {
WriteAuctionDossiers = true, RecordAuctions = true,
DossierIncludeRawPolls = rawPolls RecordPolls = rawPolls
}; };
private static AuctionInfo NewAuction() => new() private static AuctionInfo NewAuction() => new()
@@ -249,6 +249,6 @@ public class AuctionRecorderTests
public void Con_la_registrazione_spenta_non_si_apre_nulla() public void Con_la_registrazione_spenta_non_si_apre_nulla()
{ {
var auction = NewAuction(); var auction = NewAuction();
Assert.Null(AuctionRecorder.OpenFor(auction, new AppSettings { WriteAuctionDossiers = false })); Assert.Null(AuctionRecorder.OpenFor(auction, new AppSettings { RecordAuctions = false }));
} }
} }
-223
View File
@@ -1,223 +0,0 @@
using AutoBidder.Models;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// La taratura dell'anticipo tocca il parametro che decide se una puntata arriva o no.
/// Un consiglio troppo aggressivo si paga in aste perse, quindi la proprietà da
/// verificare è la prudenza: mai scendere sotto il margine di rete.
/// </summary>
public class BidLeadAdviceTests
{
// Le prove usano lo store reale, quindi si parte sempre da pulito.
private static void Reset() => BidLeadStats.Clear();
private static void Add(int configured, double actual, double ping = 60, bool ok = true, bool late = false)
=> BidLeadStats.Record(new BidLeadStats.Sample
{
AuctionId = "t",
ConfiguredMs = configured,
ActualMs = actual,
PingMs = ping,
Success = ok,
TooLate = late
});
[Fact]
public void Senza_dati_sufficienti_non_da_consigli()
{
Reset();
for (var i = 0; i < 5; i++) Add(800, 700);
var advice = BidLeadStats.Suggest(currentLeadMs: 800, minSamples: 15);
Assert.False(advice.HasAdvice);
Assert.Equal(800, advice.SuggestedLeadMs);
}
[Fact]
public void Con_margine_ampio_propone_di_scendere()
{
Reset();
// Le puntate arrivano sempre ~1700 ms prima della scadenza, ping 60 ms:
// c'è spazio abbondante sopra il margine di sicurezza.
for (var i = 0; i < 20; i++) Add(1800, 1700 + i % 5, ping: 60);
var advice = BidLeadStats.Suggest(1800, minSamples: 15);
Assert.True(advice.HasAdvice);
Assert.True(advice.SuggestedLeadMs < 1800, "avrebbe dovuto proporre di scendere");
Assert.True(advice.SuggestedLeadMs >= BidLeadStats.SafeFloorMs,
"non deve scendere sotto il pavimento ricavato dai dossier");
}
[Fact]
public void Non_scende_mai_sotto_il_pavimento_anche_con_ping_minimo()
{
Reset();
// Ping bassissimo e puntate larghissime: la vecchia regola avrebbe proposto
// un anticipo di poche centinaia di ms, dove basta un picco di rete per
// arrivare a giochi chiusi (p99,9 del ping misurato: 444 ms).
for (var i = 0; i < 20; i++) Add(3000, 2900, ping: 10);
var advice = BidLeadStats.Suggest(3000, minSamples: 15);
Assert.True(advice.SuggestedLeadMs >= BidLeadStats.SafeFloorMs,
$"proposto {advice.SuggestedLeadMs}, sotto il pavimento di {BidLeadStats.SafeFloorMs} ms");
}
[Fact]
public void Senza_misure_avverte_comunque_se_lanticipo_e_fuori_fascia()
{
Reset();
// Troppo stretto: il rischio è la puntata che arriva tardi.
var stretto = BidLeadStats.Suggest(200, minSamples: 15);
Assert.True(stretto.HasAdvice);
Assert.Equal(BidLeadStats.SafeFloorMs, stretto.SuggestedLeadMs);
// Troppo largo: il costo sono le puntate spese in cicli che si sarebbero
// risolti da soli.
var largo = BidLeadStats.Suggest(4000, minSamples: 15);
Assert.True(largo.HasAdvice);
Assert.Equal(BidLeadStats.SafeCeilingMs, largo.SuggestedLeadMs);
// Dentro la fascia, senza misure non c'è nulla da dire.
var giusto = BidLeadStats.Suggest(1000, minSamples: 15);
Assert.False(giusto.HasAdvice);
}
[Fact]
public void Non_scende_mai_sotto_il_margine_di_rete()
{
Reset();
// Ping alto: il margine di sicurezza è grande e va rispettato.
for (var i = 0; i < 20; i++) Add(2000, 1900, ping: 400);
var advice = BidLeadStats.Suggest(2000, minSamples: 15);
if (advice.HasAdvice)
{
// safety = ping (400) + 150 = 550
Assert.True(advice.SuggestedLeadMs >= 550,
$"proposto {advice.SuggestedLeadMs}, sotto il margine di sicurezza");
}
}
[Fact]
public void Con_puntate_tardive_propone_di_allargare()
{
Reset();
for (var i = 0; i < 18; i++) Add(300, 120, ping: 90);
for (var i = 0; i < 3; i++) Add(300, -20, ping: 90, ok: false, late: true);
var advice = BidLeadStats.Suggest(300, minSamples: 15);
Assert.True(advice.HasAdvice);
Assert.True(advice.SuggestedLeadMs > 300, "con puntate tardive deve allargare, non stringere");
}
[Fact]
public void Se_l_anticipo_e_gia_stretto_non_propone_nulla()
{
Reset();
// Anticipo 250 ms, ping 60: margine 210, quasi nessuno spazio.
for (var i = 0; i < 20; i++) Add(250, 240, ping: 60);
var advice = BidLeadStats.Suggest(250, minSamples: 15);
Assert.False(advice.HasAdvice);
}
[Fact]
public void Il_riepilogo_conta_riuscite_e_tardive()
{
Reset();
for (var i = 0; i < 10; i++) Add(800, 700);
Add(800, -5, ok: false, late: true);
var s = BidLeadStats.Summarize();
Assert.Equal(11, s.Count);
Assert.Equal(10, s.Successes);
Assert.Equal(1, s.TooLate);
// La media considera solo le puntate accettate: una rifiutata non è mai arrivata.
Assert.Equal(700, s.AverageActualMs, 1);
}
}
/// <summary>
/// La scheda dettagliata è la materia prima per migliorare le strategie: i valori
/// derivati devono essere corretti, o le analisi partiranno da numeri sbagliati.
/// </summary>
public class AuctionDetailRecordTests
{
[Fact]
public void La_quota_del_puntatore_principale_e_una_percentuale()
{
var bids = new Dictionary<string, int> { ["tizio"] = 6, ["caio"] = 3, ["sempronio"] = 1 };
Assert.Equal(60.0, AuctionDetailRecord.ComputeTopShare(bids), 1);
}
[Fact]
public void Senza_puntatori_la_quota_e_zero()
{
Assert.Equal(0, AuctionDetailRecord.ComputeTopShare(new Dictionary<string, int>()));
}
[Fact]
public void Il_costo_reale_somma_prezzo_puntate_e_spedizione()
{
var r = new AuctionDetailRecord { FinalPrice = 12.50, MyBids = 40, ShippingCost = 5.90 };
// 12,50 + 40 x 0,20 + 5,90 = 26,40
Assert.Equal(26.40, r.TotalCostIfWon(0.20), 2);
}
[Fact]
public void Il_rapporto_col_valore_prodotto_manca_se_il_valore_e_ignoto()
{
Assert.Null(new AuctionDetailRecord { FinalPrice = 10 }.FinalPriceRatio);
Assert.Equal(0.1, new AuctionDetailRecord { FinalPrice = 10, BuyNowPrice = 100 }.FinalPriceRatio!.Value, 3);
}
}
/// <summary>La serie prezzi si raccoglie solo mentre l'asta vive: va registrata bene.</summary>
public class PriceSeriesTests
{
[Fact]
public void Registra_solo_i_cambi_di_prezzo()
{
var a = new AuctionInfo();
a.TrackPrice(1.00);
a.TrackPrice(1.00); // identico: ignorato
a.TrackPrice(1.02);
Assert.Equal(2, a.SnapshotPriceSeries().Count);
}
[Fact]
public void Ignora_i_prezzi_non_validi()
{
var a = new AuctionInfo();
a.TrackPrice(0);
a.TrackPrice(-1);
Assert.Empty(a.SnapshotPriceSeries());
}
[Fact]
public void Non_cresce_oltre_il_tetto()
{
var a = new AuctionInfo();
for (var i = 1; i <= 1500; i++) a.TrackPrice(i * 0.01);
// Il diradamento tiene la serie sotto controllo senza perderne la forma.
Assert.True(a.SnapshotPriceSeries().Count <= 600);
}
}
+1 -140
View File
@@ -13,7 +13,7 @@ namespace AutoBidder.Tests;
/// un'asta sbagliata non si nota — si notano invece le conclusioni sbagliate che se ne /// un'asta sbagliata non si nota — si notano invece le conclusioni sbagliate che se ne
/// traggono un mese dopo, quando non c'è più modo di capire da dove venivano. /// traggono un mese dopo, quando non c'è più modo di capire da dove venivano.
/// </summary> /// </summary>
public class AuctionExportFilterTests public class AuctionDetailRecordTests
{ {
private static AuctionDetailRecord Record( private static AuctionDetailRecord Record(
string id = "1", string id = "1",
@@ -67,145 +67,6 @@ public class AuctionExportFilterTests
Assert.Equal(0.1, withValue.FinalPriceRatio!.Value, 3); Assert.Equal(0.1, withValue.FinalPriceRatio!.Value, 3);
} }
[Fact]
public void I_filtri_predefiniti_lasciano_passare_tutto()
{
var filter = new AuctionExportFilter();
Assert.True(filter.IncludeWon);
Assert.True(filter.IncludeLost);
Assert.True(filter.IncludeClosed);
Assert.False(filter.OnlyComplete);
Assert.Equal(0, filter.MaxAuctions);
}
[Fact]
public void Il_percorso_predefinito_sta_nella_cartella_esportazioni()
{
var path = AuctionExporter.DefaultPath();
Assert.StartsWith(AppPaths.ExportFolder, path);
Assert.EndsWith(".json", path);
}
[Fact]
public void Senza_storico_lesportazione_lo_dice_invece_di_scrivere_un_file_vuoto()
{
var result = AuctionExporter.Export(new AuctionExportFilter
{
// Un periodo in cui non può esserci nulla.
From = new DateTime(1990, 1, 1),
To = new DateTime(1990, 1, 2)
});
Assert.False(result.Success);
Assert.Equal(0, result.AuctionCount);
Assert.Contains("nessuna asta", result.Message);
}
}
/// <summary>
/// Il registro passa da una coda: chi scrive non aspetta il disco. La proprietà da
/// difendere è che le righe arrivino comunque nel file, e nell'ordine giusto — un registro
/// che perde o mescola le righe è peggio di nessun registro, perché lo si legge credendogli.
/// </summary>
public class FileLogWriterTests
{
private static string TempFile() =>
Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", $"log-{Guid.NewGuid():N}.log");
[Fact]
public void Quello_che_si_scrive_finisce_nel_file()
{
var path = TempFile();
var writer = FileLogWriter.For(path);
writer.Write("prima");
writer.Write("seconda");
writer.Flush();
var lines = File.ReadAllLines(path);
Assert.Equal(new[] { "prima", "seconda" }, lines);
writer.Dispose();
}
[Fact]
public void Lo_stesso_percorso_da_sempre_lo_stesso_scrittore()
{
// Due scrittori sullo stesso file mescolerebbero le righe fra loro.
var path = TempFile();
Assert.Same(FileLogWriter.For(path), FileLogWriter.For(path));
FileLogWriter.For(path).Dispose();
}
[Fact]
public void Le_righe_si_accodano_a_quelle_gia_scritte()
{
var path = TempFile();
var first = FileLogWriter.For(path);
first.Write("sessione 1");
first.Flush();
first.Dispose();
var second = FileLogWriter.For(path);
second.Write("sessione 2");
second.Flush();
second.Dispose();
Assert.Equal(new[] { "sessione 1", "sessione 2" }, File.ReadAllLines(path));
}
[Fact]
public void Un_percorso_impossibile_non_solleva()
{
// Il registro non deve mai poter interrompere un'asta.
var writer = FileLogWriter.For(Path.Combine("Z:\\", "inesistente", "x.log"));
writer.Write("qualcosa");
writer.Flush();
writer.Dispose();
}
[Fact]
public void I_registri_scaduti_si_cancellano_e_quelli_recenti_no()
{
var folder = Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
var old = Path.Combine(folder, "app-2020-01-01.log");
var recent = Path.Combine(folder, "app-oggi.log");
File.WriteAllText(old, "vecchio");
File.WriteAllText(recent, "recente");
File.SetLastWriteTime(old, DateTime.Now.AddDays(-120));
var removed = FileLogWriter.PurgeOlderThan(folder, "app-*.log", days: 90);
Assert.Equal(1, removed);
Assert.False(File.Exists(old));
Assert.True(File.Exists(recent));
}
[Fact]
public void Con_zero_giorni_non_si_cancella_nulla()
{
// 0 = "tienili per sempre": interpretarlo come "cancellali tutti" distruggerebbe
// esattamente i dati che si voleva conservare.
var folder = Path.Combine(Path.GetTempPath(), "AutoBidder.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
var file = Path.Combine(folder, "app-vecchio.log");
File.WriteAllText(file, "x");
File.SetLastWriteTime(file, DateTime.Now.AddYears(-3));
Assert.Equal(0, FileLogWriter.PurgeOlderThan(folder, "app-*.log", days: 0));
Assert.True(File.Exists(file));
}
} }
/// <summary> /// <summary>
+59
View File
@@ -0,0 +1,59 @@
using System;
using AutoBidder.Ml;
using AutoBidder.Models;
using AutoBidder.Services;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// La copertura «Compralo Ora» con V lasciato al listino rende gratis ogni puntata: la
/// strategia la concede solo ai prodotti con il valore reale scritto dall'utente.
/// </summary>
[Collection("Database")]
public class HedgeRequiresValueTests
{
[Fact]
public void Con_V_uguale_al_listino_la_copertura_rende_gratis_ogni_puntata()
{
// È il caso degenere: P dell'uno per mille e valore atteso comunque positivo.
var s = new Theory.BidSituation(PWin: 0.001, Value: 11, CurrentPrice: 1.48, BidCost: 0.20,
TransactionFee: 1, Shipping: 0, ListPrice: 11, BidsSpentEuro: 0, HedgeEnabled: true);
Assert.Equal(0, Theory.LossBranchCost(s));
Assert.True(Theory.BidExpectedValue(s) > 0);
// Senza copertura la stessa puntata è in perdita: P deve superare c / (guadagno + c).
var senza = s with { HedgeEnabled = false };
Assert.Equal(0.20, Theory.LossBranchCost(senza));
Assert.True(Theory.BidExpectedValue(senza) < 0);
}
[Fact]
public void La_copertura_vale_solo_con_il_valore_reale_scritto()
{
var nome = $"Buono di prova {Guid.NewGuid():N}";
var catalogo = new CatalogAuction { AuctionId = "prova-copertura", Name = nome, BuyNowPrice = 11m, ProductId = 987654 };
Assert.True(WatchedProductsStore.Add(catalogo));
try
{
var asta = new AuctionInfo { Name = nome, BuyNowPrice = 11 };
Assert.False(BidStrategyService.HasExplicitValue(asta));
Assert.Equal(11, BidStrategyService.ValueOf(asta));
var scheda = WatchedProductsStore.FindByName(nome);
Assert.NotNull(scheda);
scheda!.MarketValue = 9.5;
WatchedProductsStore.Persist();
Assert.True(BidStrategyService.HasExplicitValue(asta));
Assert.Equal(9.5, BidStrategyService.ValueOf(asta));
}
finally
{
WatchedProductsStore.Remove(catalogo);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Data;
using AutoBidder.Ml;
using AutoBidder.Models;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// La chiusura di un'asta arrivava due volte all'apprendimento (fine, poi rimozione dal
/// monitor) e di nuovo al riavvio: la stessa asta va studiata una volta sola.
/// </summary>
[Collection("Database")]
public class LearningDoubleCloseTests
{
[Fact]
public void Una_asta_gia_appresa_non_viene_ristudiata()
{
var db = AuctionDatabase.Instance;
var id = "doppia-" + Guid.NewGuid().ToString("N");
var now = AuctionDatabase.Now();
db.ExecuteNow(
"INSERT INTO auctions(auction_id, product_key, name, added_at, first_seen_at, opened_at, status, outcome, learned_at) " +
"VALUES(?1, 'prova', 'Prova doppia', ?2, ?2, ?2, 'closed', 'Persa', ?2)", id, now);
var apprese = LearningService.AuctionsLearned;
var profilo = LearningService.ProfileAuctions;
var messaggi = new List<string>();
void Raccogli(string m) { lock (messaggi) messaggi.Add(m); }
LearningService.OnLog += Raccogli;
try
{
var record = new CompletedAuctionRecord { AuctionId = id, Name = "Prova doppia", ProductKey = "prova", EndedAt = DateTime.Now };
LearningService.OnAuctionClosed(record);
LearningService.OnAuctionClosed(record);
}
finally
{
LearningService.OnLog -= Raccogli;
}
Assert.Equal(apprese, LearningService.AuctionsLearned);
Assert.Equal(profilo, LearningService.ProfileAuctions);
// La prima chiamata vede learned_at e lo dice; la seconda si ferma prima ancora
// di guardare il database.
Assert.Equal(1, messaggi.Count(m => m.Contains("già appresa")));
}
}
+110
View File
@@ -0,0 +1,110 @@
using System;
using System.IO;
using System.Linq;
using AutoBidder.Data;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Il database d'esercizio: documenti per chiave, importazione una tantum dei vecchi
/// file JSON, registri che si scrivono, si rileggono, si esportano e scadono.
/// </summary>
[Collection("Database")]
public class OperationalDatabaseTests
{
private static OperationalDatabase Db => OperationalDatabase.Instance;
[Fact]
public void Un_documento_si_scrive_e_si_rilegge()
{
var key = "test/" + Guid.NewGuid().ToString("N");
Db.SetDocument(key, "{\"a\":1}");
Assert.Equal("{\"a\":1}", Db.GetDocument(key));
Db.SetDocument(key, "{\"a\":2}");
Assert.Equal("{\"a\":2}", Db.GetDocument(key));
Db.DeleteDocument(key);
Db.Flush();
Assert.Null(Db.GetDocument(key));
}
[Fact]
public void Il_vecchio_file_si_importa_una_volta_sola()
{
var key = "test/import-" + Guid.NewGuid().ToString("N");
var legacy = Path.Combine(Path.GetTempPath(), $"legacy-{Guid.NewGuid():N}.json");
File.WriteAllText(legacy, "{\"vecchio\":true}");
try
{
Assert.Equal("{\"vecchio\":true}", Db.GetDocumentOrImport(key, legacy));
// Il file cambia, ma il documento è già nel database: comanda il database.
File.WriteAllText(legacy, "{\"vecchio\":false}");
Assert.Equal("{\"vecchio\":true}", Db.GetDocumentOrImport(key, legacy));
Assert.True(File.Exists(legacy)); // resta dov'è
}
finally { File.Delete(legacy); }
}
[Fact]
public void Senza_documento_ne_file_si_ha_null()
{
Assert.Null(Db.GetDocumentOrImport("test/niente-" + Guid.NewGuid().ToString("N"), Path.Combine(Path.GetTempPath(), "non-esiste.json")));
}
[Fact]
public void Il_registro_si_scrive_si_rilegge_e_si_esporta()
{
var marker = "riga-" + Guid.NewGuid().ToString("N");
Db.WriteLog(OperationalDatabase.AppLog, "INFO", marker);
Db.WriteLog(OperationalDatabase.AppLog, "WARN", marker + "-due");
var rows = Db.ReadLog(OperationalDatabase.AppLog, 10);
Assert.Contains(rows, r => r.Message == marker && r.Level == "INFO");
Assert.Equal(marker + "-due", rows[0].Message); // dalla più recente
var file = Path.Combine(Path.GetTempPath(), $"log-{Guid.NewGuid():N}.txt");
try
{
var n = Db.ExportLog(OperationalDatabase.AppLog, file);
Assert.True(n >= 2);
var text = File.ReadAllText(file);
Assert.Contains(marker, text);
Assert.Contains("[WARN]", text);
}
finally { File.Delete(file); }
}
[Fact]
public void Le_righe_vecchie_scadono()
{
var old = SqliteDatabase.Iso(DateTime.Now.AddDays(-200));
Db.ExecuteNow($"INSERT INTO {OperationalDatabase.FreeBidsLog}(ts, level, message) VALUES(?1, 'INFO', 'antica')", old);
Db.WriteLog(OperationalDatabase.FreeBidsLog, "INFO", "recente");
var removed = Db.PurgeLogs(90);
Assert.True(removed >= 1);
var rows = Db.ReadLog(OperationalDatabase.FreeBidsLog, 100);
Assert.DoesNotContain(rows, r => r.Message == "antica");
Assert.Contains(rows, r => r.Message == "recente");
}
[Fact]
public void Le_aste_nel_monitor_vivono_nel_documento()
{
PersistenceManager.SaveAuctions(new[]
{
new Models.AuctionInfo { AuctionId = "doc-1", Name = "Uno" },
new Models.AuctionInfo { AuctionId = "doc-2", Name = "Due" }
});
var back = PersistenceManager.LoadAuctions();
Assert.Equal(2, back.Count);
Assert.Contains(back, a => a.AuctionId == "doc-2" && a.Name == "Due");
}
}
-18
View File
@@ -24,29 +24,11 @@ public class RiskManagerTests
private static void Pulito() private static void Pulito()
{ {
RiskManager.SetKillSwitch(false);
AuctionDatabase.Instance.ExecuteNow("DELETE FROM risk_ledger"); AuctionDatabase.Instance.ExecuteNow("DELETE FROM risk_ledger");
AuctionDatabase.Instance.ExecuteNow("DELETE FROM meta WHERE key = 'risk_halt'"); AuctionDatabase.Instance.ExecuteNow("DELETE FROM meta WHERE key = 'risk_halt'");
RiskManager.ResetForTests(); RiskManager.ResetForTests();
} }
[Fact]
public void Il_kill_switch_ferma_ogni_puntata()
{
Pulito();
RiskManager.SetKillSwitch(true);
try
{
var v = RiskManager.Authorize(Auction("a"), new AppSettings());
Assert.False(v.Allowed);
Assert.Contains("kill-switch", v.Reason);
Assert.True(File.Exists(RiskManager.KillSwitchFile));
}
finally { RiskManager.SetKillSwitch(false); }
Assert.True(RiskManager.Authorize(Auction("a"), new AppSettings()).Allowed);
}
[Fact] [Fact]
public void Lo_stop_loss_porta_in_halt_e_l_halt_resta() public void Lo_stop_loss_porta_in_halt_e_l_halt_resta()
{ {
+14 -36
View File
@@ -6,39 +6,35 @@ using Xunit;
namespace AutoBidder.Tests; namespace AutoBidder.Tests;
/// <summary> /// <summary>
/// L'azzeramento tocca solo le voci scelte, e riconosce gli archivi mensili delle /// L'azzeramento tocca solo le voci scelte: gli archivi delle versioni precedenti sono
/// versioni precedenti dal nome senza confonderli con il resto della cartella. /// la vecchia cartella Dati, e senza voci scelte non succede nulla.
/// </summary> /// </summary>
[Collection("Database")] [Collection("Database")]
public class StatsWipeTests public class StatsWipeTests
{ {
[Fact] [Fact]
public void Toglie_gli_archivi_mensili_e_lascia_il_resto() public void Toglie_la_vecchia_cartella_dei_dati_e_lascia_il_resto()
{ {
var stats = AppPaths.StatsFolder; var legacy = AppPaths.LegacyDataFolder;
Directory.CreateDirectory(stats); Directory.CreateDirectory(Path.Combine(legacy, "Statistiche"));
var vecchio = Path.Combine(legacy, "Statistiche", "aste-2020-01.jsonl");
File.WriteAllText(vecchio, "{}\n");
var legacy = Path.Combine(stats, "aste-2020-01.jsonl"); var altro = Path.Combine(AppPaths.ConfigRoot, $"altro-{Guid.NewGuid():N}.json");
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, "[]"); File.WriteAllText(altro, "[]");
try try
{ {
var prima = StatsWipe.Measure(); var prima = StatsWipe.Measure();
Assert.True(prima.LegacyFiles >= 2); Assert.True(prima.LegacyFiles >= 1);
var report = StatsWipe.Run(new StatsWipe.Options var report = StatsWipe.Run(new StatsWipe.Options
{ {
History = false, ProductStats = false, BidLeadMeasures = false, History = false, ProductStats = false, LegacyArchives = true, Learning = false
Exports = false, LegacyArchives = true, Learning = false
}); });
Assert.False(File.Exists(legacy)); Assert.False(File.Exists(vecchio));
Assert.False(File.Exists(legacyJson)); Assert.False(Directory.Exists(legacy));
Assert.True(File.Exists(altro)); Assert.True(File.Exists(altro));
Assert.Null(report.BackupPath); Assert.Null(report.BackupPath);
Assert.Empty(report.Errors); Assert.Empty(report.Errors);
@@ -46,32 +42,14 @@ public class StatsWipeTests
} }
finally finally
{ {
foreach (var f in new[] { legacy, legacyJson, altro }) if (File.Exists(f)) File.Delete(f); if (File.Exists(altro)) File.Delete(altro);
} }
} }
[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] [Fact]
public void Senza_voci_scelte_non_fa_nulla() public void Senza_voci_scelte_non_fa_nulla()
{ {
var o = new StatsWipe.Options var o = new StatsWipe.Options { History = false, ProductStats = false, LegacyArchives = false, Learning = false };
{
History = false, ProductStats = false, BidLeadMeasures = false,
Exports = false, LegacyArchives = false, Learning = false
};
Assert.True(o.Nothing); Assert.True(o.Nothing);
var report = StatsWipe.Run(o); var report = StatsWipe.Run(o);
+1 -4
View File
@@ -24,10 +24,7 @@ internal static class TestEnvironment
// leggerebbe e scriverebbe quelle vere di chi lancia la suite. // leggerebbe e scriverebbe quelle vere di chi lancia la suite.
AppPaths.OverrideConfigRootForTests(sandbox); AppPaths.OverrideConfigRootForTests(sandbox);
AppPaths.Configure( AppPaths.Configure(databaseFolder: Path.Combine(sandbox, "Database"));
dataFolder: Path.Combine(sandbox, "Dati"),
statsFolder: Path.Combine(sandbox, "Statistiche"),
databaseFile: Path.Combine(sandbox, "autobidder.sqlite"));
AppPaths.EnsureFolders(); AppPaths.EnsureFolders();
} }
+34
View File
@@ -698,6 +698,40 @@
</Setter> </Setter>
</Style> </Style>
<!-- Pulsante icona a due stati: stesso aspetto di IconButton, e quando è acceso
si riempie del colore d'accento. -->
<Style x:Key="IconToggle" TargetType="ToggleButton">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource Brush.TextMuted}"/>
<Setter Property="FontFamily" Value="{StaticResource Font.UI}"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="Width" Value="30"/>
<Setter Property="Height" Value="28"/>
<Setter Property="Margin" Value="1,0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="7">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Brush.Accent}"/>
<Setter Property="Foreground" Value="{DynamicResource Brush.TextOnAccent}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Brush.SurfaceHover}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.35"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Variante minuta, per le azioni dentro una riga di griglia. --> <!-- Variante minuta, per le azioni dentro una riga di griglia. -->
<Style x:Key="MiniIconButton" TargetType="Button" BasedOn="{StaticResource IconButton}"> <Style x:Key="MiniIconButton" TargetType="Button" BasedOn="{StaticResource IconButton}">
<Setter Property="Width" Value="24"/> <Setter Property="Width" Value="24"/>
+51 -184
View File
@@ -6,40 +6,32 @@ namespace AutoBidder.Utilities
/// <summary> /// <summary>
/// Posizione di tutti i file dell'applicazione, in un solo posto. /// Posizione di tutti i file dell'applicazione, in un solo posto.
/// ///
/// <para>Prima ogni archivio sceglieva per conto proprio fra <c>Roaming</c> e /// <para>Tre cose, tre posti:</para>
/// <c>Local</c>: le aste finivano in una cartella e le impostazioni in un'altra, cosa
/// che rendeva difficile perfino sapere dove guardare per un salvataggio.</para>
///
/// <para>La struttura è deliberatamente su tre livelli:</para>
/// <list type="bullet"> /// <list type="bullet">
/// <item><b>Radice dell'autenticazione</b> (<c>%LocalAppData%\AutoBidder</c>): sessione, /// <item><b>Radice dell'autenticazione</b> (<c>%LocalAppData%\AutoBidder</c>): sessione,
/// riferimenti del sito e profilo del browser. Non si sposta mai: la sessione è /// riferimenti del sito e profilo del browser. Non si sposta mai: la sessione è
/// protetta con DPAPI e legata a questo utente Windows, quindi su un altro disco o /// protetta con DPAPI e legata a questo utente Windows.</item>
/// in una cartella sincronizzata non sarebbe comunque leggibile.</item>
/// <item><b>Radice della configurazione</b> (<c>Documenti\AutoBidder</c> per le /// <item><b>Radice della configurazione</b> (<c>Documenti\AutoBidder</c> per le
/// installazioni nuove): le impostazioni e, di serie, i dati. È una cartella nota a /// installazioni nuove; <c>%LocalAppData%</c> per chi ci aveva già le impostazioni):
/// Windows, quindi si trova senza dover leggere niente prima — che è l'unico requisito /// il file delle impostazioni e le copie di sicurezza. È una cartella nota a Windows,
/// per il file che dice <i>dove</i> stanno gli altri.</item> /// quindi si trova senza dover leggere niente prima.</item>
/// <item><b>Cartella dati</b> (configurabile): aste monitorate, storico, prodotti /// <item><b>Cartella dei database</b> (configurabile, predefinita
/// seguiti e statistiche. Si può portare su un altro disco o accanto ai propri fogli /// <c>%LocalAppData%\AutoBidder\Database</c>): i due file SQLite — osservazioni e
/// di calcolo.</item> /// esercizio — con tutto il resto: aste, puntate, decisioni, prodotti, modelli,
/// registri. Mai in una cartella sincronizzata (OneDrive, Dropbox): un database
/// aperto sotto sincronizzazione si corrompe.</item>
/// </list> /// </list>
/// ///
/// <para><b>Chi ha già un'installazione non viene spostato.</b> Se le impostazioni /// <para>I file JSON delle versioni precedenti (<c>Dati\…</c>) non si scrivono più;
/// esistono in <c>%LocalAppData%</c> e non in Documenti, si continua a usare quelle /// alla prima apertura vengono importati nel database d'esercizio e restano sul disco
/// dove sono: scelta esplicita dell'utente, per non toccare mesi di raccolta. Chi vuole /// finché non li si toglie dalla scheda Storico.</para>
/// cambiare posto lo fa dalla scheda Impostazioni.</para>
/// </summary> /// </summary>
public static class AppPaths public static class AppPaths
{ {
/// <summary> /// <summary>Radice dell'autenticazione. Resta in <c>%LocalAppData%</c> qualunque cosa succeda.</summary>
/// Radice dell'autenticazione: sessione, riferimenti del sito, profilo del browser.
/// Resta in <c>%LocalAppData%</c> qualunque cosa succeda alle altre cartelle.
/// </summary>
public static string Root { get; } = Path.Combine( public static string Root { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AutoBidder"); Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AutoBidder");
/// <summary>Radice della configurazione per chi installa da zero: Documenti.</summary>
private static string DocumentsRoot private static string DocumentsRoot
{ {
get get
@@ -49,7 +41,7 @@ namespace AutoBidder.Utilities
var docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); var docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
if (!string.IsNullOrWhiteSpace(docs)) return Path.Combine(docs, "AutoBidder"); if (!string.IsNullOrWhiteSpace(docs)) return Path.Combine(docs, "AutoBidder");
} }
catch { /* profilo senza Documenti: si ripiega sotto */ } catch { }
return Root; return Root;
} }
@@ -59,15 +51,10 @@ namespace AutoBidder.Utilities
private static string? _configRootResolved; private static string? _configRootResolved;
/// <summary> /// <summary>
/// Radice della configurazione: dove stanno le impostazioni e, di serie, i dati. /// Radice della configurazione: impostazioni e copie di sicurezza. Si decide una
/// /// volta sola: Documenti se le impostazioni ci sono già; altrimenti
/// <para>Si decide una volta sola, al primo accesso, e non cambia più per tutta la
/// vita del processo — spostarla a metà lascerebbe metà degli archivi in una
/// cartella e metà nell'altra.</para>
///
/// <para>Ordine: Documenti se le impostazioni ci sono già; altrimenti
/// <c>%LocalAppData%</c> se ci sono lì (installazione precedente, che resta dov'è); /// <c>%LocalAppData%</c> se ci sono lì (installazione precedente, che resta dov'è);
/// altrimenti Documenti, perché è un'installazione nuova.</para> /// altrimenti Documenti, perché è un'installazione nuova.
/// </summary> /// </summary>
public static string ConfigRoot public static string ConfigRoot
{ {
@@ -85,7 +72,6 @@ namespace AutoBidder.Utilities
if (File.Exists(Path.Combine(documenti, "settings.json"))) return documenti; if (File.Exists(Path.Combine(documenti, "settings.json"))) return documenti;
if (File.Exists(Path.Combine(Root, "settings.json"))) return Root; if (File.Exists(Path.Combine(Root, "settings.json"))) return Root;
// Installazione nuova: Documenti, purché sia davvero scrivibile.
try try
{ {
Directory.CreateDirectory(documenti); Directory.CreateDirectory(documenti);
@@ -97,143 +83,57 @@ namespace AutoBidder.Utilities
} }
} }
/// <summary> /// <summary>Solo per i test: fissa la radice della configurazione senza guardare il disco.</summary>
/// Solo per i test: fissa la radice della configurazione senza guardare il disco.
/// Senza, la suite leggerebbe e scriverebbe le impostazioni vere di chi la lancia.
/// </summary>
public static void OverrideConfigRootForTests(string? path) => _configRootOverride = path; public static void OverrideConfigRootForTests(string? path) => _configRootOverride = path;
/// <summary>Impostazioni: nella radice della configurazione, che si trova senza leggerle.</summary>
public static string SettingsFile => Path.Combine(ConfigRoot, "settings.json"); public static string SettingsFile => Path.Combine(ConfigRoot, "settings.json");
/// <summary>Copie di sicurezza dello storico, fatte prima di ogni pulizia.</summary> /// <summary>Copie di sicurezza dei database, fatte prima di ogni operazione che cancella.</summary>
public static string BackupsFolder => Path.Combine(ConfigRoot, "Backups"); public static string BackupsFolder => Path.Combine(ConfigRoot, "Backups");
/// <summary>Cookie di sessione protetto con DPAPI.</summary> // ── Autenticazione ───────────────────────────────────────────────
public static string SessionFile => Path.Combine(Root, "session.dat"); public static string SessionFile => Path.Combine(Root, "session.dat");
/// <summary>
/// Riferimenti del sito per il riscatto puntate: indirizzi, intestazioni, parametri.
/// Sta nella radice fissa insieme alle impostazioni perché è configurazione, non un
/// dato d'esercizio: chi porta la cartella dati su un altro disco non se lo porta
/// dietro, e chi corregge un indirizzo lo trova sempre nello stesso posto.
/// </summary>
public static string SiteConfigFile => Path.Combine(Root, "site-config.json"); public static string SiteConfigFile => Path.Combine(Root, "site-config.json");
/// <summary>Profilo del browser integrato (cache WebView2).</summary>
public static string BrowserProfile => Path.Combine(Root, "WebView2"); public static string BrowserProfile => Path.Combine(Root, "WebView2");
/// <summary>Percorso del database scelto dall'utente; vuoto = predefinito.</summary> // ── Database ─────────────────────────────────────────────────────
private static string _databaseFileOverride = "";
/// <summary> private static string _databaseFolderOverride = "";
/// L'archivio SQLite con aste, puntate, decisioni e misure. Sta nella radice
/// fissa, non in Documenti: una cartella sincronizzata (OneDrive) è il modo
/// classico di corrompere un database aperto.
/// </summary>
public static string DatabaseFile =>
_databaseFileOverride.Length > 0 ? _databaseFileOverride : Path.Combine(Root, "Database", "autobidder.sqlite");
/// <summary>Percorso dati scelto dall'utente; vuoto = predefinito.</summary> /// <summary>La cartella dei due database. Vuota nelle impostazioni = predefinita.</summary>
private static string _dataFolderOverride = ""; public static string DatabaseFolder =>
_databaseFolderOverride.Length > 0 ? _databaseFolderOverride : Path.Combine(Root, "Database");
/// <summary>Percorso statistiche scelto dall'utente; vuoto = sotto la cartella dati.</summary> public static string DefaultDatabaseFolder => Path.Combine(Root, "Database");
private static string _statsFolderOverride = "";
/// <summary> /// <summary>Le osservazioni: aste, puntate, interrogazioni, decisioni, misure.</summary>
/// Cartella dei dati d'esercizio: aste monitorate, storico, prodotti seguiti. public static string DatabaseFile => Path.Combine(DatabaseFolder, "autobidder.sqlite");
/// </summary>
public static string DataFolder =>
_dataFolderOverride.Length > 0 ? _dataFolderOverride : Path.Combine(ConfigRoot, "Dati");
/// <summary> /// <summary>L'esercizio: documenti (aste nel monitor, prodotti, modelli) e registri.</summary>
/// Cartella delle statistiche e delle esportazioni. Separata dalla precedente public static string OperationalDatabaseFile => Path.Combine(DatabaseFolder, "esercizio.sqlite");
/// perché è quella che si tiene volentieri altrove: cresce nel tempo e serve
/// soprattutto per le analisi fatte fuori dall'applicazione.
/// </summary>
public static string StatsFolder =>
_statsFolderOverride.Length > 0 ? _statsFolderOverride : Path.Combine(DataFolder, "Statistiche");
// ── File dei singoli archivi ───────────────────────────────────── /// <summary>I tre file (principale, WAL, SHM) di un database, quelli che esistono.</summary>
public static string[] DatabaseFiles(string dbPath) =>
Array.FindAll(new[] { dbPath, dbPath + "-wal", dbPath + "-shm" }, File.Exists);
public static string AuctionsFile => Path.Combine(DataFolder, "auctions.json"); // ── Archivi delle versioni precedenti (solo lettura, per l'importazione) ──
public static string WatchedProductsFile => Path.Combine(DataFolder, "watched-products.json");
/// <summary> /// <summary>La vecchia cartella dei dati d'esercizio: <c>ConfigRoot\Dati</c>.</summary>
/// Collegamenti promozionali già aperti. Dato d'esercizio, non statistica: serve a public static string LegacyDataFolder => Path.Combine(ConfigRoot, "Dati");
/// non riaprire ogni mezz'ora gli stessi premi, che sulla pagina di origine restano
/// pubblicati per giorni.
/// </summary>
public static string ClaimedPromosFile => Path.Combine(DataFolder, "claimed-promos.json");
/// <summary>Storico delle aste concluse: è un dato statistico, non d'esercizio.</summary> public static string LegacyAuctionsFile => Path.Combine(LegacyDataFolder, "auctions.json");
public static string CompletedAuctionsFile => Path.Combine(StatsFolder, "completed-auctions.json"); public static string LegacyWatchedProductsFile => Path.Combine(LegacyDataFolder, "watched-products.json");
public static string LegacyProductsFile => Path.Combine(LegacyDataFolder, "products.json");
public static string LegacyClaimedPromosFile => Path.Combine(LegacyDataFolder, "claimed-promos.json");
public static string LegacyStatsFolder => Path.Combine(LegacyDataFolder, "Statistiche");
public static string LegacyLearningFolder => Path.Combine(LegacyStatsFolder, "Apprendimento");
public static string LegacyFreeBidsStatsFile => Path.Combine(LegacyStatsFolder, "free-bids-stats.json");
/// <summary>Misure dell'anticipo effettivo delle puntate.</summary> /// <summary>Applica la cartella dei database scelta dall'utente. Non utilizzabile = predefinita.</summary>
public static string BidLeadStatsFile => Path.Combine(StatsFolder, "bid-lead-stats.json"); public static void Configure(string? databaseFolder)
/// <summary>Cartella dove finiscono le esportazioni richieste dall'utente.</summary>
public static string ExportFolder => Path.Combine(StatsFolder, "Esportazioni");
/// <summary>
/// Statistiche e opzioni per prodotto. Sta fra i dati d'esercizio perché è ciò che
/// decide come entrano le aste nuove, non un archivio da consultare.
/// </summary>
public static string ProductsFile => Path.Combine(DataFolder, "products.json");
// ── Registri di testo ────────────────────────────────────────────
/// <summary>Percorso registri scelto dall'utente; vuoto = sotto la cartella dati.</summary>
private static string _logFolderOverride = "";
/// <summary>
/// Registri leggibili: log applicativo, riscatto puntate e dossier delle singole
/// aste. Separata dal resto perché è la cartella che si apre a mano più spesso — e
/// la sola che convenga poter mettere su un disco capiente: con il registro completo
/// dei poll un'asta lunga vale qualche megabyte.
/// </summary>
public static string LogFolder =>
_logFolderOverride.Length > 0 ? _logFolderOverride : Path.Combine(DataFolder, "Registri");
/// <summary>Dossier di una singola asta, uno per file.</summary>
public static string AuctionLogFolder => Path.Combine(LogFolder, "Aste");
/// <summary>Registro applicativo del giorno indicato.</summary>
public static string AppLogFile(DateTime day) =>
Path.Combine(LogFolder, $"app-{day:yyyy-MM-dd}.log");
/// <summary>Registro del riscatto puntate del giorno indicato.</summary>
public static string FreeBidsLogFile(DateTime day) =>
Path.Combine(LogFolder, $"puntate-{day:yyyy-MM-dd}.log");
/// <summary>
/// Applica i percorsi scelti dall'utente. Un percorso non utilizzabile viene
/// ignorato in favore del predefinito: è preferibile scrivere nel posto sbagliato
/// che non scrivere affatto e perdere lo storico.
/// </summary>
public static void Configure(string? dataFolder, string? statsFolder, string? logFolder = null, string? databaseFile = null)
{ {
_dataFolderOverride = Sanitize(dataFolder); _databaseFolderOverride = Sanitize(databaseFolder);
_statsFolderOverride = Sanitize(statsFolder);
_logFolderOverride = Sanitize(logFolder);
_databaseFileOverride = SanitizeFile(databaseFile);
}
private static string SanitizeFile(string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate)) return "";
try
{
var full = Path.GetFullPath(candidate.Trim());
var folder = Path.GetDirectoryName(full);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
return full;
}
catch
{
return "";
}
} }
private static string Sanitize(string? candidate) private static string Sanitize(string? candidate)
@@ -243,7 +143,7 @@ namespace AutoBidder.Utilities
try try
{ {
var full = Path.GetFullPath(candidate.Trim()); var full = Path.GetFullPath(candidate.Trim());
Directory.CreateDirectory(full); // verifica che sia davvero scrivibile Directory.CreateDirectory(full);
return full; return full;
} }
catch catch
@@ -257,10 +157,7 @@ namespace AutoBidder.Utilities
{ {
TryCreate(Root); TryCreate(Root);
TryCreate(ConfigRoot); TryCreate(ConfigRoot);
TryCreate(DataFolder); TryCreate(DatabaseFolder);
TryCreate(StatsFolder);
TryCreate(LogFolder);
TryCreate(Path.GetDirectoryName(DatabaseFile) ?? Root);
} }
private static void TryCreate(string path) private static void TryCreate(string path)
@@ -269,37 +166,7 @@ namespace AutoBidder.Utilities
{ {
if (!Directory.Exists(path)) Directory.CreateDirectory(path); if (!Directory.Exists(path)) Directory.CreateDirectory(path);
} }
catch { /* la creazione è best-effort: l'errore emerge alla scrittura */ } catch { }
}
/// <summary>
/// Sposta i file rimasti nelle vecchie posizioni. Le versioni precedenti
/// scrivevano parte dei dati in <c>Roaming</c> e parte in <c>Local</c>, senza
/// sottocartelle: senza questo passaggio storico e aste sparirebbero alla vista.
/// </summary>
public static void MigrateLegacyFiles()
{
var roaming = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AutoBidder");
EnsureFolders();
MoveIfPresent(Path.Combine(roaming, "auctions.json"), AuctionsFile);
MoveIfPresent(Path.Combine(roaming, "completed-auctions.json"), CompletedAuctionsFile);
MoveIfPresent(Path.Combine(roaming, "session.dat"), SessionFile);
MoveIfPresent(Path.Combine(Root, "watched-products.json"), WatchedProductsFile);
}
private static void MoveIfPresent(string from, string to)
{
try
{
if (!File.Exists(from) || File.Exists(to)) return;
Directory.CreateDirectory(Path.GetDirectoryName(to)!);
File.Move(from, to);
}
catch { /* un file bloccato non deve impedire l'avvio */ }
} }
} }
} }
-328
View File
@@ -1,328 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using AutoBidder.Data;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
/// <summary>
/// Criteri con cui scegliere le aste da esportare. Tutti facoltativi: quello che resta
/// vuoto non filtra.
/// </summary>
public sealed class AuctionExportFilter
{
public DateTime? From { get; set; }
public DateTime? To { get; set; }
/// <summary>Testo cercato nel nome del prodotto.</summary>
public string? NameContains { get; set; }
/// <summary>Chiave prodotto esatta: seleziona tutte le aste dello stesso articolo.</summary>
public string? ProductKey { get; set; }
public bool IncludeWon { get; set; } = true;
public bool IncludeLost { get; set; } = true;
/// <summary>Aste finite senza che ci puntassi: utili come campione di mercato.</summary>
public bool IncludeClosed { get; set; } = true;
/// <summary>Solo aste su cui ho davvero puntato.</summary>
public bool OnlyWithMyBids { get; set; }
/// <summary>Solo aste seguite dall'inizio alla fine: le più preziose per l'analisi.</summary>
public bool OnlyComplete { get; set; }
/// <summary>Solo aste di cui esistono gli eventi registrati (interrogazioni e puntate).</summary>
public bool OnlyWithDossier { get; set; }
public double? MinFinalPrice { get; set; }
public double? MaxFinalPrice { get; set; }
/// <summary>Includi nel file anche gli eventi registrati, non solo il riepilogo.</summary>
public bool IncludeEvents { get; set; } = true;
/// <summary>Tetto di aste esportate. 0 = nessuno.</summary>
public int MaxAuctions { get; set; }
}
/// <summary>Esito di un'esportazione.</summary>
public sealed record AuctionExportResult(
bool Success,
string Path,
int AuctionCount,
int WithEvents,
long Bytes,
string Message);
/// <summary>
/// Mette in un unico file le aste che rispondono ai criteri scelti, riepilogo e — dove
/// ci sono — tutti gli eventi registrati: interrogazioni, puntate, reset, nostre
/// puntate, decisioni del motore, registro.
///
/// <para>Il formato è JSON pensato per essere <b>letto da un'AI</b>: un blocco
/// <c>meta</c> che spiega cosa sono i campi e con che filtri è stato prodotto, e poi le
/// aste. La spiegazione dentro il file non è ridondanza: un'esportazione viene letta
/// mesi dopo, da qualcuno (o qualcosa) che non ha davanti né questo codice né la memoria
/// di com'era impostata l'applicazione quel giorno.</para>
/// </summary>
public static class AuctionExporter
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private static AuctionDatabase Db => AuctionDatabase.Instance;
/// <summary>Le aste che passerebbero il filtro, per mostrarne il conto prima di esportare.</summary>
public static List<AuctionDetailRecord> Select(AuctionExportFilter filter)
{
filter ??= new AuctionExportFilter();
var details = AuctionDetailStore.LoadAll();
var withEvents = filter.OnlyWithDossier ? AuctionDetailStore.WithEvents() : null;
var selected = details.Where(r => Matches(r, filter, withEvents)).ToList();
return filter.MaxAuctions > 0 ? selected.Take(filter.MaxAuctions).ToList() : selected;
}
/// <summary>Scrive il file. Il percorso viene deciso qui se non lo si impone.</summary>
public static AuctionExportResult Export(AuctionExportFilter filter, string? targetPath = null)
{
try
{
// Ciò che è ancora in coda deve essere sul disco prima di rileggerlo.
Db.Flush();
filter ??= new AuctionExportFilter();
var selected = Select(filter);
if (selected.Count == 0)
return new AuctionExportResult(false, "", 0, 0, 0, "nessuna asta corrisponde ai filtri");
var withEvents = 0;
var auctions = new JsonArray();
foreach (var record in selected)
{
var node = JsonSerializer.SerializeToNode(record, Options)!.AsObject();
node["totalCostIfWon"] = Math.Round(record.TotalCostIfWon(record.BidCostEuro), 2);
if (filter.IncludeEvents)
{
var events = ReadEvents(record.AuctionId);
if (events != null)
{
node["events"] = events;
withEvents++;
}
}
auctions.Add(node);
}
var document = new JsonObject
{
["meta"] = BuildMeta(filter, selected, withEvents),
["auctions"] = auctions
};
var path = targetPath ?? DefaultPath();
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
File.WriteAllText(path, document.ToJsonString(Options));
var bytes = new FileInfo(path).Length;
return new AuctionExportResult(true, path, selected.Count, withEvents, bytes,
$"{selected.Count} aste esportate ({withEvents} con eventi)");
}
catch (Exception ex)
{
return new AuctionExportResult(false, "", 0, 0, 0, $"esportazione non riuscita: {ex.Message}");
}
}
/// <summary>Percorso predefinito: cartella esportazioni, nome con data e ora.</summary>
public static string DefaultPath() =>
System.IO.Path.Combine(AppPaths.ExportFolder, $"aste-{DateTime.Now:yyyy-MM-dd_HHmm}.json");
/// <summary>Elenco dei prodotti presenti nello storico, per il menu dei filtri.</summary>
public static List<string> KnownProducts() =>
AuctionDetailStore.LoadAll()
.Select(r => r.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(n => n, StringComparer.CurrentCultureIgnoreCase)
.ToList();
// ── Interno ──────────────────────────────────────────────────────
private static JsonObject BuildMeta(
AuctionExportFilter filter,
List<AuctionDetailRecord> selected,
int withEvents)
{
var complete = selected.Count(r => r.IsComplete);
return new JsonObject
{
["schema"] = "autobidder.export.v2",
["app"] = AppInfo.Version,
["generatedAt"] = DateTime.Now.ToString("o"),
["auctionCount"] = selected.Count,
["withEvents"] = withEvents,
["completeObservations"] = complete,
["filters"] = new JsonObject
{
["from"] = filter.From?.ToString("yyyy-MM-dd"),
["to"] = filter.To?.ToString("yyyy-MM-dd"),
["nameContains"] = filter.NameContains,
["productKey"] = filter.ProductKey,
["includeWon"] = filter.IncludeWon,
["includeLost"] = filter.IncludeLost,
["includeClosed"] = filter.IncludeClosed,
["onlyWithMyBids"] = filter.OnlyWithMyBids,
["onlyComplete"] = filter.OnlyComplete,
["onlyWithEvents"] = filter.OnlyWithDossier,
["minFinalPrice"] = filter.MinFinalPrice,
["maxFinalPrice"] = filter.MaxFinalPrice
},
["fieldGuide"] = new JsonObject
{
["observedFromStart"] = "il monitor c'era prima che l'asta partisse",
["observedToEnd"] = "la conclusione è stata vista dal vivo",
["isComplete"] = "storia completa: sono le aste su cui l'analisi vale davvero",
["priceSeries"] = "prezzo campionato: t = secondi dal primo avvistamento",
["bidsByUser"] = "puntate osservate per utente",
["topBidderShare"] = "quota del puntatore più attivo",
["configuredLeadMs"] = "anticipo in uso sulla scadenza",
["averagePingMs"] = "latenza media verso Bidoo su questa asta",
["buyNowPrice"] = "valore del prodotto (Compra Subito) al momento dell'asta",
["totalCostIfWon"] = "prezzo finale + puntate spese + spedizione",
["events[].type"] = "poll = interrogazione, bid = puntata di un utente (mine = nostra), " +
"my_bid = nostra puntata (plannedLeadMs vs actualLeadMs), " +
"reset = timer azzerato (minTimer = fin dove era sceso), " +
"decision = decisione del motore (action, pWin, ev, reason), log = registro"
}
};
}
/// <summary>Tutti gli eventi registrati di un'asta, in ordine di tempo.</summary>
private static JsonArray? ReadEvents(string auctionId)
{
try
{
var events = new List<(double T, JsonObject Node)>();
foreach (var r in Db.Query("SELECT * FROM polls WHERE auction_id = ?1 ORDER BY t", auctionId))
events.Add((r.Dbl("t"), new JsonObject
{
["t"] = r.Dbl("t"), ["type"] = "poll", ["at"] = r.StrOrNull("local_ts"),
["price"] = r.Dbl("price"), ["timer"] = r.Dbl("timer"), ["status"] = r.StrOrNull("status"),
["lastBidder"] = r.StrOrNull("last_bidder"), ["mine"] = r.Bool("is_mine"),
["pingMs"] = r.Int("ping_ms"), ["expiryUnix"] = r.Long("expiry_unix"), ["serverUnix"] = r.Long("server_unix")
}));
foreach (var r in Db.Query("SELECT * FROM bids WHERE auction_id = ?1 ORDER BY COALESCE(server_ts, 0), price_after", auctionId))
events.Add((r.Dbl("seen_t"), new JsonObject
{
["t"] = r.Dbl("seen_t"), ["type"] = "bid", ["at"] = r.StrOrNull("local_ts"), ["user"] = r.Str("username"),
["bidAt"] = r.LongOrNull("server_ts") is { } ts ? DateTimeOffset.FromUnixTimeSeconds(ts).ToLocalTime().ToString("o") : null,
["bidType"] = r.Str("bid_type"), ["mine"] = r.Bool("is_mine"), ["price"] = r.Dbl("price_after"),
["timerBefore"] = r.DblOrNull("timer_before")
}));
foreach (var r in Db.Query("SELECT * FROM resets WHERE auction_id = ?1 ORDER BY t", auctionId))
events.Add((r.Dbl("t"), new JsonObject
{
["t"] = r.Dbl("t"), ["type"] = "reset", ["at"] = r.StrOrNull("local_ts"), ["resetCount"] = r.Int("reset_count"),
["price"] = r.Dbl("price"), ["bidder"] = r.StrOrNull("bidder"),
["minTimer"] = r.DblOrNull("min_timer"), ["cycleSeconds"] = r.DblOrNull("cycle_seconds")
}));
foreach (var r in Db.Query("SELECT * FROM my_bids WHERE auction_id = ?1 ORDER BY t", auctionId))
events.Add((r.Dbl("t"), new JsonObject
{
["t"] = r.Dbl("t"), ["type"] = "my_bid", ["at"] = r.StrOrNull("local_ts"), ["price"] = r.Dbl("price"),
["plannedLeadMs"] = r.Int("planned_lead_ms"), ["actualLeadMs"] = r.Dbl("actual_lead_ms"),
["pingMs"] = r.Int("ping_ms"), ["rttMs"] = r.Int("rtt_ms"), ["result"] = r.Bool("success") ? "ok" : "fail",
["error"] = r.StrOrNull("error"), ["bidsUsed"] = r.IntOrNull("bids_used"), ["remainingBids"] = r.IntOrNull("remaining_bids")
}));
foreach (var r in Db.Query("SELECT * FROM bot_decisions WHERE auction_id = ?1 ORDER BY ts", auctionId))
events.Add((r.Dbl("t"), new JsonObject
{
["t"] = r.DblOrNull("t"), ["type"] = "decision", ["at"] = r.Str("ts"), ["price"] = r.DblOrNull("price"),
["timer"] = r.DblOrNull("timer"), ["action"] = r.Str("action"), ["pWin"] = r.DblOrNull("p_win"),
["ev"] = r.DblOrNull("ev_estimate"), ["mode"] = r.Str("mode"), ["regime"] = r.StrOrNull("regime"),
["executed"] = r.Bool("executed"), ["outcome"] = r.StrOrNull("outcome"), ["reason"] = r.StrOrNull("reason_detail")
}));
foreach (var r in Db.Query("SELECT * FROM auction_log WHERE auction_id = ?1 ORDER BY t", auctionId))
events.Add((r.Dbl("t"), new JsonObject
{
["t"] = r.Dbl("t"), ["type"] = "log", ["at"] = r.StrOrNull("local_ts"), ["level"] = r.StrOrNull("level"),
["category"] = r.StrOrNull("category"), ["msg"] = r.StrOrNull("msg")
}));
if (events.Count == 0) return null;
var array = new JsonArray();
foreach (var (_, node) in events.OrderBy(e => e.T)) array.Add(node);
return array;
}
catch
{
return null;
}
}
private static bool Matches(
AuctionDetailRecord record,
AuctionExportFilter filter,
HashSet<string>? withEvents)
{
if (filter.From.HasValue && record.EndedAt.ToLocalTime() < filter.From.Value.Date) return false;
if (filter.To.HasValue && record.EndedAt.ToLocalTime() > filter.To.Value.Date.AddDays(1)) return false;
if (!string.IsNullOrWhiteSpace(filter.NameContains) &&
!record.Name.Contains(filter.NameContains.Trim(), StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrWhiteSpace(filter.ProductKey) &&
!string.Equals(record.ProductKey, filter.ProductKey.Trim(), StringComparison.OrdinalIgnoreCase))
return false;
var won = record.WonByMe;
var lost = !record.WonByMe && record.MyBids > 0;
var closed = !record.WonByMe && record.MyBids == 0;
if (won && !filter.IncludeWon) return false;
if (lost && !filter.IncludeLost) return false;
if (closed && !filter.IncludeClosed) return false;
if (filter.OnlyWithMyBids && record.MyBids == 0) return false;
if (filter.OnlyComplete && !record.IsComplete) return false;
if (withEvents != null && !withEvents.Contains(record.AuctionId)) return false;
if (filter.MinFinalPrice.HasValue && record.FinalPrice < filter.MinFinalPrice.Value) return false;
if (filter.MaxFinalPrice.HasValue && record.FinalPrice > filter.MaxFinalPrice.Value) return false;
return true;
}
}
}
-247
View File
@@ -1,247 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace AutoBidder.Utilities
{
/// <summary>
/// Misura l'anticipo <b>effettivo</b> delle puntate e ne ricava un consiglio.
///
/// <para>L'anticipo impostato dall'utente è una scommessa: "parto 800 ms prima della
/// scadenza". Ma ciò che conta è dove la puntata arriva davvero, cioè quanto restava
/// alla scadenza nell'istante in cui il server l'ha accettata. Fra i due valori ci
/// sono il ritardo di rete e l'errore di attesa.</para>
///
/// <para>Il motore calcolava già questo numero a ogni puntata, ma lo scriveva solo nel
/// log dell'asta, dove nessuno lo poteva sommare. Qui viene raccolto: con qualche
/// decina di misure si vede se l'anticipo impostato è troppo largo — e ogni
/// millisecondo di troppo è tempo in cui un avversario può passare davanti.</para>
/// </summary>
public static class BidLeadStats
{
/// <summary>Misure conservate. Oltre, si dimenticano le più vecchie.</summary>
private const int MaxSamples = 500;
private static readonly object Sync = new();
private static Store? _cache;
public class Sample
{
public DateTime At { get; set; }
public string AuctionId { get; set; } = "";
/// <summary>Anticipo impostato per quell'asta.</summary>
public int ConfiguredMs { get; set; }
/// <summary>Millisecondi realmente mancanti alla scadenza quando è partita.</summary>
public double ActualMs { get; set; }
/// <summary>Ping medio osservato su quell'asta al momento della puntata.</summary>
public double PingMs { get; set; }
public bool Success { get; set; }
/// <summary>Il server ha rifiutato perché il tempo era scaduto.</summary>
public bool TooLate { get; set; }
}
private class Store
{
public List<Sample> Samples { get; set; } = new();
}
private static Store Load()
{
lock (Sync)
{
if (_cache != null) return _cache;
try
{
var path = AppPaths.BidLeadStatsFile;
_cache = File.Exists(path)
? JsonSerializer.Deserialize<Store>(File.ReadAllText(path)) ?? new Store()
: new Store();
}
catch
{
_cache = new Store();
}
return _cache;
}
}
private static void Save()
{
try
{
AppPaths.EnsureFolders();
lock (Sync)
{
File.WriteAllText(AppPaths.BidLeadStatsFile,
JsonSerializer.Serialize(_cache, new JsonSerializerOptions { WriteIndented = true }));
}
}
catch { /* una misura persa non giustifica un errore all'utente */ }
}
public static void Record(Sample sample)
{
lock (Sync)
{
var store = Load();
store.Samples.Add(sample);
if (store.Samples.Count > MaxSamples)
store.Samples.RemoveRange(0, store.Samples.Count - MaxSamples);
}
Save();
}
public static List<Sample> All()
{
lock (Sync) return Load().Samples.ToList();
}
public static void Clear()
{
lock (Sync) Load().Samples.Clear();
Save();
}
/// <summary>Riepilogo delle misure raccolte.</summary>
public readonly record struct Summary(
int Count,
int Successes,
int TooLate,
double AverageActualMs,
double MedianActualMs,
double MinActualMs,
double AveragePingMs,
int TypicalConfiguredMs)
{
public bool HasData => Count > 0;
public double SuccessRate => Count > 0 ? Successes * 100.0 / Count : 0;
}
public static Summary Summarize()
{
var samples = All();
if (samples.Count == 0) return default;
// Solo le puntate accettate dicono qualcosa sull'anticipo: una rifiutata non
// è mai arrivata a destinazione e il suo "anticipo" non è confrontabile.
var accepted = samples.Where(s => s.Success).ToList();
var basis = accepted.Count > 0 ? accepted : samples;
var actual = basis.Select(s => s.ActualMs).OrderBy(v => v).ToList();
return new Summary(
Count: samples.Count,
Successes: samples.Count(s => s.Success),
TooLate: samples.Count(s => s.TooLate),
AverageActualMs: actual.Average(),
MedianActualMs: actual[actual.Count / 2],
MinActualMs: actual[0],
AveragePingMs: basis.Average(s => s.PingMs),
TypicalConfiguredMs: (int)basis.Average(s => s.ConfiguredMs));
}
/// <summary>Esito della taratura: un consiglio, mai un'applicazione automatica.</summary>
public readonly record struct Advice(bool HasAdvice, int SuggestedLeadMs, string Reason);
/// <summary>
/// Propone un anticipo a partire dalle misure.
///
/// <para>Il criterio è prudente per costruzione. Il margine minimo che va comunque
/// lasciato è il ritardo di rete più un cuscinetto: sotto quella soglia la puntata
/// rischia di arrivare a giochi chiusi, e una puntata tardiva costa un'asta intera
/// mentre un anticipo un po' largo costa solo qualche possibilità in più
/// all'avversario. Per questo si scende solo se lo scarto è ampio, e mai sotto il
/// margine di sicurezza.</para>
/// </summary>
/// <summary>
/// Anticipo minimo sensato, in millisecondi. Sotto questa soglia la puntata
/// dipende dal non avere un picco di rete: sui dossier raccolti il ping è
/// mediano 56 ms ma il p99,9 è 444 ms, quindi con 200 ms di anticipo lo 0,43%
/// delle puntate arriverebbe dopo la scadenza — e una tardiva costa l'asta.
/// </summary>
public const int SafeFloorMs = 600;
/// <summary>
/// Anticipo oltre il quale si comincia a pagare. Il motore punta in ogni ciclo che
/// arriva fino all'anticipo senza puntate altrui, e su 1.007.850 cicli rigiocati la
/// mediana delle puntate per asta è 1 sotto il secondo, 15 sotto i due secondi,
/// 46 sotto i tre e 97 sotto i quattro. Ogni secondo in più moltiplica per dieci
/// la spesa, quindi il consiglio non supera mai questo valore.
/// </summary>
public const int SafeCeilingMs = 2000;
public static Advice Suggest(int currentLeadMs, int minSamples = 15)
{
var samples = All().Where(s => s.Success).ToList();
if (samples.Count < minSamples)
{
// Anche senza misure proprie qualcosa si può dire: le due soglie sono
// ricavate dai dossier, non da un'intuizione.
if (currentLeadMs > 0 && currentLeadMs < SafeFloorMs)
return new Advice(true, SafeFloorMs,
$"Anticipo {currentLeadMs} ms: sotto i {SafeFloorMs} ms basta un picco di rete " +
$"perché la puntata arrivi a giochi chiusi. Servono comunque {minSamples} puntate " +
$"riuscite per una taratura sulle tue misure (finora {samples.Count}).");
if (currentLeadMs > SafeCeilingMs)
return new Advice(true, SafeCeilingMs,
$"Anticipo {currentLeadMs} ms: oltre i {SafeCeilingMs} ms si punta in molti più cicli " +
$"del necessario, e ogni ciclo in più è una puntata spesa. Servono comunque " +
$"{minSamples} puntate riuscite per una taratura sulle tue misure (finora {samples.Count}).");
return new Advice(false, currentLeadMs,
$"Servono almeno {minSamples} puntate riuscite per un consiglio attendibile (finora {samples.Count}).");
}
var tooLate = All().Count(s => s.TooLate);
// Se arrivano puntate tardive il problema è opposto: bisogna allargare.
if (tooLate > 0)
{
var ping = samples.Average(s => s.PingMs);
var suggested = (int)Math.Ceiling((currentLeadMs + ping + 200) / 50.0) * 50;
return new Advice(true, suggested,
$"{tooLate} puntate rifiutate perché tardive: conviene allargare l'anticipo a {suggested} ms.");
}
var actual = samples.Select(s => s.ActualMs).OrderBy(v => v).ToList();
var median = actual[actual.Count / 2];
var worst = actual[0]; // la volta che è arrivata più tardi
var avgPing = samples.Average(s => s.PingMs);
// Margine da non erodere: il ritardo di rete più mezzo cuscinetto, e mai
// sotto il pavimento ricavato dai dossier.
var safety = Math.Max(avgPing + 150, SafeFloorMs);
// Si può guadagnare solo ciò che avanza rispetto al caso peggiore osservato.
var headroom = worst - safety;
if (headroom < 150)
return new Advice(false, currentLeadMs,
$"L'anticipo attuale è già vicino al margine di sicurezza (peggior caso {worst:F0} ms, ping medio {avgPing:F0} ms).");
var proposal = (int)Math.Floor((currentLeadMs - headroom) / 50.0) * 50;
proposal = Math.Max(proposal, (int)Math.Ceiling(safety / 50.0) * 50);
if (proposal >= currentLeadMs)
return new Advice(false, currentLeadMs, "L'anticipo attuale è già adeguato.");
return new Advice(true, proposal,
$"Le puntate arrivano in media {median:F0} ms prima della scadenza e mai oltre {worst:F0} ms: " +
$"si può scendere da {currentLeadMs} a {proposal} ms mantenendo il margine di sicurezza.");
}
}
}
+6 -8
View File
@@ -26,7 +26,7 @@ namespace AutoBidder.Utilities
private static readonly object Sync = new(); private static readonly object Sync = new();
private static StoreData? _cache; private static StoreData? _cache;
public static string StorePath => AppPaths.ClaimedPromosFile; public const string DocumentKey = "claimed-promos";
public sealed class PromoRecord public sealed class PromoRecord
{ {
@@ -129,9 +129,10 @@ namespace AutoBidder.Utilities
try try
{ {
_cache = File.Exists(StorePath) var json = Data.OperationalDatabase.Instance.GetDocumentOrImport(DocumentKey, AppPaths.LegacyClaimedPromosFile);
? JsonSerializer.Deserialize<StoreData>(File.ReadAllText(StorePath)) ?? new StoreData() _cache = string.IsNullOrWhiteSpace(json)
: new StoreData(); ? new StoreData()
: JsonSerializer.Deserialize<StoreData>(json) ?? new StoreData();
} }
catch catch
{ {
@@ -147,10 +148,7 @@ namespace AutoBidder.Utilities
{ {
try try
{ {
AppPaths.EnsureFolders(); Data.OperationalDatabase.Instance.SetDocument(DocumentKey, JsonSerializer.Serialize(_cache));
File.WriteAllText(StorePath,
JsonSerializer.Serialize(_cache, new JsonSerializerOptions { WriteIndented = true }));
} }
catch { /* la persistenza non deve mai bloccare l'interfaccia */ } catch { /* la persistenza non deve mai bloccare l'interfaccia */ }
} }
@@ -197,6 +197,30 @@ namespace AutoBidder.Utilities
return destinazione; return destinazione;
} }
/// <summary>
/// Toglie dallo storico le aste indicate, con tutto ciò che ne dipende (puntate,
/// interrogazioni, reset, registro, decisioni). Restituisce quante ne ha chieste.
/// </summary>
public static int Remove(IReadOnlyCollection<string> auctionIds)
{
if (auctionIds == null || auctionIds.Count == 0) return 0;
var ops = new List<(string, object?[])>(auctionIds.Count * 2);
foreach (var id in auctionIds)
{
if (string.IsNullOrWhiteSpace(id)) continue;
// bot_decisions non ha la chiave esterna: si cancella a mano. Il resto
// segue l'asta per ON DELETE CASCADE.
ops.Add(("DELETE FROM bot_decisions WHERE auction_id = ?1", new object?[] { id }));
ops.Add(("DELETE FROM auctions WHERE auction_id = ?1", new object?[] { id }));
}
Db.EnqueueBatch(ops);
Db.Flush();
lock (_lock) { _cache = null; _cacheVersion = -1; }
return ops.Count / 2;
}
/// <summary>Svuota tutte le osservazioni (aste, puntate, decisioni, misure). I prodotti restano.</summary> /// <summary>Svuota tutte le osservazioni (aste, puntate, decisioni, misure). I prodotti restano.</summary>
public static void Clear() public static void Clear()
{ {
+2 -2
View File
@@ -17,7 +17,7 @@ namespace AutoBidder.Utilities
{ {
public sealed record Result(bool Success, string Path, int Rows, string Message); public sealed record Result(bool Success, string Path, int Rows, string Message);
public static Result ExportCsv(string? destination = null, int maxRows = 0) public static Result ExportCsv(string destination, int maxRows = 0)
{ {
try try
{ {
@@ -34,7 +34,7 @@ namespace AutoBidder.Utilities
var rows = db.Query(sql); var rows = db.Query(sql);
if (rows.Count == 0) return new Result(false, "", 0, "Nessuna decisione registrata finora."); if (rows.Count == 0) return new Result(false, "", 0, "Nessuna decisione registrata finora.");
var path = destination ?? Path.Combine(AppPaths.ExportFolder, $"decisioni-{DateTime.Now:yyyyMMdd-HHmmss}.csv"); var path = destination;
Directory.CreateDirectory(Path.GetDirectoryName(path)!); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var sb = new StringBuilder(); var sb = new StringBuilder();
-185
View File
@@ -1,185 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Text;
using System.Threading;
namespace AutoBidder.Utilities
{
/// <summary>
/// Scrittore di file di testo in aggiunta, condiviso e non bloccante.
///
/// <para>Chi scrive non tocca mai il disco: accoda una riga e prosegue. Un solo thread
/// di fondo svuota la coda ogni mezzo secondo. È l'unico modo perché il registro possa
/// stare anche sui percorsi caldi del motore — l'anello di polling scrive una riga per
/// ogni interrogazione, quattro al secondo per asta, e una scrittura sincrona su disco
/// lì dentro sposterebbe l'istante della puntata.</para>
///
/// <para>La coda ha un tetto: se il disco non tiene il passo si perdono le righe più
/// recenti invece di far crescere la memoria senza fine. Un registro incompleto è un
/// fastidio, un'applicazione che esaurisce la RAM mentre segue un'asta è un danno.</para>
/// </summary>
public sealed class FileLogWriter : IDisposable
{
/// <summary>Righe in attesa oltre le quali si comincia a scartare.</summary>
private const int MaxQueued = 20_000;
/// <summary>Ogni quanto svuotare la coda sul disco.</summary>
private const int FlushIntervalMs = 500;
private static readonly ConcurrentDictionary<string, FileLogWriter> Writers =
new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentQueue<string> _queue = new();
private readonly string _path;
private readonly Timer _timer;
private readonly object _writeLock = new();
private int _dropped;
private int _queued;
private bool _disposed;
private FileLogWriter(string path)
{
_path = path;
try
{
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
}
catch { /* la cartella può essere già lì o non creabile: lo dirà la scrittura */ }
_timer = new Timer(_ => Flush(), null, FlushIntervalMs, FlushIntervalMs);
}
/// <summary>
/// Lo scrittore di quel file, creandolo se serve. Uno solo per percorso: due
/// scrittori sullo stesso file mescolerebbero le righe.
/// </summary>
public static FileLogWriter For(string path) =>
Writers.GetOrAdd(path, static p => new FileLogWriter(p));
public string Path => _path;
/// <summary>Righe scartate per coda piena. Zero è la normalità.</summary>
public int DroppedLines => Volatile.Read(ref _dropped);
public void Write(string line)
{
if (_disposed || line == null) return;
if (Volatile.Read(ref _queued) >= MaxQueued)
{
Interlocked.Increment(ref _dropped);
return;
}
_queue.Enqueue(line);
Interlocked.Increment(ref _queued);
}
/// <summary>Scrive subito quello che è in coda. Da chiamare alla chiusura.</summary>
public void Flush()
{
if (_queue.IsEmpty) return;
// Un solo scrittore alla volta sul file; gli altri tornano indietro invece di
// accodarsi, tanto la coda verrà svuotata comunque.
if (!Monitor.TryEnter(_writeLock)) return;
try
{
var buffer = new StringBuilder();
var taken = 0;
while (taken < 5000 && _queue.TryDequeue(out var line))
{
buffer.AppendLine(line);
taken++;
}
if (taken == 0) return;
Interlocked.Add(ref _queued, -taken);
try
{
File.AppendAllText(_path, buffer.ToString(), Encoding.UTF8);
}
catch
{
// Disco pieno, file bloccato, percorso sparito: il registro non vale
// un errore all'utente né l'interruzione di un'asta.
}
}
finally
{
Monitor.Exit(_writeLock);
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_timer.Dispose();
Flush();
Writers.TryRemove(_path, out _);
}
/// <summary>Svuota e chiude tutti gli scrittori aperti. Da chiamare all'uscita.</summary>
public static void DisposeAll()
{
foreach (var writer in Writers.Values)
{
try { writer.Dispose(); } catch { }
}
}
/// <summary>Solo svuotamento, senza chiudere: per i salvataggi intermedi.</summary>
public static void FlushAll()
{
foreach (var writer in Writers.Values)
{
try { writer.Flush(); } catch { }
}
}
/// <summary>
/// Cancella dalla cartella i registri più vecchi del numero di giorni indicato.
/// Un'applicazione che scrive ogni poll riempie il disco in fretta se nessuno
/// toglie mai nulla.
/// </summary>
public static int PurgeOlderThan(string folder, string pattern, int days)
{
if (days <= 0) return 0;
var removed = 0;
try
{
if (!Directory.Exists(folder)) return 0;
var limit = DateTime.Now.AddDays(-days);
foreach (var file in Directory.GetFiles(folder, pattern))
{
try
{
if (File.GetLastWriteTime(file) >= limit) continue;
if (Writers.ContainsKey(file)) continue; // in uso adesso
File.Delete(file);
removed++;
}
catch { /* un file bloccato si riproverà domani */ }
}
}
catch { }
return removed;
}
}
}
+6 -8
View File
@@ -27,7 +27,7 @@ namespace AutoBidder.Utilities
private static int _sessionClaimed; private static int _sessionClaimed;
private static int _sessionBids; private static int _sessionBids;
public static string StorePath => Path.Combine(AppPaths.StatsFolder, "free-bids-stats.json"); public const string DocumentKey = "free-bids-stats";
/// <summary>Totali persistiti fra un avvio e l'altro.</summary> /// <summary>Totali persistiti fra un avvio e l'altro.</summary>
public sealed class Totals public sealed class Totals
@@ -151,9 +151,10 @@ namespace AutoBidder.Utilities
try try
{ {
_cache = File.Exists(StorePath) var json = Data.OperationalDatabase.Instance.GetDocumentOrImport(DocumentKey, AppPaths.LegacyFreeBidsStatsFile);
? JsonSerializer.Deserialize<Totals>(File.ReadAllText(StorePath)) ?? new Totals() _cache = string.IsNullOrWhiteSpace(json)
: new Totals(); ? new Totals()
: JsonSerializer.Deserialize<Totals>(json) ?? new Totals();
} }
catch catch
{ {
@@ -168,10 +169,7 @@ namespace AutoBidder.Utilities
{ {
try try
{ {
AppPaths.EnsureFolders(); Data.OperationalDatabase.Instance.SetDocument(DocumentKey, JsonSerializer.Serialize(_cache));
File.WriteAllText(StorePath,
JsonSerializer.Serialize(_cache, new JsonSerializerOptions { WriteIndented = true }));
} }
catch { /* i contatori non valgono un errore all'utente */ } catch { /* i contatori non valgono un errore all'utente */ }
} }
+16 -44
View File
@@ -1,73 +1,45 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Text.Json; using System.Text.Json;
using AutoBidder.Data;
using AutoBidder.Models; using AutoBidder.Models;
namespace AutoBidder.Utilities namespace AutoBidder.Utilities
{ {
/// <summary> /// <summary>
/// Gestisce salvataggio/caricamento lista aste /// Le aste nel monitor: un documento JSON nel database d'esercizio, riscritto a ogni
/// modifica. Il vecchio <c>auctions.json</c> viene importato la prima volta.
/// </summary> /// </summary>
public static class PersistenceManager public static class PersistenceManager
{ {
// Percorsi centralizzati: vedi AppPaths (la cartella dati e' configurabile, public const string DocumentKey = "auctions";
// quindi vanno letti a ogni uso, non memorizzati in campi statici).
private static string AppDataFolder => AppPaths.DataFolder; private static readonly JsonSerializerOptions Options = new() { WriteIndented = false };
private static string AuctionsFilePath => AppPaths.AuctionsFile;
/// <summary>
/// Salva lista aste su disco
/// </summary>
public static void SaveAuctions(IEnumerable<AuctionInfo> auctions) public static void SaveAuctions(IEnumerable<AuctionInfo> auctions)
{ {
try try
{ {
// Crea cartella se non esiste OperationalDatabase.Instance.SetDocument(DocumentKey, JsonSerializer.Serialize(auctions, Options));
if (!Directory.Exists(AppDataFolder))
{
Directory.CreateDirectory(AppDataFolder);
}
// Serializza in JSON
var options = new JsonSerializerOptions
{
WriteIndented = true
};
var json = JsonSerializer.Serialize(auctions, options);
File.WriteAllText(AuctionsFilePath, json);
Console.WriteLine($"? Aste salvate in {AuctionsFilePath}");
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"? Errore salvataggio aste: {ex.Message}"); Console.WriteLine($"Errore salvataggio aste: {ex.Message}");
} }
} }
/// <summary>
/// Carica lista aste da disco
/// </summary>
public static List<AuctionInfo> LoadAuctions() public static List<AuctionInfo> LoadAuctions()
{ {
try try
{ {
if (!File.Exists(AuctionsFilePath)) var json = OperationalDatabase.Instance.GetDocumentOrImport(DocumentKey, AppPaths.LegacyAuctionsFile);
{ if (string.IsNullOrWhiteSpace(json)) return new List<AuctionInfo>();
return new List<AuctionInfo>();
} return JsonSerializer.Deserialize<List<AuctionInfo>>(json) ?? new List<AuctionInfo>();
var json = File.ReadAllText(AuctionsFilePath);
var auctions = JsonSerializer.Deserialize<List<AuctionInfo>>(json);
Console.WriteLine($"? Caricate {auctions?.Count ?? 0} aste da {AuctionsFilePath}");
return auctions ?? new List<AuctionInfo>();
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"? Errore caricamento aste: {ex.Message}"); Console.WriteLine($"Errore caricamento aste: {ex.Message}");
return new List<AuctionInfo>(); return new List<AuctionInfo>();
} }
} }
+6 -7
View File
@@ -38,7 +38,7 @@ namespace AutoBidder.Utilities
private static StoreData? _cache; private static StoreData? _cache;
public static string StorePath => AppPaths.ProductsFile; public const string DocumentKey = "products";
/// <summary>Scheda completa di un prodotto: com'è impostato e com'è andata finora.</summary> /// <summary>Scheda completa di un prodotto: com'è impostato e com'è andata finora.</summary>
public sealed class ProductCard public sealed class ProductCard
@@ -338,9 +338,10 @@ namespace AutoBidder.Utilities
try try
{ {
_cache = File.Exists(StorePath) var json = Data.OperationalDatabase.Instance.GetDocumentOrImport(DocumentKey, AppPaths.LegacyProductsFile);
? JsonSerializer.Deserialize<StoreData>(File.ReadAllText(StorePath)) ?? new StoreData() _cache = string.IsNullOrWhiteSpace(json)
: new StoreData(); ? new StoreData()
: JsonSerializer.Deserialize<StoreData>(json) ?? new StoreData();
} }
catch catch
{ {
@@ -356,10 +357,8 @@ namespace AutoBidder.Utilities
{ {
try try
{ {
AppPaths.EnsureFolders();
data.UpdatedAt = DateTime.Now; data.UpdatedAt = DateTime.Now;
File.WriteAllText(StorePath, JsonSerializer.Serialize(data, Options)); Data.OperationalDatabase.Instance.SetDocument(DocumentKey, JsonSerializer.Serialize(data, Options));
} }
catch { /* la persistenza non deve mai bloccare l'interfaccia */ } catch { /* la persistenza non deve mai bloccare l'interfaccia */ }
} }
+19 -63
View File
@@ -120,15 +120,6 @@ namespace AutoBidder.Utilities
// ANTICIPO PUNTATA — misura e taratura // ANTICIPO PUNTATA — misura e taratura
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
/// <summary>
/// Registra l'anticipo effettivo di ogni puntata, per capire se il valore
/// impostato è quello giusto. Default: true — è solo una misura, non cambia nulla.
/// </summary>
public bool BidLeadTrackingEnabled { get; set; } = true;
/// <summary>Misure necessarie prima di azzardare una proposta. Default: 15.</summary>
public int BidLeadMinSamples { get; set; } = 15;
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// NOTIFICHE // NOTIFICHE
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -144,43 +135,22 @@ namespace AutoBidder.Utilities
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// Vuoto = posizione predefinita sotto %LocalAppData%\AutoBidder. // Vuoto = posizione predefinita sotto %LocalAppData%\AutoBidder.
/// <summary>Dati d'esercizio: aste monitorate e prodotti seguiti.</summary>
public string DataFolder { get; set; } = "";
/// <summary>
/// Statistiche ed esportazioni. Separata perché cresce nel tempo e serve
/// soprattutto per le analisi fatte fuori dall'applicazione.
/// </summary>
public string StatsFolder { get; set; } = "";
/// <summary>
/// Registri di testo: log applicativo, riscatto puntate, dossier delle aste.
/// Vuoto = <c>Dati\Registri</c>.
/// </summary>
public string LogFolder { get; set; } = "";
/// <summary>
/// File del database SQLite. Vuoto = <c>%LocalAppData%\AutoBidder\Database\autobidder.sqlite</c>.
/// Non va messo in una cartella sincronizzata.
/// </summary>
public string DatabasePath { get; set; } = "";
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// REGISTRI SU FILE // REGISTRI SU FILE
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
/// <summary>Scrive il log applicativo anche su file, un file per giorno.</summary> /// <summary>Scrive il log applicativo anche su file, un file per giorno.</summary>
public bool WriteAppLogFile { get; set; } = true; public bool WriteAppLog { get; set; } = true;
/// <summary>Scrive su file il registro del riscatto puntate.</summary> /// <summary>Scrive su file il registro del riscatto puntate.</summary>
public bool WriteFreeBidsLogFile { get; set; } = true; public bool WriteFreeBidsLog { get; set; } = true;
/// <summary> /// <summary>
/// Registra un dossier per ogni asta seguita: intestazione, eventi e riepilogo, in /// Registra un dossier per ogni asta seguita: intestazione, eventi e riepilogo, in
/// formato JSON Lines. È il materiale su cui si studiano le strategie, e va raccolto /// formato JSON Lines. È il materiale su cui si studiano le strategie, e va raccolto
/// mentre l'asta è in corso — a posteriori Bidoo non espone nulla di tutto questo. /// mentre l'asta è in corso — a posteriori Bidoo non espone nulla di tutto questo.
/// </summary> /// </summary>
public bool WriteAuctionDossiers { get; set; } = true; public bool RecordAuctions { get; set; } = true;
/// <summary> /// <summary>
/// Registra nel dossier <b>ogni singola interrogazione</b> a Bidoo, non solo gli /// Registra nel dossier <b>ogni singola interrogazione</b> a Bidoo, non solo gli
@@ -188,7 +158,7 @@ namespace AutoBidder.Utilities
/// da pagare è la dimensione: nella finestra critica sono quattro righe al secondo, /// da pagare è la dimensione: nella finestra critica sono quattro righe al secondo,
/// quindi un'asta lunga vale qualche megabyte. /// quindi un'asta lunga vale qualche megabyte.
/// </summary> /// </summary>
public bool DossierIncludeRawPolls { get; set; } = true; public bool RecordPolls { get; set; } = true;
/// <summary> /// <summary>
/// Dopo quanti giorni cancellare i registri. 0 = tenerli per sempre. /// Dopo quanti giorni cancellare i registri. 0 = tenerli per sempre.
@@ -196,6 +166,20 @@ namespace AutoBidder.Utilities
/// </summary> /// </summary>
public int LogRetentionDays { get; set; } = 90; public int LogRetentionDays { get; set; } = 90;
/// <summary>
/// La cartella dei due database (osservazioni ed esercizio). Vuota =
/// <c>%LocalAppData%\AutoBidder\Database</c>. Mai una cartella sincronizzata.
/// </summary>
public string DatabaseFolder { get; set; } = "";
/// <summary>
/// L'interruttore dell'apprendimento (barra del monitor). Spento, il motore punta
/// solo entro i limiti dell'utente — prezzo, puntate, budget, fascia oraria — con
/// l'anticipo fisso: niente valore atteso, regime, bandit, duello, anticipo adattivo.
/// L'apprendimento continua a raccogliere e studiare le aste chiuse.
/// </summary>
public bool LearningEnabled { get; set; } = true;
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// CATALOGO — cache // CATALOGO — cache
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -385,38 +369,10 @@ namespace AutoBidder.Utilities
// 🎯 LOGGING GRANULARE // 🎯 LOGGING GRANULARE
// ??????????????????????????????????????????????????????????????? // ???????????????????????????????????????????????????????????????
/// <summary>
/// Log quando viene piazzata una puntata [BID]
/// Default: true
/// </summary>
public bool LogBids { get; set; } = true;
/// <summary>
/// Log quando una strategia blocca la puntata [STRATEGY]
/// Default: true
/// </summary>
public bool LogStrategyDecisions { get; set; } = true;
/// <summary>
/// Log errori e warning [ERROR/WARN]
/// Default: true
/// </summary>
public bool LogErrors { get; set; } = true;
/// <summary>
/// Applica automaticamente i limiti salvati nel prodotto quando si aggiunge una nuova asta.
/// Se TRUE e il prodotto ha valori di default salvati, li applica automaticamente.
/// Default: true (consigliato per coerenza)
/// </summary>
public bool AutoApplyProductDefaults { get; set; } = true;
/// <summary>
/// Scelta priorità limiti quando si aggiunge un'asta per un prodotto già salvato:
/// - "ProductStats": Usa i limiti personalizzati salvati nelle statistiche prodotto (UserDefaultMinPrice, ecc.)
/// - "GlobalDefaults": Usa sempre i limiti globali (DefaultMinPrice, DefaultMaxPrice, ecc.)
/// Default: "ProductStats" (consigliato per usare limiti specifici per prodotto)
/// </summary>
public string NewAuctionLimitsPriority { get; set; } = "ProductStats";
/// <summary> /// <summary>
/// Log stato asta (terminata, reset, ecc.) [STATUS] /// Log stato asta (terminata, reset, ecc.) [STATUS]
@@ -685,7 +641,7 @@ namespace AutoBidder.Utilities
private const int CACHE_TTL_MS = 2000; private const int CACHE_TTL_MS = 2000;
/// <summary>Schema corrente. Alzarlo fa girare <see cref="Migra"/> una volta sola.</summary> /// <summary>Schema corrente. Alzarlo fa girare <see cref="Migra"/> una volta sola.</summary>
public const int SchemaCorrente = 1; public const int SchemaCorrente = 2;
/// <summary> /// <summary>
/// Porta avanti un file scritto da una versione precedente. Restituisce true se /// Porta avanti un file scritto da una versione precedente. Restituisce true se
+4 -7
View File
@@ -32,7 +32,7 @@ namespace AutoBidder.Utilities
/// Tutto lo storico in un CSV: una riga per asta, colonne pronte per un foglio /// Tutto lo storico in un CSV: una riga per asta, colonne pronte per un foglio
/// di calcolo. /// di calcolo.
/// </summary> /// </summary>
public static Result ExportCsv(string? destination = null) public static Result ExportCsv(string destination)
{ {
try try
{ {
@@ -42,7 +42,7 @@ namespace AutoBidder.Utilities
var rows = Merge(details, summaries); var rows = Merge(details, summaries);
if (rows.Count == 0) return new Result(false, "", 0, "Nessuna asta conclusa da esportare."); if (rows.Count == 0) return new Result(false, "", 0, "Nessuna asta conclusa da esportare.");
var path = destination ?? DefaultPath("csv"); var path = destination;
Directory.CreateDirectory(Path.GetDirectoryName(path)!); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -103,7 +103,7 @@ namespace AutoBidder.Utilities
/// Tutto lo storico in JSON, comprese serie prezzi e puntate per utente: è il /// Tutto lo storico in JSON, comprese serie prezzi e puntate per utente: è il
/// formato da dare in pasto a un'analisi seria. /// formato da dare in pasto a un'analisi seria.
/// </summary> /// </summary>
public static Result ExportJson(string? destination = null) public static Result ExportJson(string destination)
{ {
try try
{ {
@@ -113,7 +113,7 @@ namespace AutoBidder.Utilities
var rows = Merge(details, summaries); var rows = Merge(details, summaries);
if (rows.Count == 0) return new Result(false, "", 0, "Nessuna asta conclusa da esportare."); if (rows.Count == 0) return new Result(false, "", 0, "Nessuna asta conclusa da esportare.");
var path = destination ?? DefaultPath("json"); var path = destination;
Directory.CreateDirectory(Path.GetDirectoryName(path)!); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var payload = new var payload = new
@@ -190,9 +190,6 @@ namespace AutoBidder.Utilities
return byId.Values.OrderByDescending(r => r.EndedAt).ToList(); return byId.Values.OrderByDescending(r => r.EndedAt).ToList();
} }
private static string DefaultPath(string extension) =>
Path.Combine(AppPaths.ExportFolder, $"storico-aste-{DateTime.Now:yyyyMMdd-HHmmss}.{extension}");
/// <summary>Virgola decimale: è quella che si aspetta Excel in italiano.</summary> /// <summary>Virgola decimale: è quella che si aspetta Excel in italiano.</summary>
private static string Num(double value, int decimals = 2) => private static string Num(double value, int decimals = 2) =>
double.IsFinite(value) double.IsFinite(value)
+97 -101
View File
@@ -10,13 +10,11 @@ namespace AutoBidder.Utilities
/// Azzera le statistiche registrate, una voce alla volta e solo quelle scelte. /// 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 /// <para>Ogni voce è un archivio con un padrone diverso, e ognuno si svuota nel modo
/// suo: le osservazioni del database passano dalla copia di sicurezza, le statistiche /// suo: le osservazioni passano dalla copia di sicurezza del database, le statistiche
/// per prodotto tengono le opzioni e azzerano i numeri, l'apprendimento dimentica /// 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 /// anche in memoria — se no il modello ancora vivo riscriverebbe il documento al primo
/// salvataggio.</para> /// salvataggio. Gli archivi delle versioni precedenti sono i file JSON e i dossier
/// /// nella vecchia cartella <c>Dati</c>: ormai importati o inutili, ma anche gigabyte.</para>
/// <para><see cref="Measure"/> pesa ogni voce prima: cancellare è irreversibile, e
/// dire quanto sparisce è il minimo per una scelta informata.</para>
/// </summary> /// </summary>
public static class StatsWipe public static class StatsWipe
{ {
@@ -25,21 +23,17 @@ namespace AutoBidder.Utilities
/// <summary>Le osservazioni del database: aste, puntate, interrogazioni, decisioni, misure.</summary> /// <summary>Le osservazioni del database: aste, puntate, interrogazioni, decisioni, misure.</summary>
public bool History { get; set; } = true; public bool History { get; set; } = true;
public bool ProductStats { get; set; } = true; public bool ProductStats { get; set; } = true;
public bool BidLeadMeasures { get; set; } = true;
public bool Exports { get; set; } = true;
/// <summary>Gli archivi JSON delle versioni precedenti (storico compatto, archivi mensili).</summary> /// <summary>La vecchia cartella <c>Dati</c>: JSON già importati, statistiche, registri e dossier.</summary>
public bool LegacyArchives { get; set; } = true; public bool LegacyArchives { get; set; }
public bool Learning { get; set; } public bool Learning { get; set; }
public bool Nothing => !(History || ProductStats || BidLeadMeasures || Exports || LegacyArchives || Learning); public bool Nothing => !(History || ProductStats || LegacyArchives || Learning);
} }
public readonly record struct Sizes( public readonly record struct Sizes(
int HistoryAuctions, long HistoryBytes, int HistoryAuctions, long HistoryBytes,
long ProductsBytes, long ProductsBytes,
int BidLeadSamples, long BidLeadBytes,
int ExportFiles, long ExportBytes,
int LegacyFiles, long LegacyBytes, int LegacyFiles, long LegacyBytes,
long LearningBytes); long LearningBytes);
@@ -56,28 +50,33 @@ namespace AutoBidder.Utilities
(Errors.Count > 0 ? $". Problemi: {string.Join("; ", Errors)}" : ""); (Errors.Count > 0 ? $". Problemi: {string.Join("; ", Errors)}" : "");
} }
private static string LearningFolder => Path.Combine(AppPaths.StatsFolder, "Apprendimento");
// ── Misura ─────────────────────────────────────────────────────── // ── Misura ───────────────────────────────────────────────────────
public static Sizes Measure() public static Sizes Measure()
{ {
int aste = 0, misure = 0; int aste = 0;
long dbBytes = 0; long dbBytes = 0, productsBytes = 0, learningBytes = 0;
try { aste = (int)AuctionDatabase.Instance.Count("auctions"); dbBytes = AuctionDatabase.Instance.SizeBytes; } catch { }
try { misure = BidLeadStats.All().Count; } catch { }
var export = ListFiles(AppPaths.ExportFolder, "*"); try
var legacy = LegacyFiles(); {
var learning = ListFiles(LearningFolder, "*"); aste = (int)AuctionDatabase.Instance.Count("auctions");
dbBytes = AuctionDatabase.Instance.SizeBytes;
}
catch { }
return new Sizes( try
aste, dbBytes, {
SizeOf(AppPaths.ProductsFile), foreach (var (key, bytes, _) in OperationalDatabase.Instance.Documents())
misure, SizeOf(AppPaths.BidLeadStatsFile), {
export.Count, export.Sum(SizeOf), if (key == ProductStatsStore.DocumentKey) productsBytes += bytes;
legacy.Count, legacy.Sum(SizeOf), else if (key.StartsWith("ml/", StringComparison.Ordinal)) learningBytes += bytes;
learning.Sum(SizeOf)); }
}
catch { }
var (legacyFiles, legacyBytes) = MeasureFolder(AppPaths.LegacyDataFolder);
return new Sizes(aste, dbBytes, productsBytes, legacyFiles, legacyBytes, learningBytes);
} }
// ── Azzeramento ────────────────────────────────────────────────── // ── Azzeramento ──────────────────────────────────────────────────
@@ -114,26 +113,9 @@ namespace AutoBidder.Utilities
}); });
} }
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) if (o.LegacyArchives)
{ {
var (n, bytes) = DeleteAll(report, "archivi precedenti", LegacyFiles()); var (n, bytes) = DeleteFolder(report, "archivi precedenti", AppPaths.LegacyDataFolder);
report.FilesDeleted += n; report.FilesDeleted += n;
report.BytesFreed += bytes; report.BytesFreed += bytes;
report.Steps.Add($"archivi delle versioni precedenti: {n} file tolti"); report.Steps.Add($"archivi delle versioni precedenti: {n} file tolti");
@@ -145,61 +127,24 @@ namespace AutoBidder.Utilities
{ {
Ml.LearningService.Forget(); Ml.LearningService.Forget();
Ml.LatencyModel.Forget(); Ml.LatencyModel.Forget();
report.Steps.Add("apprendimento azzerato: ristudia le aste al prossimo avvio");
}); });
var (n, bytes) = DeleteAll(report, "apprendimento", ListFiles(LearningFolder, "*"));
report.FilesDeleted += n;
report.BytesFreed += bytes;
report.Steps.Add("apprendimento azzerato: ristudia le aste al prossimo avvio");
} }
return report; 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 ───────────────────────────────────────────────────── // ── Attrezzi ─────────────────────────────────────────────────────
/// <summary> private static (int Files, long Bytes) MeasureFolder(string folder)
/// Gli archivi JSON delle versioni precedenti: lo storico compatto, gli archivi
/// mensili delle schede, il vecchio storico dei prodotti. Il database li ha
/// sostituiti tutti.
/// </summary>
private static List<string> LegacyFiles()
{
var result = new List<string>();
var folder = AppPaths.StatsFolder;
if (!Directory.Exists(folder)) return result;
try
{
foreach (var f in Directory.EnumerateFiles(folder, "aste-*.json*"))
{
var name = Path.GetFileName(f);
if (name.Length >= 12 && char.IsDigit(name[5])) result.Add(f);
}
var compact = Path.Combine(folder, "completed-auctions.json");
if (File.Exists(compact)) result.Add(compact);
}
catch { }
return result;
}
private static List<string> ListFiles(string folder, string pattern)
{ {
try try
{ {
return Directory.Exists(folder) if (!Directory.Exists(folder)) return (0, 0);
? Directory.EnumerateFiles(folder, pattern).ToList() var files = Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).ToList();
: new List<string>(); return (files.Count, files.Sum(SizeOf));
} }
catch { return new List<string>(); } catch { return (0, 0); }
} }
private static long SizeOf(string path) private static long SizeOf(string path)
@@ -208,24 +153,35 @@ namespace AutoBidder.Utilities
catch { return 0; } catch { return 0; }
} }
private static (int Files, long Bytes) DeleteAll(Report report, string label, IEnumerable<string> files) private static (int Files, long Bytes) DeleteFolder(Report report, string label, string folder)
{ {
var n = 0; var n = 0;
long bytes = 0; long bytes = 0;
foreach (var f in files) try
{ {
try if (!Directory.Exists(folder)) return (0, 0);
foreach (var f in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).ToList())
{ {
var size = SizeOf(f); try
File.Delete(f); {
n++; var size = SizeOf(f);
bytes += size; File.Delete(f);
} n++;
catch (Exception ex) bytes += size;
{ }
report.Errors.Add($"{label}: {Path.GetFileName(f)} non cancellato ({ex.Message})"); catch (Exception ex)
{
report.Errors.Add($"{label}: {Path.GetFileName(f)} non cancellato ({ex.Message})");
}
} }
try { Directory.Delete(folder, recursive: true); } catch { }
}
catch (Exception ex)
{
report.Errors.Add($"{label}: {ex.Message}");
} }
return (n, bytes); return (n, bytes);
@@ -237,4 +193,44 @@ namespace AutoBidder.Utilities
catch (Exception ex) { report.Errors.Add($"{label}: {ex.Message}"); } catch (Exception ex) { report.Errors.Add($"{label}: {ex.Message}"); }
} }
} }
/// <summary>
/// Le due operazioni estreme delle Impostazioni: cancellare tutti i dati tranne la
/// login, e riportare le impostazioni a quelle di fabbrica. Entrambe chiudono i
/// database prima, e chi le chiama riavvia l'applicazione subito dopo.
/// </summary>
public static class AppDataWipe
{
/// <summary>
/// Cancella i due database, le copie di sicurezza e la vecchia cartella dei dati.
/// Restano la sessione, i riferimenti del sito, il profilo del browser e le
/// impostazioni.
/// </summary>
public static List<string> WipeAllExceptLogin()
{
var errors = new List<string>();
try { AuctionDatabase.CloseInstance(); } catch { }
try { OperationalDatabase.CloseInstance(); } catch { }
foreach (var db in new[] { AppPaths.DatabaseFile, AppPaths.OperationalDatabaseFile })
foreach (var f in AppPaths.DatabaseFiles(db))
try { File.Delete(f); } catch (Exception ex) { errors.Add($"{Path.GetFileName(f)}: {ex.Message}"); }
foreach (var folder in new[] { AppPaths.BackupsFolder, AppPaths.LegacyDataFolder })
{
try { if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); }
catch (Exception ex) { errors.Add($"{folder}: {ex.Message}"); }
}
return errors;
}
/// <summary>Cancella il file delle impostazioni: al prossimo avvio valgono i predefiniti.</summary>
public static void ResetSettings()
{
try { if (File.Exists(AppPaths.SettingsFile)) File.Delete(AppPaths.SettingsFile); }
catch { }
}
}
} }
+48 -44
View File
@@ -1,87 +1,91 @@
using System; using System;
using AutoBidder.Utilities; using System.Collections.Generic;
using AutoBidder.Data;
namespace AutoBidder.Utilities namespace AutoBidder.Utilities
{ {
/// <summary> /// <summary>
/// I due registri leggibili dell'applicazione: quello generale e quello del riscatto /// I due registri dell'applicazione: quello generale e quello del riscatto puntate.
/// puntate.
/// ///
/// <para>Sono file di testo, uno per giorno, scritti <b>man mano</b>: quello che si vede /// <para>Sono tabelle del database d'esercizio, scritte <b>man mano</b>: quello che si
/// nella finestra è già sul disco. Serve a due cose che l'interfaccia non può dare — /// vede nella finestra è già sul disco. Servono a rileggere cosa è successo mentre
/// leggere cosa è successo mentre l'applicazione era chiusa (o dopo un blocco), e avere /// l'applicazione era chiusa (o dopo un blocco), e a tenere più delle poche centinaia
/// più delle poche centinaia di righe che i pannelli tengono in memoria.</para> /// di righe che i pannelli hanno in memoria. Si esportano in testo dalle Impostazioni
/// /// e si cancellano per età.</para>
/// <para>Il cambio di giorno si gestisce da sé: il percorso si ricalcola a ogni riga, e
/// una sessione aperta a cavallo della mezzanotte continua sul file nuovo.</para>
/// </summary> /// </summary>
public static class TextLogService public static class TextLogService
{ {
private static DateTime _lastPurge = DateTime.MinValue; private static DateTime _lastPurge = DateTime.MinValue;
/// <summary>Registro applicativo del giorno corrente.</summary>
public static string AppLogPath => AppPaths.AppLogFile(DateTime.Now);
/// <summary>Registro del riscatto puntate del giorno corrente.</summary>
public static string FreeBidsLogPath => AppPaths.FreeBidsLogFile(DateTime.Now);
/// <summary> /// <summary>
/// Scrive una riga nel registro applicativo. <paramref name="level"/> è già la /// Scrive una riga nel registro applicativo. <paramref name="level"/> è già la
/// sigla usata a video, così file e finestra raccontano la stessa storia. /// sigla usata a video, così registro e finestra raccontano la stessa storia.
/// </summary> /// </summary>
public static void App(string level, string message) public static void App(string level, string message)
{ {
if (!SettingsManager.Load().WriteAppLogFile) return; if (!SettingsManager.Load().WriteAppLog) return;
Write(OperationalDatabase.AppLog, level, message);
Write(AppLogPath, level, message);
} }
/// <summary>Scrive una riga nel registro del riscatto puntate.</summary> /// <summary>Scrive una riga nel registro del riscatto puntate.</summary>
public static void FreeBids(string message, bool isProblem = false) public static void FreeBids(string message, bool isProblem = false)
{ {
if (!SettingsManager.Load().WriteFreeBidsLogFile) return; if (!SettingsManager.Load().WriteFreeBidsLog) return;
Write(OperationalDatabase.FreeBidsLog, isProblem ? "WARN" : "INFO", message);
Write(FreeBidsLogPath, isProblem ? "WARN" : "INFO", message);
} }
private static void Write(string path, string level, string message) private static readonly List<(string Table, string Level, string Message)> _early = new();
[ThreadStatic] private static bool _writing;
private static void Write(string table, string level, string message)
{ {
try try
{ {
// Le righe a capo spezzerebbero il formato "una riga = un evento", che è
// ciò che rende il file leggibile con qualunque strumento.
var clean = (message ?? "") var clean = (message ?? "")
.Replace("\r", " ", StringComparison.Ordinal) .Replace("\r", " ", StringComparison.Ordinal)
.Replace("\n", " ", StringComparison.Ordinal); .Replace("\n", " ", StringComparison.Ordinal);
FileLogWriter.For(path).Write($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {clean}"); // Il database d'esercizio, aprendosi, scrive nel registro: quelle righe
// arrivano qui prima che esista. Si tengono da parte e si scrivono dopo.
if (_writing || !OperationalDatabase.IsOpen)
{
lock (_early) if (_early.Count < 500) _early.Add((table, level, clean));
return;
}
_writing = true;
try
{
var db = OperationalDatabase.Instance;
lock (_early)
{
foreach (var (t, l, m) in _early) db.WriteLog(t, l, m);
_early.Clear();
}
db.WriteLog(table, level, clean);
}
finally { _writing = false; }
} }
catch { /* il registro non vale un errore all'utente */ } catch { /* il registro non vale un errore all'utente */ }
} }
/// <summary>
/// Scrive l'intestazione di sessione. Senza, riaprendo un file di ieri non si
/// capirebbe dove finisce un avvio e comincia il successivo.
/// </summary>
public static void SessionStarted(string version) public static void SessionStarted(string version)
{ {
App("INFO", new string('─', 60)); App("INFO", new string('─', 60));
App("INFO", $"AutoBidder {version} — avvio sessione"); App("INFO", $"AutoBidder {version} — avvio sessione");
App("INFO", $"Dati: {AppPaths.DataFolder}"); App("INFO", $"Impostazioni: {AppPaths.SettingsFile}");
App("INFO", $"Statistiche: {AppPaths.StatsFolder}"); App("INFO", $"Database: {AppPaths.DatabaseFolder}");
App("INFO", $"Registri: {AppPaths.LogFolder}");
} }
public static void SessionEnded() public static void SessionEnded()
{ {
App("INFO", "Chiusura sessione"); App("INFO", "Chiusura sessione");
FileLogWriter.FlushAll(); try { OperationalDatabase.Instance.Flush(); } catch { }
} }
/// <summary> /// <summary>
/// Cancella i registri scaduti. Girata una volta per avvio (e non più di una volta /// Cancella le righe scadute. Una volta per avvio, e non più di una volta ogni
/// al giorno): con i poll grezzi accesi lo spazio cresce in fretta, ma passare la /// dodici ore.
/// cartella a ogni riga di log sarebbe assurdo.
/// </summary> /// </summary>
public static void PurgeOldLogs() public static void PurgeOldLogs()
{ {
@@ -91,12 +95,12 @@ namespace AutoBidder.Utilities
if ((DateTime.Now - _lastPurge).TotalHours < 12) return; if ((DateTime.Now - _lastPurge).TotalHours < 12) return;
_lastPurge = DateTime.Now; _lastPurge = DateTime.Now;
var days = settings.LogRetentionDays; try
{
var removed = FileLogWriter.PurgeOlderThan(AppPaths.LogFolder, "app-*.log", days) var removed = OperationalDatabase.Instance.PurgeLogs(settings.LogRetentionDays);
+ FileLogWriter.PurgeOlderThan(AppPaths.LogFolder, "puntate-*.log", days); if (removed > 0) App("INFO", $"Registri scaduti rimossi: {removed} righe (oltre {settings.LogRetentionDays} giorni)");
}
if (removed > 0) App("INFO", $"Registri scaduti rimossi: {removed} (oltre {days} giorni)"); catch { }
} }
} }
} }
+35 -10
View File
@@ -15,9 +15,7 @@ namespace AutoBidder.Utilities
/// </summary> /// </summary>
public static class WatchedProductsStore public static class WatchedProductsStore
{ {
private static string Folder => AppPaths.DataFolder; public const string DocumentKey = "watched-products";
public static string StorePath => AppPaths.WatchedProductsFile;
/// <summary>Oltre questa soglia gli id più vecchi vengono dimenticati.</summary> /// <summary>Oltre questa soglia gli id più vecchi vengono dimenticati.</summary>
private const int MaxHandledIds = 2000; private const int MaxHandledIds = 2000;
@@ -41,9 +39,10 @@ namespace AutoBidder.Utilities
try try
{ {
_cache = File.Exists(StorePath) var json = Data.OperationalDatabase.Instance.GetDocumentOrImport(DocumentKey, AppPaths.LegacyWatchedProductsFile);
? JsonSerializer.Deserialize<StoreData>(File.ReadAllText(StorePath)) ?? new StoreData() _cache = string.IsNullOrWhiteSpace(json)
: new StoreData(); ? new StoreData()
: JsonSerializer.Deserialize<StoreData>(json) ?? new StoreData();
} }
catch catch
{ {
@@ -59,12 +58,9 @@ namespace AutoBidder.Utilities
{ {
try try
{ {
if (!Directory.Exists(Folder)) Directory.CreateDirectory(Folder);
lock (Sync) lock (Sync)
{ {
var json = JsonSerializer.Serialize(_cache, new JsonSerializerOptions { WriteIndented = true }); Data.OperationalDatabase.Instance.SetDocument(DocumentKey, JsonSerializer.Serialize(_cache));
File.WriteAllText(StorePath, json);
} }
} }
catch { /* la persistenza non deve mai bloccare l'interfaccia */ } catch { /* la persistenza non deve mai bloccare l'interfaccia */ }
@@ -214,6 +210,35 @@ namespace AutoBidder.Utilities
Save(); Save();
} }
/// <summary>Spegne la stellina di tutti i prodotti: restano in elenco con i loro limiti.</summary>
public static int UnwatchAll()
{
int n;
lock (Sync)
{
var data = Load();
n = data.Products.Count(p => p.IsWatched);
foreach (var p in data.Products) p.IsWatched = false;
}
if (n > 0) Save();
return n;
}
/// <summary>Toglie dall'elenco i prodotti senza stellina, con i loro limiti.</summary>
public static int RemoveUnwatched()
{
int n;
lock (Sync)
{
var data = Load();
n = data.Products.RemoveAll(p => !p.IsWatched);
}
if (n > 0) Save();
return n;
}
// ── Aste già trattate ──────────────────────────────────────────── // ── Aste già trattate ────────────────────────────────────────────
public static bool IsHandled(string auctionId) public static bool IsHandled(string auctionId)