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>
377 lines
17 KiB
C#
377 lines
17 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Threading;
|
|
using AutoBidder.Ml;
|
|
using AutoBidder.Utilities;
|
|
|
|
namespace AutoBidder.Controls
|
|
{
|
|
/// <summary>
|
|
/// La scheda dell'apprendimento: cosa sa il modello, cosa ha deciso di recente, cosa
|
|
/// aspettarsi per prodotto e ora, e l'ultima valutazione.
|
|
///
|
|
/// <para>Legge direttamente da <see cref="LearningService"/>: non ha bisogno della
|
|
/// finestra principale per niente, e si aggiorna da sola ogni pochi secondi mentre è
|
|
/// visibile. Tutto il resto dell'applicazione non sa che esiste.</para>
|
|
/// </summary>
|
|
public partial class LearningControl : UserControl
|
|
{
|
|
private readonly DispatcherTimer _refresh;
|
|
private CancellationTokenSource? _evaluation;
|
|
|
|
private sealed class PesoRow
|
|
{
|
|
public string Nome { get; init; } = "";
|
|
public double Peso { get; init; }
|
|
public string PesoDisplay => Peso.ToString("+0.000;-0.000");
|
|
public string Effetto => Math.Abs(Peso) < 0.05 ? "quasi nullo"
|
|
: Peso > 0 ? "più senza risposta" : "più risposte";
|
|
}
|
|
|
|
private sealed class ProfiloRow
|
|
{
|
|
public string Prodotto { get; init; } = "";
|
|
public string Fascia { get; init; } = "";
|
|
public string Giorno { get; init; } = "";
|
|
public long Aste { get; init; }
|
|
public string Puntate { get; init; } = "";
|
|
public string Chiusura { get; init; } = "";
|
|
}
|
|
|
|
private sealed class RegimeRow
|
|
{
|
|
public string Asta { get; init; } = "";
|
|
public string StatoAsta { get; init; } = "";
|
|
public string Regime { get; init; } = "";
|
|
public string Pazienza { get; init; } = "";
|
|
public string Sondaggi { get; init; } = "";
|
|
public string Prob { get; init; } = "";
|
|
public string Ev { get; init; } = "";
|
|
public string Duello { get; init; } = "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Da dove prendere le aste seguite adesso, per la tabella dei regimi. Lo imposta
|
|
/// la finestra principale: la scheda non conosce il monitor, e non deve.
|
|
/// </summary>
|
|
public Func<IReadOnlyList<Models.AuctionInfo>>? AuctionsProvider { get; set; }
|
|
|
|
private sealed class DecisioneRow
|
|
{
|
|
public string Ora { get; init; } = "";
|
|
public string Asta { get; init; } = "";
|
|
public string Prezzo { get; init; } = "";
|
|
public string Prob { get; init; } = "";
|
|
public string Ev { get; init; } = "";
|
|
public string Esito { get; init; } = "";
|
|
}
|
|
|
|
public LearningControl()
|
|
{
|
|
InitializeComponent();
|
|
|
|
_refresh = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
|
|
_refresh.Tick += (_, _) => { if (IsVisible) Refresh(); };
|
|
|
|
IsVisibleChanged += (_, e) =>
|
|
{
|
|
if ((bool)e.NewValue) { Refresh(); _refresh.Start(); }
|
|
else _refresh.Stop();
|
|
};
|
|
}
|
|
|
|
/// <summary>Rilegge tutto dal servizio. Costa poco: sono numeri già in memoria.</summary>
|
|
public void Refresh()
|
|
{
|
|
try
|
|
{
|
|
var settings = SettingsManager.Load();
|
|
var apprese = LearningService.AuctionsLearned;
|
|
var soglia = Math.Max(1, settings.LearningMinAuctions);
|
|
var pronto = LearningService.IsReady(settings);
|
|
var calib = LearningService.CalibrationFactor;
|
|
|
|
PillAuctions.Text = $"{apprese:N0} aste";
|
|
PillUpdates.Text = $"{LearningService.ModelUpdates:N0} esempi";
|
|
PillCalibration.Text = $"calibrazione {calib:N2}";
|
|
// L'interruttore in barra: spento, il modello studia ma non decide.
|
|
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");
|
|
StatUpdates.Text = LearningService.ModelUpdates.ToString("N0");
|
|
StatProfile.Text = LearningService.ProfileAuctions.ToString("N0");
|
|
StatCalibration.Text = calib.ToString("N2");
|
|
StatThreshold.Text = soglia.ToString("N0");
|
|
StatBootstrap.Text = LearningService.BootstrapRunning ? "in corso" : "fermo";
|
|
|
|
RefreshAutonomy(settings);
|
|
RefreshShadow(settings);
|
|
|
|
var decisioni = LearningService.RecentDecisions();
|
|
var fermate = decisioni.Count(d => d.Blocked);
|
|
StatDecisions.Text = decisioni.Count == 0
|
|
? "Nessuna decisione ancora: il modello risponde quando il motore è pronto a puntare."
|
|
: $"Ultime {decisioni.Count} decisioni: {fermate} fermate, {decisioni.Count - fermate} lasciate passare" +
|
|
(settings.LearningGateEnabled ? "." : ". Il cancello è spento nelle impostazioni: il modello parla ma non decide.");
|
|
|
|
WeightsGrid.ItemsSource = LearningService.Pesi()
|
|
.Take(30)
|
|
.Select(p => new PesoRow { Nome = p.Nome, Peso = p.Peso })
|
|
.ToList();
|
|
|
|
DecisionsGrid.ItemsSource = decisioni
|
|
.Select(d => new DecisioneRow
|
|
{
|
|
Ora = d.At.ToString("HH:mm:ss"),
|
|
Asta = d.Auction,
|
|
Prezzo = d.Price.ToString("F2") + " €",
|
|
Prob = d.Probability.ToString("P2"),
|
|
Ev = d.ExpectedValue.ToString("+0.000;-0.000") + " €",
|
|
Esito = !d.Ready ? "parere" : d.Blocked ? "fermata" : "passa"
|
|
})
|
|
.ToList();
|
|
|
|
ProfileGrid.ItemsSource = LearningService.ProfileRows(150)
|
|
.Select(r => new ProfiloRow
|
|
{
|
|
Prodotto = r.Product,
|
|
Fascia = FasciaLabel(r.HourBand),
|
|
Giorno = r.Weekend ? "festivo" : "feriale",
|
|
Aste = r.Auctions,
|
|
Puntate = double.IsNaN(r.WinnerBids) ? "—" : r.WinnerBids.ToString("N0"),
|
|
Chiusura = double.IsNaN(r.CloseRatio) ? "—" : r.CloseRatio.ToString("P1")
|
|
})
|
|
.ToList();
|
|
|
|
if (_evaluation == null)
|
|
{
|
|
var ultima = LearningService.LastEvaluation();
|
|
if (!string.IsNullOrWhiteSpace(ultima)) EvaluationBox.Text = ultima;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatDecisions.Text = $"Aggiornamento non riuscito: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private static string FasciaLabel(int band) => band switch
|
|
{
|
|
0 => "0-8", 1 => "9", 2 => "10-12", 3 => "13-17", 4 => "18-20", _ => "21-23"
|
|
};
|
|
|
|
/// <summary>La sezione «Autonomia sul momento»: modello di latenza e regime per asta.</summary>
|
|
private void RefreshAutonomy(AppSettings settings)
|
|
{
|
|
var lat = LatencyModel.Snapshot();
|
|
var lead = LatencyModel.RecommendedLeadMs(settings);
|
|
|
|
StatLeadNow.Text = $"{lead} ms";
|
|
StatLatP99.Text = lat.Samples > 0 ? $"{lat.P99} ms" : "—";
|
|
StatLatP50.Text = lat.Samples > 0 ? $"{lat.P50} ms" : "—";
|
|
StatLatMargin.Text = $"{lat.MarginMs} ms";
|
|
StatLatSamples.Text = lat.Samples.ToString("N0");
|
|
StatLatLate.Text = $"{lat.LateBids} / {lat.Bids}";
|
|
|
|
StatLeadHint.Text = !settings.AdaptiveLeadEnabled
|
|
? $"Anticipo adattivo spento nelle impostazioni: vale l'anticipo fisso di {settings.DefaultBidBeforeDeadlineMs} ms."
|
|
: lat.Samples < 10
|
|
? $"Ancora pochi campioni: finché non ce ne sono dieci vale l'anticipo fisso di {settings.DefaultBidBeforeDeadlineMs} ms."
|
|
: $"Margine {lat.MarginMs} + coda {lat.P99} = {lat.MarginMs + lat.P99} ms, tenuto fra {settings.LeadMinMs} e {settings.LeadMaxMs}.";
|
|
|
|
var aste = AuctionsProvider?.Invoke() ?? Array.Empty<Models.AuctionInfo>();
|
|
var righe = aste
|
|
.Where(a => a.State != Models.RunState.Stopped)
|
|
.Select(a => new RegimeRow
|
|
{
|
|
Asta = a.Name,
|
|
StatoAsta = a.State == Models.RunState.Active ? "Attiva" : "Osserva",
|
|
Regime = a.Regime.StatoAttuale switch
|
|
{
|
|
CompetitionRegime.Stato.Sfogo => $"Sfogo ({a.Regime.CicliBuoni}/{a.Regime.Pazienza})",
|
|
CompetitionRegime.Stato.Sondaggio => "Sondaggio",
|
|
_ => "Calmo"
|
|
},
|
|
Pazienza = a.Regime.Pazienza.ToString(),
|
|
Sondaggi = a.Regime.SondaggiFalliti.ToString(),
|
|
Prob = a.LearnedUnansweredProbability is { } p ? p.ToString("P1") : "—",
|
|
Ev = a.LearnedExpectedValue is { } ev ? ev.ToString("+0.000;-0.000") + " €" : "—",
|
|
Duello = a.AutoBidDuelDetected ? "sì" : a.AutoResponsesInARow > 0 ? $"{a.AutoResponsesInARow}/5" : ""
|
|
})
|
|
.ToList();
|
|
|
|
RegimeGrid.ItemsSource = righe;
|
|
|
|
var sfogo = righe.Count(r => r.Regime.StartsWith("Sfogo"));
|
|
RegimeHint.Text = righe.Count == 0
|
|
? "Nessuna asta seguita in questo momento."
|
|
: $"{righe.Count} aste seguite: {sfogo} in Sfogo, {righe.Count(r => r.Regime == "Sondaggio")} in Sondaggio, {righe.Count - sfogo - righe.Count(r => r.Regime == "Sondaggio")} Calme.";
|
|
}
|
|
|
|
private DateTime _shadowRefreshedAt = DateTime.MinValue;
|
|
|
|
/// <summary>Campione, Brier, bandit e il rapporto shadow (ogni mezzo minuto: interroga il database).</summary>
|
|
private void RefreshShadow(AppSettings settings)
|
|
{
|
|
var (bl, bn, samples) = LearningService.BrierScores;
|
|
StatChampion.Text = LearningService.ChallengerLeads ? "rete neurale" : "logistico";
|
|
StatBrier.Text = samples == 0 ? "—" : $"{bl:F5} / {bn:F5}";
|
|
StatBandit.Text = $"{LearningService.Bandit.Arms:N0} / {LearningService.Bandit.Observations:N0}";
|
|
|
|
if ((DateTime.Now - _shadowRefreshedAt).TotalSeconds < 30) return;
|
|
_shadowRefreshedAt = DateTime.Now;
|
|
|
|
try
|
|
{
|
|
var report = ShadowReport.Compute(Data.AuctionDatabase.Instance, settings, 30);
|
|
ShadowBox.Text = report.WithOutcome == 0
|
|
? $"Decisioni registrate negli ultimi 30 giorni: {report.TotalDecisions:N0}, nessuna con esito ancora (arriva alla chiusura dell'asta)."
|
|
: report.Text;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShadowBox.Text = $"Rapporto non disponibile: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private void ExportDecisionsButton_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
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)
|
|
{
|
|
try { Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(result.Path)!, UseShellExecute = true }); } catch { }
|
|
MessageBox.Show($"{result.Message}\n{result.Path}", "Decisioni", MessageBoxButton.OK, MessageBoxImage.Information);
|
|
}
|
|
else
|
|
{
|
|
MessageBox.Show(result.Message, "Decisioni", MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
}
|
|
}
|
|
|
|
private CancellationTokenSource? _simulation;
|
|
|
|
private async void SimulateButton_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_simulation != null) { _simulation.Cancel(); return; }
|
|
|
|
_simulation = new CancellationTokenSource();
|
|
var ct = _simulation.Token;
|
|
SimulateButton.Content = "Annulla";
|
|
SimulationBox.Text = "Simulazione in corso…";
|
|
var settings = SettingsManager.Load();
|
|
|
|
try
|
|
{
|
|
var text = await Task.Run(() => SimulationLab.Run(Data.AuctionDatabase.Instance, settings, 500,
|
|
msg => Dispatcher.BeginInvoke(() => SimulateHint.Text = msg), ct), ct);
|
|
SimulationBox.Text = text;
|
|
SimulateHint.Text = $"fatto alle {DateTime.Now:HH:mm}";
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
SimulationBox.Text = "Simulazione annullata.";
|
|
SimulateHint.Text = "";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SimulationBox.Text = $"Simulazione non riuscita: {ex.Message}";
|
|
SimulateHint.Text = "";
|
|
}
|
|
finally
|
|
{
|
|
_simulation = null;
|
|
SimulateButton.Content = "Simula 500 aste per policy";
|
|
}
|
|
}
|
|
|
|
private void RefreshButton_Click(object sender, RoutedEventArgs e) => Refresh();
|
|
|
|
private async void EvaluateButton_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (_evaluation != null)
|
|
{
|
|
_evaluation.Cancel();
|
|
return;
|
|
}
|
|
|
|
_evaluation = new CancellationTokenSource();
|
|
// Il pulsante è solo icona: mentre gira diventa una X, e il tooltip lo dice.
|
|
EvaluateGlyph.Text = "\uE711";
|
|
EvaluateButton.ToolTip = "Annulla la valutazione in corso.";
|
|
EvaluationBox.Text = "Valutazione in corso: lettura delle aste…";
|
|
|
|
try
|
|
{
|
|
// 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.
|
|
var report = await LearningService.EvaluateAsync(3000,
|
|
msg => Dispatcher.BeginInvoke(() => EvaluationBox.Text = "Valutazione in corso: " + msg),
|
|
_evaluation.Token);
|
|
|
|
EvaluationBox.Text = report.Text;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
EvaluationBox.Text = "Valutazione annullata.";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
EvaluationBox.Text = $"Valutazione non riuscita: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
_evaluation = null;
|
|
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)
|
|
{
|
|
var answer = MessageBox.Show(
|
|
"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?",
|
|
"Ricomincia da capo", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
|
|
|
if (answer != MessageBoxResult.Yes) return;
|
|
|
|
RetrainButton.IsEnabled = false;
|
|
try
|
|
{
|
|
await LearningService.RetrainFromScratchAsync(SettingsManager.Load());
|
|
}
|
|
finally
|
|
{
|
|
RetrainButton.IsEnabled = true;
|
|
Refresh();
|
|
}
|
|
}
|
|
}
|
|
}
|