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>
This commit is contained in:
2026-09-08 14:47:47 +02:00
co-authored by Claude Fable 5.1
parent 9177b31bd5
commit 3985935958
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,
+26 -2
View File
@@ -273,9 +273,19 @@ namespace AutoBidder
var backfill = new Services.WinnerBidsBackfill(transport);
backfill.OnLog += m => Dispatcher.Invoke(() => Log(m, LogLevel.Warning));
Log($"[RECUPERO] Avvio: {missing.Count} aste da completare", LogLevel.Info);
// 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;
@@ -283,10 +293,24 @@ namespace AutoBidder
Dispatcher.Invoke(() =>
Log($"[RECUPERO] {done}/{total} aste esaminate, {updated} aggiornate", LogLevel.Info));
};
return backfill.RunAsync(0, ct);
}, modal: false);
Log($"[RECUPERO] Avvio: {missing.Count} aste da completare", LogLevel.Info);
if (esito.Error != null)
{
Log($"[RECUPERO] Non riuscito: {esito.Error.Message}", LogLevel.Error);
return;
}
var result = await backfill.RunAsync(0, System.Threading.CancellationToken.None);
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 };
}
}