Files
Mimante/Mimante/Ml/ShadowReport.cs
T
Alby96andClaude Fable 5.1 7993ca44a0 Database SQLite al posto dei dossier, gestore del rischio, decisioni in shadow, rete neurale, bandit e simulatore
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>
2026-09-08 11:00:49 +02:00

166 lines
7.5 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using AutoBidder.Data;
using AutoBidder.Utilities;
namespace AutoBidder.Ml
{
/// <summary>
/// Cosa avrebbero reso le decisioni registrate, policy per policy, con l'esito noto.
///
/// <para>Ogni decisione ha un esito alla chiusura dell'asta: se il prezzo non è più
/// salito dopo quell'istante, una puntata lì sarebbe stata l'ultima. Con questo si
/// valuta ogni policy sugli stessi istanti, dal vivo e in shadow: quante volte
/// avrebbe puntato, quante di quelle sarebbero state vincenti, quanto avrebbe speso e
/// incassato al valore reale del bene. È la valutazione <i>off-policy</i> più onesta
/// che i dati permettono — con l'avvertenza che una puntata nostra può cambiare il
/// comportamento altrui, e questo nessun dato registrato lo dice.</para>
///
/// <para>Il confronto è sempre con il null-model: profitto atteso per puntata zero.
/// Un ROI per puntata positivo su poche decine di decisioni non prova nulla; su
/// migliaia, e con l'estremo inferiore dell'intervallo sopra lo zero, comincia a
/// dire qualcosa.</para>
/// </summary>
public static class ShadowReport
{
public sealed class PolicyStats
{
public string Name { get; init; } = "";
public int Decisions { get; set; }
public int Bids { get; set; }
public int Wins { get; set; }
public double Spent { get; set; }
public double Gained { get; set; }
public double ForecastEv { get; set; }
public readonly List<double> PerBid = new();
public double Net => Gained - Spent;
public double RoiPerBid => Bids > 0 ? Net / Bids : 0;
public double WinRate => Bids > 0 ? (double)Wins / Bids : 0;
/// <summary>Estremo inferiore (95%) del ROI per puntata: sotto zero, il null-model non è battuto.</summary>
public double RoiLower95
{
get
{
if (PerBid.Count < 2) return double.NaN;
var m = PerBid.Average();
var sd = Math.Sqrt(PerBid.Sum(x => (x - m) * (x - m)) / (PerBid.Count - 1));
return m - 1.96 * sd / Math.Sqrt(PerBid.Count);
}
}
}
public sealed class Report
{
public int Days { get; init; }
public int TotalDecisions { get; init; }
public int WithOutcome { get; init; }
public int LiveBids { get; init; }
public int LiveWins { get; init; }
public List<PolicyStats> Policies { get; } = new();
public string Text { get; set; } = "";
}
public static Report Compute(AuctionDatabase db, AppSettings settings, int days = 30)
{
db.Flush();
var since = AuctionDatabase.Iso(DateTime.Today.AddDays(-days));
var rows = db.Query(
"SELECT d.mode, d.action, d.outcome, d.ev_estimate, d.price, d.alt_action, d.executed, " +
" COALESCE(a.market_value, a.buy_now_price) AS v, a.shipping, a.bid_fee " +
"FROM bot_decisions d JOIN auctions a ON a.auction_id = d.auction_id " +
"WHERE d.ts >= ?1 AND d.outcome IS NOT NULL", since);
var total = (int)db.ScalarLong("SELECT COUNT(*) AS n FROM bot_decisions WHERE ts >= ?1", since);
var acting = new PolicyStats { Name = "modello + regime (in uso)" };
var alt = new PolicyStats { Name = "bandit Thompson (shadow)" };
var always = new PolicyStats { Name = "punta sempre" };
var never = new PolicyStats { Name = "non punta mai" };
int liveBids = 0, liveWins = 0;
foreach (var r in rows)
{
var outcome = r.Str("outcome");
var unanswered = outcome is "won" or "would_win" or "missed";
var v = r.DblOrNull("v") ?? 0;
var price = r.Dbl("price");
var fee = settings.TransactionFeeEuro;
var ship = r.DblOrNull("shipping") ?? 0;
var cost = r.DblOrNull("bid_fee") is > 0 ? r.Dbl("bid_fee") : settings.AverageBidCostEuro;
var gainIfWin = v - (price + Theory.PriceIncrement) - fee - ship;
void Apply(PolicyStats s, bool bid, double? forecast)
{
s.Decisions++;
if (!bid) return;
s.Bids++;
s.Spent += cost;
if (forecast is { } f) s.ForecastEv += f;
if (unanswered)
{
s.Wins++;
s.Gained += gainIfWin;
s.PerBid.Add(gainIfWin - cost);
}
else s.PerBid.Add(-cost);
}
var actingBid = r.Str("action") == "BID";
Apply(acting, actingBid, r.DblOrNull("ev_estimate"));
Apply(alt, r.Str("alt_action") == "BID", null);
Apply(always, true, null);
Apply(never, false, null);
if (r.Str("mode") == "live" && r.Bool("executed"))
{
liveBids++;
if (outcome == "won") liveWins++;
}
}
var report = new Report
{
Days = days, TotalDecisions = total, WithOutcome = rows.Count, LiveBids = liveBids, LiveWins = liveWins
};
report.Policies.AddRange(new[] { acting, alt, always, never });
report.Text = Format(report);
return report;
}
public static string Format(Report r)
{
var sb = new StringBuilder();
var c = System.Globalization.CultureInfo.GetCultureInfo("it-IT");
sb.AppendLine($"Decisioni negli ultimi {r.Days} giorni: {r.TotalDecisions:N0}, con esito noto {r.WithOutcome:N0}. " +
$"Puntate dal vivo: {r.LiveBids:N0}, di cui vincenti {r.LiveWins:N0}.");
sb.AppendLine();
sb.AppendLine(" policy decisioni puntate vincenti spesi € incassati € netto € ROI/puntata IC95% inf. EV previsto");
sb.AppendLine(" ------------------------------------------------------------------------------------------------------------------------");
foreach (var p in r.Policies)
{
sb.AppendLine(string.Format(c,
" {0,-30} {1,9} {2,8} {3,9} {4,9:F2} {5,12:F2} {6,9:F2} {7,12:F3} {8,10} {9,12}",
p.Name, p.Decisions, p.Bids, p.Wins, p.Spent, p.Gained, p.Net, p.RoiPerBid,
double.IsNaN(p.RoiLower95) ? "—" : p.RoiLower95.ToString("F3", c),
p.Name.Contains("in uso") ? p.ForecastEv.ToString("F2", c) : "—"));
}
sb.AppendLine();
sb.AppendLine(" Il null-model teorico (Augenblick 2016) prevede ROI per puntata = 0. Una policy batte il caso solo se");
sb.AppendLine(" l'estremo inferiore dell'intervallo di confidenza è sopra zero, e su abbastanza puntate da contare.");
sb.AppendLine(" «Vincenti» = il prezzo non è più salito dopo quell'istante: una puntata lì sarebbe stata l'ultima.");
sb.AppendLine(" Avvertenza: una puntata nostra può far rispondere chi stava per smettere; i dati non lo dicono.");
return sb.ToString();
}
}
}