Le aste non si salvano più in un file JSON per asta più tre archivi da tenere allineati: un solo database SQLite (winsqlite3.dll di Windows via P/Invoke, nessun pacchetto) con prodotti, aste, puntate, interrogazioni coalescenti, reset con profondità del ciclo, nostre puntate, decisioni del motore, misure di rete, puntatori, sessioni e contabilità. Scrittura in coda su un thread dedicato, letture in WAL. Per scelta dell'utente si parte da zero: il database si riempie man mano che si osservano le aste; i vecchi dossier restano leggibili per un'importazione futura ma non si scrivono più. Storico, schede, apprendimento, rigiocata ed esportazioni leggono tutti da lì. Il documento di progetto (Modifiche.txt) tradotto in C# senza librerie: - RiskManager: kill-switch (file KILL_SWITCH, anche dalla barra), HALT persistente per stop-loss e drawdown, tetto del giorno, aste in gioco insieme, contabilità in euro. - Theory: valore atteso con fee, spedizione, valore reale per prodotto (nuova colonna nella scheda Prodotti) e copertura «Compralo Ora»; null-model; sopravvivenza empirica e Kaplan-Meier dai reset; arrivi di Poisson. - Shadow per asta: in Osserva il cecchino arriva allo stesso istante, registra cosa avrebbe fatto e non punta; alla chiusura ogni decisione riceve l'esito, e ShadowReport confronta le policy sugli stessi istanti con intervallo di confidenza sul ROI. - NeuralNet (MLP, Adam) come sfidante del logistico, scelto dal Brier prequenziale; ThompsonBandit come seconda policy che impara dagli esiti delle decisioni; PennyAuctionEnv con avversari tarati sul database e QLearningAgent conservativo; SimulationLab per il confronto fra policy; esportazione CSV delle decisioni con reason_detail. Tutto nella scheda Apprendimento. Ml/LEGGIMI.md riscritto con le avvertenze su termini d'uso, quadro legale e realtà economica, lo schema del database e lo stato delle milestone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
272 lines
12 KiB
C#
272 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using AutoBidder.Models;
|
|
using AutoBidder.Utilities;
|
|
|
|
namespace AutoBidder
|
|
{
|
|
/// <summary>
|
|
/// Registrazione delle aste concluse.
|
|
///
|
|
/// Ne escono due archivi con scopi diversi: un riepilogo compatto che alimenta la
|
|
/// pagina Statistiche, e una scheda dettagliata per ogni asta destinata alle analisi
|
|
/// successive. Il secondo esiste perché quei dati, se non raccolti mentre l'asta è in
|
|
/// corso, non sono più recuperabili: Bidoo non li espone a posteriori.
|
|
/// </summary>
|
|
public partial class MainWindow
|
|
{
|
|
private void AuctionMonitor_OnAuctionCompleted(AuctionInfo auction, AuctionState state, bool won)
|
|
{
|
|
try
|
|
{
|
|
var settings = SettingsManager.Load();
|
|
|
|
var record = new CompletedAuctionRecord
|
|
{
|
|
AuctionId = auction.AuctionId,
|
|
Name = string.IsNullOrWhiteSpace(auction.Name) ? auction.AuctionId : auction.Name,
|
|
ProductKey = ProductKeyHelper.GenerateProductKey(auction.Name),
|
|
OriginalUrl = auction.OriginalUrl,
|
|
FinalPrice = state?.Price ?? 0,
|
|
Winner = state?.LastBidder ?? "",
|
|
WonByMe = won,
|
|
Outcome = won ? "Vinta"
|
|
: (state?.Status == AuctionStatus.EndedLost ? "Persa" : "Chiusa"),
|
|
MyBidsUsed = auction.BidsUsedOnThisAuction ?? 0,
|
|
TotalResets = auction.ResetCount,
|
|
BuyNowPrice = auction.BuyNowPrice,
|
|
AddedAt = auction.AddedAt,
|
|
|
|
// Le puntate spese dal vincitore arrivano dal server insieme allo
|
|
// stato finale: e' il numero che il sito mostra come "Puntate
|
|
// utilizzate", e l'unico affidabile (lo storico si ferma a cinquanta).
|
|
WinnerBidsPaid = state?.WinnerBidsPaid,
|
|
WinnerBidsFree = state?.WinnerBidsFree,
|
|
|
|
// L'ora della fine vera, non quella della registrazione. Un'asta
|
|
// terminata resta nel monitor finché non la si toglie: prendere
|
|
// DateTime.Now qui significherebbe datare la chiusura al momento in cui
|
|
// si è fatto pulizia, magari il giorno dopo.
|
|
EndedAt = ResolveEndedAt(auction, state)
|
|
};
|
|
|
|
CompletedAuctionsStore.Append(record);
|
|
|
|
// Un dato incoerente non va nelle medie: si dice e si scarta, invece di
|
|
// spostare in silenzio i limiti di prezzo del prodotto.
|
|
var integrity = AuctionIntegrity.CheckWinnerBids(record);
|
|
if (record.WinnerBidsUsed.HasValue && !integrity.IsTrustworthy)
|
|
{
|
|
Log($"[STATISTICHE] {record.Name}: puntate del vincitore scartate ({integrity.Reason})",
|
|
LogLevel.Warning);
|
|
}
|
|
else if (integrity.IsTrustworthy)
|
|
{
|
|
Log($"[STATISTICHE] {record.Name}: vinta con {record.WinnerBidsUsed} puntate " +
|
|
$"(prezzo {record.FinalPrice:F2} EUR)", LogLevel.Info);
|
|
}
|
|
|
|
// La scheda dettagliata vive solo nel riepilogo del dossier: l'archivio
|
|
// mensile che la duplicava non c'è più (vedi AuctionDetailStore).
|
|
var detail = BuildDetail(auction, state, record);
|
|
|
|
// La registrazione si chiude con il riepilogo: da lì in poi la riga è una
|
|
// storia completa, ed è così che l'analisi sa di poterla usare.
|
|
Data.AuctionRecorder.Close(auction, detail, state);
|
|
|
|
ProductStatsStore.RecordCompleted(record, detail);
|
|
|
|
// L'apprendimento impara subito da questa asta: il dossier è già su disco
|
|
// (Close lo svuota) e lo storico è già scritto. Fuori dal percorso di
|
|
// chiusura, che deve restare corto.
|
|
_ = System.Threading.Tasks.Task.Run(() => Ml.LearningService.OnAuctionClosed(record));
|
|
|
|
NotifyOutcome(record, settings);
|
|
|
|
Dispatcher.BeginInvoke(() =>
|
|
{
|
|
Log($"[STORICO] Asta '{record.Name}' salvata ({record.Outcome}, €{record.FinalPrice:F2})",
|
|
won ? LogLevel.Success : LogLevel.Info);
|
|
|
|
// Una vincita è il solo momento in cui il conto delle aste da confermare
|
|
// cambia: chiederlo subito evita che la barra resti indietro fino al
|
|
// giro successivo del timer.
|
|
if (won) _ = RefreshAuctionsToConfirmAsync();
|
|
|
|
if (StatisticsPanel != null && StatisticsPanel.Visibility == Visibility.Visible)
|
|
{
|
|
LoadStatistics();
|
|
}
|
|
|
|
// Ultimo passo, e non prima: lo storico è già scritto, il dossier
|
|
// chiuso, le statistiche del prodotto aggiornate. Togliere l'asta
|
|
// dall'elenco a questo punto non perde niente — tutto ciò che aveva
|
|
// da dire è già altrove.
|
|
RimuoviSeConclusaEDaTogliere(auction, won, settings);
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"[STORICO ERROR] {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Toglie dall'elenco l'asta appena conclusa, se la regola lo consente.
|
|
///
|
|
/// <para>Con l'aggiunta automatica accesa le aste concluse si accumulano a centinaia
|
|
/// e seppelliscono quelle vive, che è il contrario di quello che serve guardando un
|
|
/// cruscotto. Restano però quelle su cui si è speso: sono le uniche che vale la pena
|
|
/// riguardare, e i soldi erano veri. Vedi <see cref="FinishedAuctionCleanup"/>.</para>
|
|
///
|
|
/// <para>Il motivo finisce sempre nel registro, anche quando l'asta resta: senza,
|
|
/// un'asta che sparisce o che non sparisce sarebbero entrambe inspiegabili.</para>
|
|
/// </summary>
|
|
private void RimuoviSeConclusaEDaTogliere(AuctionInfo auction, bool won, AppSettings settings)
|
|
{
|
|
try
|
|
{
|
|
var verdict = FinishedAuctionCleanup.Decide(auction, won, new FinishedAuctionCleanup.Options
|
|
{
|
|
Enabled = settings.AutoRemoveFinished,
|
|
KeepWithMyBids = settings.AutoRemoveKeepWithMyBids,
|
|
KeepWon = settings.AutoRemoveKeepWon,
|
|
KeepUnclear = settings.AutoRemoveKeepUnclear
|
|
});
|
|
|
|
if (!verdict.Remove)
|
|
{
|
|
if (settings.AutoRemoveFinished)
|
|
Log($"[ELENCO] '{auction.Name}' resta in elenco: {verdict.Reason}", LogLevel.Info);
|
|
return;
|
|
}
|
|
|
|
// Stesso giro della rimozione a mano: prima il motore, poi la griglia,
|
|
// poi il salvataggio. Toglierla solo dalla griglia lascerebbe il runner
|
|
// a interrogare un'asta che non esiste piu' per chi guarda.
|
|
_auctionMonitor.RemoveAuction(auction.AuctionId);
|
|
|
|
var vm = _auctionViewModels.FirstOrDefault(x => x.AuctionId == auction.AuctionId);
|
|
if (vm != null) _auctionViewModels.Remove(vm);
|
|
|
|
SaveAuctions();
|
|
UpdateTotalCount();
|
|
UpdateGlobalControlButtons();
|
|
|
|
Log($"[ELENCO] '{auction.Name}' tolta dall'elenco: {verdict.Reason}", LogLevel.Info);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"[ELENCO ERROR] {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Quando l'asta è finita davvero, in ordine di attendibilità: l'istante fissato dal
|
|
/// monitor quando ha visto la conclusione dal vivo; la scadenza dichiarata dal
|
|
/// server nell'ultima risposta; l'istante di quella risposta. Solo se non c'è
|
|
/// nessuna delle tre si ripiega sull'ora corrente.
|
|
///
|
|
/// <para>La differenza non è cosmetica: lo storico si legge per capire a che ora del
|
|
/// giorno chiudono le aste che conviene seguire, e una data presa al momento della
|
|
/// rimozione rende quel numero privo di significato.</para>
|
|
/// </summary>
|
|
private static DateTime ResolveEndedAt(AuctionInfo auction, AuctionState? state)
|
|
{
|
|
if (auction.ConcludedAt.HasValue) return auction.ConcludedAt.Value;
|
|
|
|
if (state != null && state.ExpiryUnixSeconds > 0)
|
|
return DateTimeOffset.FromUnixTimeSeconds(state.ExpiryUnixSeconds).ToLocalTime().DateTime;
|
|
|
|
if (state != null && state.SnapshotTime != default)
|
|
return state.SnapshotTime.ToLocalTime();
|
|
|
|
return DateTime.Now;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Costruisce la scheda dettagliata a partire da ciò che il monitor ha osservato.
|
|
/// </summary>
|
|
private AuctionDetailRecord BuildDetail(AuctionInfo auction, AuctionState? state, CompletedAuctionRecord summary)
|
|
{
|
|
var bidders = auction.SnapshotBidderStats();
|
|
var bidsByUser = bidders.ToDictionary(b => b.Username, b => b.BidCount, StringComparer.OrdinalIgnoreCase);
|
|
|
|
var series = auction.SnapshotPriceSeries();
|
|
var firstSeen = auction.FirstSeenAt ?? auction.AddedAt;
|
|
var observedMinutes = Math.Max(0, (summary.EndedAt - firstSeen).TotalMinutes);
|
|
|
|
return new AuctionDetailRecord
|
|
{
|
|
AuctionId = summary.AuctionId,
|
|
Name = summary.Name,
|
|
ProductKey = summary.ProductKey,
|
|
Url = summary.OriginalUrl,
|
|
|
|
Outcome = summary.Outcome,
|
|
WonByMe = summary.WonByMe,
|
|
Winner = summary.Winner,
|
|
FinalPrice = summary.FinalPrice,
|
|
BuyNowPrice = auction.BuyNowPrice,
|
|
ShippingCost = auction.ShippingCost,
|
|
|
|
FirstSeenAt = firstSeen,
|
|
EndedAt = summary.EndedAt,
|
|
ObservedMinutes = observedMinutes,
|
|
|
|
MyBids = summary.MyBidsUsed,
|
|
TotalObservedBids = bidsByUser.Values.Sum(),
|
|
DistinctBidders = bidsByUser.Count,
|
|
Resets = auction.ResetCount,
|
|
BidsByUser = bidsByUser,
|
|
TopBidderShare = AuctionDetailRecord.ComputeTopShare(bidsByUser),
|
|
|
|
PriceSeries = series,
|
|
PriceVelocityPerMinute = observedMinutes > 0.01 ? summary.FinalPrice / observedMinutes : 0,
|
|
|
|
AveragePingMs = auction.AverageLatencyMs,
|
|
PollCount = auction.PollCount,
|
|
PollErrors = auction.PollErrors,
|
|
ConfiguredLeadMs = auction.BidBeforeDeadlineMs,
|
|
TimerExpiredCount = auction.TimerExpiredCount,
|
|
SuccessfulBids = auction.SuccessfulBidCount,
|
|
FailedBids = auction.FailedBidCount,
|
|
BidCostEuro = auction.BidCost,
|
|
|
|
ObservedFromStart = auction.ObservedFromStart,
|
|
ObservedToEnd = auction.ObservedToEnd
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Avvisa con una notifica di sistema. Una vittoria va confermata su Bidoo, e
|
|
/// accorgersene solo riaprendo la finestra è facile da dimenticare.
|
|
/// </summary>
|
|
private static void NotifyOutcome(CompletedAuctionRecord record, AppSettings settings)
|
|
{
|
|
try
|
|
{
|
|
if (record.WonByMe && settings.NotifyOnWin)
|
|
{
|
|
WindowsNotifier.Show(
|
|
"Asta vinta!",
|
|
$"{record.Name}\nAggiudicata a € {record.FinalPrice:F2} con {record.MyBidsUsed} puntate.\n" +
|
|
"Ricordati di completare l'acquisto su Bidoo.");
|
|
}
|
|
else if (!record.WonByMe && settings.NotifyOnLoss && record.MyBidsUsed > 0)
|
|
{
|
|
// Solo se ci avevamo davvero puntato: le aste solo osservate non
|
|
// meritano un avviso.
|
|
WindowsNotifier.Show(
|
|
"Asta persa",
|
|
$"{record.Name}\nChiusa a € {record.FinalPrice:F2} da {record.Winner}.",
|
|
warning: true);
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
}
|