1 Commits
Author SHA1 Message Date
Alby96andClaude Fable 5.1 3985935958 Ricalcolo dei prodotti dallo storico, finestra di avanzamento, operazioni sull'apprendimento, barre uguali
Nella scheda Prodotti c'è il pulsante che rilegge tutto lo storico e rifà da capo i
numeri di ogni articolo: aste viste, prezzo minimo, massimo e medio, puntate del
vincitore, vittorie, osservazioni. I limiti scritti a mano e la stellina non cambiano.
Il ricalcolo si costruisce da parte e sostituisce le schede solo alla fine: un
annullamento a metà lascia quelle di prima.

Ogni operazione massiva passa da una finestra di avanzamento (ProgressDialog): titolo,
a che punto si è, barra, conto fatto/totale, tempo passato e Annulla. Modale quando è
questione di secondi, altrimenti si continua a usare l'applicazione: il recupero delle
puntate dei vincitori e lo studio delle aste vanno piano di proposito.

La scheda Apprendimento ha un menu di operazioni: studiare le aste chiuse non ancora
apprese (lo studio all'avvio si ferma dopo qualche minuto, questo va in fondo),
ricostruire il profilo per prodotto e fascia oraria, rifare il bandit dalle decisioni
registrate, azzerare lo sfidante o la calibrazione, ricominciare da capo. Lo studio
delle aste è una sola routine con budget di tempo facoltativo, avanzamento e
annullamento; quello che è già studiato resta studiato.

Le barre in alto di tutte le schede hanno la stessa altezza (stile TabToolbar): passando
da una scheda all'altra i pulsanti restano allo stesso posto.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 14:47:47 +02:00
21 changed files with 759 additions and 60 deletions
+1 -2
View File
@@ -35,8 +35,7 @@
</Grid.RowDefinitions>
<!-- ═══════════════ BARRA STRUMENTI ═══════════════ -->
<Border Grid.Row="0" Background="{DynamicResource Brush.Surface}" Padding="10,7"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<Border Grid.Row="0" Style="{StaticResource TabToolbar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
+1 -2
View File
@@ -148,8 +148,7 @@
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Background="{DynamicResource Brush.Surface}" Padding="10,7"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<Border Grid.Row="0" Style="{StaticResource TabToolbar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
+1 -2
View File
@@ -35,8 +35,7 @@
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Background="{DynamicResource Brush.Surface}" Padding="10,7"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<Border Grid.Row="0" Style="{StaticResource TabToolbar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
+36 -2
View File
@@ -33,8 +33,7 @@
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Background="{DynamicResource Brush.Surface}" Padding="10,7"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<Border Grid.Row="0" Style="{StaticResource TabToolbar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@@ -87,6 +86,41 @@
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
<ToggleButton x:Name="OptionsButton" Style="{StaticResource IconToggle}"
ToolTip="Altre operazioni sull'apprendimento.&#10;Studiare le aste non ancora apprese, ricostruire il profilo, rifare il bandit, azzerare lo sfidante o la calibrazione. Ognuna mostra l'avanzamento e si può annullare.">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE9E9;"/>
</ToggleButton>
<Popup x:Name="OptionsPopup" PlacementTarget="{Binding ElementName=OptionsButton}" Placement="Bottom"
StaysOpen="False" AllowsTransparency="True" HorizontalOffset="-300" VerticalOffset="4"
IsOpen="{Binding IsChecked, ElementName=OptionsButton, Mode=TwoWay}">
<Border Background="{DynamicResource Brush.Surface}" BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1" CornerRadius="10" Padding="6" Width="340">
<StackPanel>
<TextBlock Style="{StaticResource Hint}" Margin="10,4,10,6" Text="Operazioni sull'apprendimento"/>
<Button Style="{StaticResource MenuEntry}" Content="Studia le aste chiuse non ancora apprese"
ToolTip="Lo studio automatico all'avvio si ferma dopo qualche minuto: questo va in fondo a tutte le aste chiuse del database che il modello non ha ancora visto. Non tocca ciò che è già appreso."
Click="LearnPending_Click"/>
<Button Style="{StaticResource MenuEntry}" Content="Ricostruisci il profilo per prodotto e fascia oraria"
ToolTip="Rifà da tutto lo storico le puntate tipiche del vincitore e il rapporto di chiusura per prodotto, fascia oraria e giorno. Modello e bandit non si toccano."
Click="RebuildProfile_Click"/>
<Button Style="{StaticResource MenuEntry}" Content="Rifai il bandit dalle decisioni registrate"
ToolTip="Butta il bandit Thompson e lo rifà da tutte le decisioni con esito registrate nel database: le stesse osservazioni che avrebbe raccolto asta per asta, senza doppioni."
Click="RebuildBandit_Click"/>
<Button Style="{StaticResource MenuEntry}" Content="Azzera lo sfidante (rete neurale)"
ToolTip="La rete riparte da zero e il confronto Brier ricomincia. Il campione logistico resta com'è: è lui che risponde al motore finché lo sfidante non lo batte."
Click="ResetChallenger_Click"/>
<Button Style="{StaticResource MenuEntry}" Content="Azzera la calibrazione del modello"
ToolTip="Il fattore osservato/previsto torna a 1 e si rimisura sugli esempi nuovi. Utile se il mercato è cambiato di colpo e la scala delle probabilità è rimasta indietro."
Click="ResetCalibration_Click"/>
<Border Height="1" Background="{DynamicResource Brush.Border}" Margin="6,6"/>
<Button Style="{StaticResource MenuEntry}" Content="Ricomincia da capo: azzera tutto e ristudia lo storico"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Butta modello, profilo, sfidante, bandit e contatore delle aste studiate, e ristudia tutte le aste chiuse del database. Non tocca lo storico né i prodotti. Chiede conferma."
Click="RetrainButton_Click"/>
</StackPanel>
</Border>
</Popup>
<Button x:Name="RetrainButton" Style="{StaticResource IconButton}"
Foreground="{DynamicResource Brush.Danger}"
ToolTip="Ricomincia da capo.&#10;Butta modello, profilo, sfidante, bandit e latenza appresa, e ristudia tutte le aste chiuse del database. Non tocca lo storico né i prodotti. Chiede conferma."
+73 -4
View File
@@ -352,11 +352,78 @@ namespace AutoBidder.Controls
}
}
// ── Operazioni sull'apprendimento: tutte con la finestra di avanzamento ──
private Window? Host => Window.GetWindow(this);
private void CloseOptions() => OptionsButton.IsChecked = false;
/// <summary>Dice com'è finita un'operazione: errore, annullata, o il testo di riepilogo.</summary>
private void Conclude<T>(ProgressOutcome<T> esito, string titolo, Func<T, string> riepilogo)
{
Refresh();
if (esito.Error != null)
MessageBox.Show(Host!, esito.Error.Message, titolo, MessageBoxButton.OK, MessageBoxImage.Warning);
else if (esito.Cancelled)
MessageBox.Show(Host!, "Operazione annullata. Quello che era già fatto resta fatto.", titolo, MessageBoxButton.OK, MessageBoxImage.Information);
else
MessageBox.Show(Host!, riepilogo(esito.Result!), titolo, MessageBoxButton.OK, MessageBoxImage.Information);
}
private async void LearnPending_Click(object sender, RoutedEventArgs e)
{
CloseOptions();
var esito = await Dialogs.ProgressDialog.RunAsync(Host, "Studio delle aste non ancora apprese",
"Cerco le aste chiuse che il modello non ha ancora visto…",
(p, ct) => Task.Run(() => LearningService.LearnPendingAsync(p, ct), ct), modal: false);
Conclude(esito, "Apprendimento", r => $"Studiate {r.Done:N0} aste, {r.Usable:N0} utilizzabili per il modello.");
}
private async void RebuildProfile_Click(object sender, RoutedEventArgs e)
{
CloseOptions();
var esito = await Dialogs.ProgressDialog.RunAsync(Host, "Profilo per prodotto e fascia oraria",
"Rileggo lo storico…",
(p, ct) => Task.Run(() => LearningService.RebuildProfileFromHistory(p, ct), ct));
Conclude(esito, "Profilo", n => $"Profilo ricostruito da {n:N0} aste dello storico.");
}
private async void RebuildBandit_Click(object sender, RoutedEventArgs e)
{
CloseOptions();
var esito = await Dialogs.ProgressDialog.RunAsync(Host, "Bandit dalle decisioni registrate",
"Leggo le decisioni con esito…",
(p, ct) => LearningService.RebuildBanditFromDecisionsAsync(p, ct));
Conclude(esito, "Bandit", r => $"Bandit rifatto da {r.Auctions:N0} aste: {r.Observations:N0} osservazioni.");
}
private void ResetChallenger_Click(object sender, RoutedEventArgs e)
{
CloseOptions();
var answer = MessageBox.Show(Host!, "Azzero lo sfidante (la rete) e il confronto Brier? Il campione logistico resta.",
"Sfidante", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
LearningService.ResetChallenger();
Refresh();
}
private void ResetCalibration_Click(object sender, RoutedEventArgs e)
{
CloseOptions();
var answer = MessageBox.Show(Host!, "Azzero la calibrazione? Il fattore torna a 1 e si rimisura sugli esempi nuovi.",
"Calibrazione", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
LearningService.ResetCalibration();
Refresh();
}
private async void RetrainButton_Click(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(
CloseOptions();
var answer = MessageBox.Show(Host!,
"Butto modello, profilo, sfidante, bandit e contatore delle aste studiate, e ricomincio a studiare tutte le aste chiuse del database.\n\n" +
"Lo storico e i prodotti non vengono toccati. Ci vorranno un paio di minuti in sottofondo.\n\nProcedo?",
"Lo storico e i prodotti non vengono toccati. Puoi continuare a usare l'applicazione; la finestra mostra a che punto è.\n\nProcedo?",
"Ricomincia da capo", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
@@ -364,12 +431,14 @@ namespace AutoBidder.Controls
RetrainButton.IsEnabled = false;
try
{
await LearningService.RetrainFromScratchAsync(SettingsManager.Load());
var esito = await Dialogs.ProgressDialog.RunAsync(Host, "Ricomincio da capo",
"Azzero modello, profilo, sfidante e bandit…",
(p, ct) => Task.Run(() => LearningService.RetrainFromScratchAsync(p, ct), ct), modal: false);
Conclude(esito, "Ricomincia da capo", r => $"Ristudiate {r.Done:N0} aste, {r.Usable:N0} utilizzabili per il modello.");
}
finally
{
RetrainButton.IsEnabled = true;
Refresh();
}
}
}
+6 -2
View File
@@ -121,8 +121,7 @@
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Background="{DynamicResource Brush.Surface}" Padding="10,7"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<Border Grid.Row="0" Style="{StaticResource TabToolbar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@@ -201,6 +200,11 @@
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="6,4"/>
<Button x:Name="RecalculateButton" Style="{StaticResource IconButton}"
ToolTip="Ricalcola dallo storico.&#10;Rilegge tutte le aste concluse dello storico e rifà da capo i numeri di ogni prodotto in elenco: aste viste, prezzo minimo, massimo e medio, puntate del vincitore, vittorie, osservazioni. I limiti scritti a mano e la stellina non cambiano; cambiano i numeri, e quindi i limiti consigliati. Mostra l'avanzamento e si può annullare."
Click="RecalculateButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE81C;"/>
</Button>
<Button x:Name="ApplySuggestedButton" Style="{StaticResource IconButton}"
ToolTip="Usa i limiti consigliati sul prodotto selezionato.&#10;Scrive prezzo minimo e massimo consigliati dallo storico: il massimo copre l'85% delle chiusure passate (regolabile in Impostazioni), abbassato se il budget non lo regge — valore del prodotto meno il costo delle puntate massime. Il minimo è il decimo percentile: sotto quel prezzo l'asta non si chiude quasi mai."
Click="ApplySuggestedButton_Click">
+12
View File
@@ -138,6 +138,9 @@ namespace AutoBidder.Controls
private void ClearAllButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearAllClickedEvent, this));
private void RecalculateButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(RecalculateClickedEvent, this));
private void UnwatchAllButton_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(UnwatchAllClickedEvent, this));
@@ -170,6 +173,9 @@ namespace AutoBidder.Controls
public static readonly RoutedEvent ClearAllClickedEvent = EventManager.RegisterRoutedEvent(
"ClearAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent RecalculateClickedEvent = EventManager.RegisterRoutedEvent(
"RecalculateClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
public static readonly RoutedEvent UnwatchAllClickedEvent = EventManager.RegisterRoutedEvent(
"UnwatchAllClicked", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ProductsControl));
@@ -224,6 +230,12 @@ namespace AutoBidder.Controls
remove { RemoveHandler(ClearAllClickedEvent, value); }
}
public event RoutedEventHandler RecalculateClicked
{
add { AddHandler(RecalculateClickedEvent, value); }
remove { RemoveHandler(RecalculateClickedEvent, value); }
}
public event RoutedEventHandler UnwatchAllClicked
{
add { AddHandler(UnwatchAllClickedEvent, value); }
+41
View File
@@ -217,6 +217,47 @@ namespace AutoBidder
Log("[PRODOTTI] Elenco svuotato", LogLevel.Info);
}
/// <summary>
/// Rifà i numeri di ogni scheda prodotto dallo storico, con la finestra di
/// avanzamento. Vedi <see cref="ProductStatsStore.RebuildFromHistory"/>.
/// </summary>
private async void Products_RecalculateClicked(object sender, RoutedEventArgs e)
{
var aste = CompletedAuctionsStore.LoadAll().Count;
if (aste == 0)
{
MessageBox.Show(this, "Lo storico è vuoto: non c'è niente da cui ricalcolare.",
"Ricalcola dallo storico", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var answer = MessageBox.Show(this,
$"Rileggo tutto lo storico ({aste:N0} aste concluse) e ricalcolo per ogni prodotto: aste viste, " +
"prezzo minimo, massimo e medio, puntate del vincitore, vittorie e osservazioni.\n\n" +
"I limiti scritti a mano e la stellina non cambiano; cambiano i numeri, e quindi i limiti consigliati.\n\nProcedo?",
"Ricalcola dallo storico", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (answer != MessageBoxResult.Yes) return;
var esito = await Dialogs.ProgressDialog.RunAsync(this, "Ricalcolo dallo storico", "Leggo lo storico…",
(progress, ct) => System.Threading.Tasks.Task.Run(() => ProductStatsStore.RebuildFromHistory(progress, ct), ct));
if (esito.Error != null)
{
Log($"[PRODOTTI] Ricalcolo non riuscito: {esito.Error.Message}", LogLevel.Error);
return;
}
if (esito.Cancelled)
{
Log("[PRODOTTI] Ricalcolo annullato: le schede restano come prima", LogLevel.Warning);
return;
}
LoadProducts();
Log($"[PRODOTTI] Statistiche per prodotto ricalcolate dallo storico ({esito.Result:N0} aste)", LogLevel.Success);
}
private void Products_ClearAllClicked(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(this,
+36 -12
View File
@@ -273,20 +273,44 @@ namespace AutoBidder
var backfill = new Services.WinnerBidsBackfill(transport);
backfill.OnLog += m => Dispatcher.Invoke(() => Log(m, LogLevel.Warning));
var lastReported = 0;
backfill.OnProgress += (done, total, updated) =>
{
// Una riga di registro ogni cinquanta: una per asta seppellirebbe
// tutto il resto sotto migliaia di righe.
if (done - lastReported < 50 && done != total) return;
lastReported = done;
Dispatcher.Invoke(() =>
Log($"[RECUPERO] {done}/{total} aste esaminate, {updated} aggiornate", LogLevel.Info));
};
Log($"[RECUPERO] Avvio: {missing.Count} aste da completare", LogLevel.Info);
var result = await backfill.RunAsync(0, System.Threading.CancellationToken.None);
// La finestra di avanzamento non blocca l'applicazione: il recupero va
// piano di proposito e intanto si può fare altro.
var esito = await Dialogs.ProgressDialog.RunAsync(this, "Recupero delle puntate dei vincitori",
$"{missing.Count:N0} aste da completare, una richiesta alla volta in corsia di fondo.",
(progress, ct) =>
{
var lastReported = 0;
backfill.OnProgress += (done, total, updated) =>
{
progress.Report(new ProgressStep(done, total, $"{updated:N0} aggiornate"));
// Una riga di registro ogni cinquanta: una per asta seppellirebbe
// tutto il resto sotto migliaia di righe.
if (done - lastReported < 50 && done != total) return;
lastReported = done;
Dispatcher.Invoke(() =>
Log($"[RECUPERO] {done}/{total} aste esaminate, {updated} aggiornate", LogLevel.Info));
};
return backfill.RunAsync(0, ct);
}, modal: false);
if (esito.Error != null)
{
Log($"[RECUPERO] Non riuscito: {esito.Error.Message}", LogLevel.Error);
return;
}
if (esito.Cancelled)
{
LoadStatistics();
LoadProducts();
Log("[RECUPERO] Interrotto dall'utente: le aste già completate restano completate", LogLevel.Warning);
return;
}
var result = esito.Result!;
LoadStatistics();
LoadProducts();
+33
View File
@@ -0,0 +1,33 @@
<Window x:Class="AutoBidder.Dialogs.ProgressDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Operazione in corso"
Width="480" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False" ResizeMode="NoResize"
Background="{DynamicResource Brush.Bg}">
<StackPanel Margin="22,18">
<TextBlock x:Name="TitleText"
FontSize="{StaticResource Font.Size.Lg}" FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text}"/>
<TextBlock x:Name="MessageText" Style="{StaticResource Hint}" Margin="0,6,0,14"
TextWrapping="Wrap"/>
<ProgressBar x:Name="Bar" Height="6" Minimum="0" Maximum="100" IsIndeterminate="True"/>
<Grid Margin="0,10,0,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="CountText" Style="{StaticResource Hint}" Text=""/>
<TextBlock x:Name="ElapsedText" Style="{StaticResource Hint}" Margin="12,0,0,0" Text=""/>
</StackPanel>
<Button x:Name="CancelButton" Content="Annulla" HorizontalAlignment="Right"
Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Padding="18,5" Click="CancelButton_Click"/>
</Grid>
</StackPanel>
</Window>
+144
View File
@@ -0,0 +1,144 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using AutoBidder.Utilities;
namespace AutoBidder.Dialogs
{
/// <summary>
/// La finestra di avanzamento delle operazioni lunghe: titolo, una riga che dice a
/// che punto si è, la barra, il conto fatto/totale, il tempo passato e Annulla.
///
/// <para>Si usa da <see cref="RunAsync{T}"/>: si passa il lavoro da fare, che riceve
/// un <see cref="IProgress{T}"/> e un token di annullamento; la finestra si apre, segue
/// l'avanzamento e si chiude da sola. In modalità «modale» la finestra principale
/// resta disabilitata finché il lavoro non finisce; altrimenti si può continuare a
/// usare l'applicazione (le aste in corso non si fermano mai, in nessun caso).</para>
/// </summary>
public partial class ProgressDialog : Window
{
private readonly CancellationTokenSource _cts = new();
private readonly Stopwatch _elapsed = Stopwatch.StartNew();
private readonly DispatcherTimer _clock;
private bool _finished;
public CancellationToken Token => _cts.Token;
public ProgressDialog(string title, string message, bool cancellable)
{
InitializeComponent();
Title = title;
TitleText.Text = title;
MessageText.Text = message;
CancelButton.Visibility = cancellable ? Visibility.Visible : Visibility.Collapsed;
_clock = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_clock.Tick += (_, _) => ElapsedText.Text = _elapsed.Elapsed.TotalSeconds < 60
? $"{_elapsed.Elapsed.TotalSeconds:N0} s"
: $"{(int)_elapsed.Elapsed.TotalMinutes} min {_elapsed.Elapsed.Seconds:00} s";
_clock.Start();
}
/// <summary>Aggiorna barra e testi. Va chiamato sul thread dell'interfaccia.</summary>
public void Report(ProgressStep step)
{
if (_finished) return;
if (step.Total > 0)
{
Bar.IsIndeterminate = false;
Bar.Maximum = step.Total;
Bar.Value = Math.Clamp(step.Done, 0, step.Total);
CountText.Text = $"{step.Done:N0} / {step.Total:N0}";
}
else
{
Bar.IsIndeterminate = true;
CountText.Text = step.Done > 0 ? step.Done.ToString("N0") : "";
}
if (!string.IsNullOrEmpty(step.Message)) MessageText.Text = step.Message;
}
private void CancelButton_Click(object sender, RoutedEventArgs e) => RequestCancel();
private void RequestCancel()
{
if (_cts.IsCancellationRequested) return;
_cts.Cancel();
CancelButton.IsEnabled = false;
MessageText.Text = "Annullamento in corso: aspetto che il passo in corso finisca…";
}
/// <summary>
/// La X della finestra non chiude niente a metà: chiede l'annullamento, se
/// l'operazione lo ammette, e la finestra se ne va quando il lavoro è finito.
/// </summary>
protected override void OnClosing(CancelEventArgs e)
{
if (!_finished)
{
e.Cancel = true;
if (CancelButton.Visibility == Visibility.Visible) RequestCancel();
}
base.OnClosing(e);
}
private void Finish()
{
_finished = true;
_clock.Stop();
Close();
}
/// <summary>
/// Esegue <paramref name="work"/> mostrando la finestra di avanzamento sopra
/// <paramref name="owner"/>. Restituisce l'esito: risultato, annullata, o errore.
/// </summary>
/// <param name="modal">Vero: la finestra principale è disabilitata finché non si finisce.</param>
/// <param name="cancellable">Vero: c'è il pulsante Annulla e il token viene annullato.</param>
public static async Task<ProgressOutcome<T>> RunAsync<T>(
Window? owner, string title, string message,
Func<IProgress<ProgressStep>, CancellationToken, Task<T>> work,
bool modal = true, bool cancellable = true)
{
var dialog = new ProgressDialog(title, message, cancellable);
if (owner != null && owner.IsLoaded) dialog.Owner = owner;
// Progress<T> cattura il contesto di sincronizzazione di chi lo crea: da qui,
// il thread dell'interfaccia. I rapporti arrivano già sul thread giusto.
var progress = new Progress<ProgressStep>(dialog.Report);
if (modal && owner != null) owner.IsEnabled = false;
dialog.Show();
try
{
var result = await work(progress, dialog.Token);
return dialog.Token.IsCancellationRequested
? ProgressOutcome<T>.CancelledByUser()
: ProgressOutcome<T>.Ok(result);
}
catch (OperationCanceledException)
{
return ProgressOutcome<T>.CancelledByUser();
}
catch (Exception ex)
{
return ProgressOutcome<T>.Failed(ex);
}
finally
{
if (modal && owner != null) owner.IsEnabled = true;
dialog.Finish();
owner?.Activate();
}
}
}
}
+2 -2
View File
@@ -189,6 +189,7 @@
ClearAllClicked="Products_ClearAllClicked"
UnwatchAllClicked="Products_UnwatchAllClicked"
RemoveUnwatchedClicked="Products_RemoveUnwatchedClicked"
RecalculateClicked="Products_RecalculateClicked"
ApplySuggestedClicked="Products_ApplySuggestedClicked"
ApplyAllSuggestedClicked="Products_ApplyAllSuggestedClicked"
ProductLimitsEdited="Products_ProductLimitsEdited"
@@ -222,8 +223,7 @@
</Grid.RowDefinitions>
<!-- Barra strumenti -->
<Border Grid.Row="0" Background="{DynamicResource Brush.Surface}" Padding="10,7"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<Border Grid.Row="0" Style="{StaticResource TabToolbar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
+1 -5
View File
@@ -234,18 +234,14 @@ namespace AutoBidder
/// </summary>
private void EnsureProductCardsBuilt()
{
const string Marker = "products-generation";
const string Generation = "2";
try
{
var op = Data.OperationalDatabase.Instance;
if (ProductStatsStore.GetAll().Count > 0 && op.GetDocument(Marker) == Generation) return;
if (ProductStatsStore.GetAll().Count > 0 && op.GetDocument(ProductStatsStore.GenerationDocument) == ProductStatsStore.CurrentGeneration) return;
_ = System.Threading.Tasks.Task.Run(() =>
{
var count = ProductStatsStore.RebuildFromHistory();
op.SetDocument(Marker, Generation);
Dispatcher.BeginInvoke(() =>
Log($"[PRODOTTI] Schede prodotto ricostruite dallo storico ({count} aste)", LogLevel.Info));
+3 -1
View File
@@ -233,7 +233,9 @@ 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,
valutazione, autonomia sul momento (latenza e regime per asta), shadow (policy a confronto,
campione/sfidante, bandit), simulatore. Barra del **monitor**: l'interruttore
campione/sfidante, bandit), simulatore; dal menu delle operazioni: studiare le aste non
ancora apprese, ricostruire il profilo, rifare il bandit dalle decisioni, azzerare sfidante
o calibrazione, ricominciare da capo — tutte con la finestra di avanzamento. Barra del **monitor**: l'interruttore
dell'apprendimento. Scheda **Impostazioni Gestione del rischio**: paletti, HALT e
contabilità; **Impostazioni Database** e **Manutenzione**: cartella dei database, «Elimina
tutti i dati (tranne la login)», «Impostazioni di fabbrica». Scheda **Prodotti**: colonna
+136 -18
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Data;
using AutoBidder.Models;
@@ -220,16 +221,101 @@ namespace AutoBidder.Ml
/// studiarle dal database. Serve quando si cambiano le variabili o si sospetta che
/// il modello abbia imparato da dati sbagliati.
/// </summary>
public static Task RetrainFromScratchAsync(AppSettings settings)
public static Task<(int Done, int Usable)> RetrainFromScratchAsync(IProgress<ProgressStep>? progress = null, CancellationToken ct = default)
{
if (_bootstrapRunning) throw new InvalidOperationException("uno studio delle aste è già in corso: aspetta che finisca");
Forget();
OnLog?.Invoke("[APPRENDIMENTO] Ricomincio da capo: profilo dallo storico, poi tutte le aste chiuse.");
progress?.Report(new ProgressStep(0, 0, "profilo per prodotto e fascia oraria dallo storico…"));
BootstrapProfileFromHistory();
// Un riaddestramento voluto ha diritto a tutto il tempo che serve.
var generose = new AppSettings { LearningBootstrapSecondsPerStart = Math.Max(600, settings.LearningBootstrapSecondsPerStart) };
return Task.Run(() => BootstrapAsync(generose));
return StudyPendingAsync(null, progress, ct);
}
/// <summary>
/// Studia adesso tutte le aste chiuse non ancora apprese, senza limite di tempo,
/// riferendo l'avanzamento. È l'operazione «Studia le aste non ancora apprese»
/// della scheda Apprendimento: lo studio automatico all'avvio si ferma dopo
/// qualche minuto, questo va in fondo.
/// </summary>
public static Task<(int Done, int Usable)> LearnPendingAsync(IProgress<ProgressStep>? progress, CancellationToken ct)
=> StudyPendingAsync(null, progress, ct);
/// <summary>
/// Ricostruisce il profilo per prodotto e fascia oraria da tutto lo storico,
/// anche se ne esiste già uno. Il modello e il bandit non si toccano.
/// </summary>
public static int RebuildProfileFromHistory(IProgress<ProgressStep>? progress = null, CancellationToken ct = default)
{
var records = CompletedAuctionsStore.LoadAll();
var fresh = new AuctionProfileStats();
var done = 0;
foreach (var r in records)
{
ct.ThrowIfCancellationRequested();
fresh.Observe(r.ProductKey, r.EndedAt.ToLocalTime(),
AuctionIntegrity.TrustedWinnerBids(r), Ratio(r.FinalPrice, r.BuyNowPrice));
if (++done % 50 == 0 || done == records.Count) progress?.Report(new ProgressStep(done, records.Count));
}
lock (Sync) _profile = fresh;
Save();
OnLog?.Invoke($"[APPRENDIMENTO] Profilo delle aste ricostruito dallo storico: {records.Count:N0} aste");
return records.Count;
}
/// <summary>
/// Butta il bandit e lo rifà da tutte le decisioni registrate che hanno un esito:
/// stesse osservazioni che avrebbe raccolto asta per asta, senza doppioni.
/// </summary>
public static Task<(int Auctions, long Observations)> RebuildBanditFromDecisionsAsync(IProgress<ProgressStep>? progress, CancellationToken ct) => Task.Run(() =>
{
Db.Flush();
var ids = Db.Query("SELECT DISTINCT auction_id FROM bot_decisions WHERE context_key IS NOT NULL AND outcome IS NOT NULL ORDER BY auction_id")
.Select(r => r.Str("auction_id")).ToList();
var fresh = new ThompsonBandit();
var done = 0;
foreach (var id in ids)
{
ct.ThrowIfCancellationRequested();
LearnBanditFromAuction(fresh, id);
if (++done % 20 == 0 || done == ids.Count)
progress?.Report(new ProgressStep(done, ids.Count, $"{fresh.Observations:N0} osservazioni"));
}
ct.ThrowIfCancellationRequested();
lock (Sync) _bandit = fresh;
Save();
OnLog?.Invoke($"[APPRENDIMENTO] Bandit rifatto dalle decisioni registrate: {ids.Count:N0} aste, {fresh.Observations:N0} osservazioni");
return (ids.Count, fresh.Observations);
}, ct);
/// <summary>Azzera lo sfidante (la rete) e il confronto Brier: il campione resta.</summary>
public static void ResetChallenger()
{
lock (Sync)
{
_challenger = NewChallenger();
_brierLogit = double.NaN; _brierNet = double.NaN; _brierSamples = 0;
}
Save();
OnLog?.Invoke("[APPRENDIMENTO] Sfidante azzerato: la rete riparte da zero e il confronto Brier ricomincia");
}
/// <summary>Azzera la calibrazione del campione: il fattore torna a 1 finché non si rimisura.</summary>
public static void ResetCalibration()
{
lock (Sync) _model.ResetCalibration();
Save();
OnLog?.Invoke("[APPRENDIMENTO] Calibrazione azzerata: il fattore torna a 1 finché non si rimisura sugli esempi nuovi");
}
/// <summary>
@@ -413,17 +499,33 @@ namespace AutoBidder.Ml
}
/// <summary>
/// Studia le aste chiuse non ancora apprese, dalla più recente, finché c'è tempo.
/// All'avvio: studia le aste chiuse non ancora apprese, dalla più recente, finché
/// c'è tempo. Il resto aspetta il prossimo avvio, o l'operazione a mano.
/// </summary>
private static async Task BootstrapAsync(AppSettings settings)
{
if (_bootstrapRunning) return;
await Task.Delay(3000).ConfigureAwait(false); // prima l'avvio, poi lo studio
var budget = TimeSpan.FromSeconds(Math.Max(10, settings.LearningBootstrapSecondsPerStart));
try { await StudyPendingAsync(budget, null, CancellationToken.None).ConfigureAwait(false); }
catch { /* già riferito nel registro */ }
}
/// <summary>
/// Studia le aste chiuse non ancora apprese, dalla più recente. Con un budget di
/// tempo si ferma allo scadere; senza, va in fondo. Riferisce l'avanzamento e
/// rispetta l'annullamento: ciò che è già studiato resta studiato.
/// </summary>
private static async Task<(int Done, int Usable)> StudyPendingAsync(TimeSpan? budget, IProgress<ProgressStep>? progress, CancellationToken ct)
{
if (_bootstrapRunning) throw new InvalidOperationException("uno studio delle aste è già in corso: aspetta che finisca");
_bootstrapRunning = true;
var done = 0;
var usable = 0;
try
{
await Task.Delay(3000).ConfigureAwait(false); // prima l'avvio, poi lo studio
List<string> pending;
try
{
@@ -434,21 +536,26 @@ namespace AutoBidder.Ml
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Elenco delle aste da studiare non disponibile: {ex.Message}");
return;
throw;
}
if (pending.Count == 0) return;
if (pending.Count == 0)
{
progress?.Report(new ProgressStep(0, 0, "nessuna asta da studiare: l'archivio è già tutto appreso"));
return (0, 0);
}
var budget = TimeSpan.FromSeconds(Math.Max(10, settings.LearningBootstrapSecondsPerStart));
var sw = Stopwatch.StartNew();
var done = 0;
var usable = 0;
OnLog?.Invoke($"[APPRENDIMENTO] {pending.Count:N0} aste da studiare, al massimo {budget.TotalSeconds:N0} s per questo avvio");
OnLog?.Invoke(budget is { } b
? $"[APPRENDIMENTO] {pending.Count:N0} aste da studiare, al massimo {b.TotalSeconds:N0} s per questo avvio"
: $"[APPRENDIMENTO] {pending.Count:N0} aste da studiare");
progress?.Report(new ProgressStep(0, pending.Count, $"{pending.Count:N0} aste da studiare"));
foreach (var id in pending)
{
if (sw.Elapsed > budget) break;
if (budget is { } limit && sw.Elapsed > limit) break;
ct.ThrowIfCancellationRequested();
if (IngestAuction(id)) usable++;
done++;
@@ -459,6 +566,9 @@ namespace AutoBidder.Ml
OnLog?.Invoke($"[APPRENDIMENTO] {done:N0} aste studiate, {usable:N0} utilizzabili, {ModelUpdates:N0} aggiornamenti");
}
if (done % 10 == 0 || done == pending.Count)
progress?.Report(new ProgressStep(done, pending.Count, $"{usable:N0} utilizzabili, {ModelUpdates:N0} aggiornamenti"));
await Task.Yield(); // il motore delle aste ha la precedenza
}
@@ -466,11 +576,19 @@ namespace AutoBidder.Ml
var remaining = pending.Count - done;
OnLog?.Invoke($"[APPRENDIMENTO] Studiate {done:N0} aste ({usable:N0} utilizzabili) in {sw.Elapsed.TotalSeconds:N0} s; " +
(remaining > 0 ? $"ne restano {remaining:N0} per i prossimi avvii." : "archivio completo."));
(remaining > 0 ? $"ne restano {remaining:N0}." : "archivio completo."));
return (done, usable);
}
catch (OperationCanceledException)
{
Save();
OnLog?.Invoke($"[APPRENDIMENTO] Studio interrotto dall'utente dopo {done:N0} aste ({usable:N0} utilizzabili)");
throw;
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Studio delle aste interrotto: {ex.Message}");
throw;
}
finally
{
@@ -523,14 +641,14 @@ namespace AutoBidder.Ml
/// Il bandit impara dalle decisioni di quest'asta, ora che hanno un esito: per
/// ogni istante di decisione si sa se una puntata lì sarebbe stata l'ultima.
/// </summary>
private static int LearnBanditFromAuction(string auctionId)
private static int LearnBanditFromAuction(ThompsonBandit bandit, string auctionId)
{
var n = 0;
foreach (var r in Db.Query("SELECT context_key, outcome FROM bot_decisions WHERE auction_id = ?1 AND context_key IS NOT NULL AND outcome IS NOT NULL", auctionId))
{
var outcome = r.Str("outcome");
var unanswered = outcome is "won" or "would_win" or "missed";
_bandit.Observe(r.Str("context_key"), unanswered);
bandit.Observe(r.Str("context_key"), unanswered);
n++;
}
return n;
@@ -585,7 +703,7 @@ namespace AutoBidder.Ml
// Le righe dell'asta sono in coda: prima devono essere sul disco.
Db.Flush();
var learned = IngestAuction(record.AuctionId);
var decisioni = LearnBanditFromAuction(record.AuctionId);
var decisioni = LearnBanditFromAuction(_bandit, record.AuctionId);
Save();
+7
View File
@@ -78,6 +78,13 @@ namespace AutoBidder.Ml
}
}
/// <summary>Dimentica la misura recente: il fattore torna a 1 finché non si rimisura.</summary>
public void ResetCalibration()
{
_emaObserved = 0;
_emaPredicted = 0;
}
/// <summary>Probabilità riportata nella scala osservata di recente.</summary>
public double PredictCalibrated(ReadOnlySpan<double> x) =>
Math.Clamp(Predict(x) * CalibrationFactor, 0.0, 1.0);
+2
View File
@@ -0,0 +1,2 @@
Ottimo! Ora alcune cose:
-
+112
View File
@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using AutoBidder.Data;
using AutoBidder.Ml;
using AutoBidder.Models;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Le operazioni massive riferiscono l'avanzamento, si annullano senza lasciare le
/// cose a metà, e le manutenzioni dell'apprendimento fanno quello che dicono.
/// </summary>
[Collection("Database")]
public class MaintenanceProgressTests
{
/// <summary>Raccoglie i passi sul posto: Progress&lt;T&gt; li rimanderebbe a un altro thread.</summary>
private sealed class Raccolta : IProgress<ProgressStep>
{
public readonly List<ProgressStep> Passi = new();
public void Report(ProgressStep value) { lock (Passi) Passi.Add(value); }
}
private static CompletedAuctionRecord Asta(string key, double prezzo) => new()
{
AuctionId = "ric-" + Guid.NewGuid().ToString("N"),
Name = key + " di prova",
ProductKey = key,
FinalPrice = prezzo,
Winner = "qualcuno",
Outcome = "Persa",
BuyNowPrice = 11,
EndedAt = DateTime.Now.AddMinutes(-1)
};
[Fact]
public void Il_ricalcolo_dallo_storico_riferisce_lavanzamento_e_arriva_in_fondo()
{
var key = "ricalcolo-" + Guid.NewGuid().ToString("N")[..8];
CompletedAuctionsStore.AppendMany(new[] { Asta(key, 1.10), Asta(key, 2.30), Asta(key, 0.70) });
var raccolta = new Raccolta();
var n = ProductStatsStore.RebuildFromHistory(raccolta, CancellationToken.None);
Assert.True(n >= 3);
Assert.NotEmpty(raccolta.Passi);
var ultimo = raccolta.Passi[^1];
Assert.Equal(n, ultimo.Total);
Assert.Equal(n, ultimo.Done);
var scheda = ProductStatsStore.Find(key);
Assert.NotNull(scheda);
Assert.Equal(3, scheda!.AuctionsCompleted);
Assert.Equal(0.70, scheda.MinFinalPrice);
Assert.Equal(2.30, scheda.MaxFinalPrice);
}
[Fact]
public void Un_ricalcolo_annullato_lascia_le_schede_di_prima()
{
var key = "annulla-" + Guid.NewGuid().ToString("N")[..8];
CompletedAuctionsStore.Append(Asta(key, 1.00));
ProductStatsStore.RebuildFromHistory();
var prima = ProductStatsStore.GetAll().Count;
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.Throws<OperationCanceledException>(() => ProductStatsStore.RebuildFromHistory(null, cts.Token));
Assert.Equal(prima, ProductStatsStore.GetAll().Count);
}
[Fact]
public void Azzerare_la_calibrazione_riporta_il_fattore_a_uno()
{
var logit = new OnlineLogit(1) { CalibrationWindow = 100 };
var x = new[] { 1.0 };
// Prima il modello impara che succede una volta su dieci, poi il mondo cambia e
// succede nove volte su dieci: la calibrazione misura lo scarto e lo corregge.
for (var i = 0; i < 2200; i++) logit.Update(x, i % 10 == 0);
for (var i = 0; i < 300; i++) logit.Update(x, i % 10 != 0);
Assert.True(logit.CalibrationFactor > 1.05, $"fattore {logit.CalibrationFactor}");
logit.ResetCalibration();
Assert.Equal(1.0, logit.CalibrationFactor);
}
[Fact]
public async System.Threading.Tasks.Task Il_bandit_si_rifa_dalle_decisioni_con_esito()
{
var db = AuctionDatabase.Instance;
var id = "bandit-" + 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) VALUES(?1, 'b', 'Bandit', ?2, ?2, ?2, 'closed', 'Persa')", id, now);
foreach (var (esito, k) in new[] { ("would_win", 1), ("would_be_answered", 2), ("missed", 3) })
db.ExecuteNow("INSERT INTO bot_decisions(decision_id, auction_id, ts, action, mode, context_key, outcome) VALUES(?1, ?2, ?3, 'BID', 'shadow', 'chiave-di-prova', ?4)",
db.NextDecisionId(), id, now, esito);
var raccolta = new Raccolta();
var (aste, osservazioni) = await LearningService.RebuildBanditFromDecisionsAsync(raccolta, CancellationToken.None);
Assert.True(aste >= 1);
Assert.True(osservazioni >= 3);
Assert.True(LearningService.Bandit.Observations >= 3);
Assert.NotEmpty(raccolta.Passi);
}
}
+41
View File
@@ -698,6 +698,47 @@
</Setter>
</Style>
<!-- La barra in alto di ogni scheda: stessa altezza ovunque, così passando da una
scheda all'altra i pulsanti restano allo stesso posto. Il contenuto si centra
in verticale da sé: le colonne sono StackPanel con VerticalAlignment=Center. -->
<Style x:Key="TabToolbar" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource Brush.Surface}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
<Setter Property="Padding" Value="10,0"/>
<Setter Property="Height" Value="54"/>
</Style>
<!-- Voce di un menu a comparsa (Popup): un pulsante piatto, allineato a sinistra. -->
<Style x:Key="MenuEntry" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
<Setter Property="FontFamily" Value="{StaticResource Font.UI}"/>
<Setter Property="FontSize" Value="{StaticResource Font.Size.Base}"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="7"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Left" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<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.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Pulsante icona a due stati: stesso aspetto di IconButton, e quando è acceso
si riempie del colore d'accento. -->
<Style x:Key="IconToggle" TargetType="ToggleButton">
+43 -8
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Text.Encodings.Web;
using System.Text.Json;
using AutoBidder.Models;
@@ -156,6 +157,15 @@ namespace AutoBidder.Utilities
lock (Sync)
{
var data = Load();
RecordInto(data, summary, detail);
Save(data);
}
}
/// <summary>Aggiorna la scheda dentro <paramref name="data"/>, qualunque elenco sia: quello vivo o uno in costruzione.</summary>
private static void RecordInto(StoreData data, CompletedAuctionRecord summary, AuctionDetailRecord? detail)
{
{
var card = FindInternal(data, summary.ProductKey);
if (card == null)
@@ -207,7 +217,6 @@ namespace AutoBidder.Utilities
}
SyncOptions(card);
Save(data);
}
}
@@ -273,27 +282,53 @@ namespace AutoBidder.Utilities
/// concluse prima che questo archivio esistesse non lo hanno mai alimentato — e ogni
/// volta che si sospetta che i totali non tornino.
/// </summary>
public static int RebuildFromHistory()
/// <summary>Contrassegno di «generazione» delle schede nel database d'esercizio: cambia quando cambia il modo di contare.</summary>
public const string GenerationDocument = "products-generation";
public const string CurrentGeneration = "2";
/// <summary>
/// Rilegge tutto lo storico e rifà da capo i numeri di ogni scheda: aste viste,
/// prezzo minimo, massimo e medio, puntate del vincitore, vittorie, osservazioni.
/// Riferisce l'avanzamento e si può annullare: si costruisce da parte e si
/// sostituisce solo alla fine, così un annullamento a metà lascia le schede di
/// prima invece di un elenco dimezzato.
/// </summary>
public static int RebuildFromHistory(IProgress<ProgressStep>? progress = null, CancellationToken ct = default)
{
var summaries = CompletedAuctionsStore.LoadAll();
progress?.Report(new ProgressStep(0, summaries.Count, "leggo le schede dettagliate delle aste…"));
var details = AuctionDetailStore.LoadAll()
.GroupBy(d => d.AuctionId)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
lock (Sync)
{
_cache = new StoreData();
Save(_cache);
}
var fresh = new StoreData();
var done = 0;
foreach (var summary in summaries.OrderBy(s => s.EndedAt))
{
ct.ThrowIfCancellationRequested();
details.TryGetValue(summary.AuctionId, out var detail);
RecordCompleted(summary, detail);
if (!string.IsNullOrWhiteSpace(summary.ProductKey)) RecordInto(fresh, summary, detail);
done++;
if (done % 25 == 0 || done == summaries.Count)
progress?.Report(new ProgressStep(done, summaries.Count, $"{fresh.Products.Count:N0} prodotti"));
}
ct.ThrowIfCancellationRequested();
lock (Sync)
{
_cache = fresh;
Save(fresh);
}
SyncOptionsFromWatchList();
try { Data.OperationalDatabase.Instance.SetDocument(GenerationDocument, CurrentGeneration); } catch { }
return summaries.Count;
}
+28
View File
@@ -0,0 +1,28 @@
using System;
namespace AutoBidder.Utilities
{
/// <summary>
/// Un passo di avanzamento di un'operazione lunga: quanti elementi sono fatti su
/// quanti in tutto, e una riga di testo per dire a che punto si è. <c>Total</c> a
/// zero = non si sa ancora quanto ci vuole (barra indeterminata).
/// </summary>
public readonly record struct ProgressStep(int Done, int Total, string? Message = null);
/// <summary>
/// Com'è finita un'operazione seguita dalla finestra di avanzamento: con un
/// risultato, annullata dall'utente, oppure con un errore. Chi la lancia legge qui
/// invece di gestire eccezioni in un gestore di eventi.
/// </summary>
public sealed class ProgressOutcome<T>
{
public T? Result { get; private init; }
public bool Cancelled { get; private init; }
public Exception? Error { get; private init; }
public bool Success => !Cancelled && Error == null;
public static ProgressOutcome<T> Ok(T result) => new() { Result = result };
public static ProgressOutcome<T> CancelledByUser() => new() { Cancelled = true };
public static ProgressOutcome<T> Failed(Exception error) => new() { Error = error };
}
}