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>
320 lines
14 KiB
C#
320 lines
14 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using AutoBidder.Ml;
|
||
using AutoBidder.Models;
|
||
using AutoBidder.Utilities;
|
||
|
||
namespace AutoBidder.Services
|
||
{
|
||
/// <summary>
|
||
/// Decide se puntare. Cinque passi, sempre nello stesso ordine — vedi
|
||
/// <c>Ml/LEGGIMI.md</c> per il disegno completo e i numeri che lo giustificano.
|
||
///
|
||
/// <list type="number">
|
||
/// <item><b>Rischio</b> — il gestore del rischio: kill-switch, HALT, stop-loss,
|
||
/// drawdown, tetto del giorno, aste in gioco insieme. Solo dal vivo: in shadow la
|
||
/// decisione si registra com'è, per poterla giudicare.</item>
|
||
/// <item><b>Paletti</b> — regole dell'utente che non si discutono: tetti di
|
||
/// puntate, fascia oraria sospesa.</item>
|
||
/// <item><b>Duello</b> — riconoscimento dell'autopuntata avversaria dal ritmo
|
||
/// delle risposte.</item>
|
||
/// <item><b>Valore atteso</b> — il modello appreso dice con che probabilità una
|
||
/// puntata fatta adesso resterebbe senza risposta; <see cref="Theory"/> la
|
||
/// trasforma in euro con valore reale del bene, fee, spedizione e copertura.</item>
|
||
/// <item><b>Regime</b> — una macchina a stati per asta che trasforma la sequenza
|
||
/// dei valori attesi in una condotta: quando lasciar sfogare gli altri, quando
|
||
/// sondare, quando tornare a giocare.</item>
|
||
/// </list>
|
||
///
|
||
/// <para><i>Quando</i> puntare — l'anticipo — non si decide qui ma nel cecchino,
|
||
/// con <see cref="LatencyModel"/>.</para>
|
||
/// </summary>
|
||
public class BidStrategyService
|
||
{
|
||
private int _sessionTotalBids = 0;
|
||
private DateTime _sessionStartedAt = DateTime.UtcNow;
|
||
private readonly Random _rng = new();
|
||
|
||
/// <summary>
|
||
/// Il valore reale V del bene di quest'asta: quello scritto dall'utente sul
|
||
/// prodotto, se c'è; altrimenti il «Compralo Ora». Vedi <see cref="Theory"/>: il
|
||
/// listino di Bidoo è gonfiato, e un valore atteso calcolato su di esso mente.
|
||
/// </summary>
|
||
public static double? ValueOf(AuctionInfo auction)
|
||
{
|
||
if (auction == null) return null;
|
||
var scheda = WatchedProductsStore.FindByName(auction.Name);
|
||
return scheda?.MarketValue is > 0 ? scheda.MarketValue : auction.BuyNowPrice;
|
||
}
|
||
|
||
public BidDecision ShouldPlaceBid(
|
||
AuctionInfo auction,
|
||
AuctionState state,
|
||
AppSettings settings,
|
||
string currentUsername,
|
||
IReadOnlyList<AuctionInfo>? allAuctions = null,
|
||
bool shadow = false)
|
||
{
|
||
var decision = new BidDecision { ShouldBid = true, Policy = "ev-regime" };
|
||
|
||
// ── 1. Rischio ─────────────────────────────────────────────────
|
||
if (!shadow)
|
||
{
|
||
var risk = RiskManager.Authorize(auction, settings, allAuctions);
|
||
if (!risk.Allowed)
|
||
{
|
||
decision.ShouldBid = false;
|
||
decision.Reason = risk.Reason;
|
||
decision.Policy = "rischio";
|
||
return decision;
|
||
}
|
||
}
|
||
|
||
// ── 2. Paletti ─────────────────────────────────────────────────
|
||
if (QuietHoursApply(auction, settings) &&
|
||
BiddingHours.IsQuiet(DateTime.Now, settings.QuietHoursStart, settings.QuietHoursEnd))
|
||
{
|
||
decision.ShouldBid = false;
|
||
decision.Reason = BiddingHours.Spiegazione(settings.QuietHoursStart, settings.QuietHoursEnd);
|
||
decision.Policy = "paletti";
|
||
return decision;
|
||
}
|
||
|
||
if (settings.BankrollManagerEnabled)
|
||
{
|
||
var bankrollCheck = CheckBankrollLimits(auction, settings);
|
||
if (!bankrollCheck.CanBid)
|
||
{
|
||
decision.ShouldBid = false;
|
||
decision.Reason = bankrollCheck.Reason;
|
||
decision.Policy = "paletti";
|
||
return decision;
|
||
}
|
||
}
|
||
|
||
// ── 3. Duello con l'autopuntata ────────────────────────────────
|
||
if (auction.AutoBidDuelDetected)
|
||
{
|
||
auction.Regime.AutopuntataRiconosciuta();
|
||
|
||
if (settings.AutoBidDuelWithdrawEnabled)
|
||
{
|
||
decision.ShouldBid = false;
|
||
decision.Reason = AutoBidDuel.Spiegazione(auction.AutoResponsesInARow);
|
||
decision.Policy = "duello";
|
||
decision.Regime = auction.Regime.StatoAttuale.ToString();
|
||
return decision;
|
||
}
|
||
}
|
||
|
||
// ── 4. Valore atteso ───────────────────────────────────────────
|
||
//
|
||
// Il modello parla sempre; decide solo quando ha appreso abbastanza aste. La
|
||
// probabilità e il valore atteso finiscono comunque sull'asta e sulla decisione.
|
||
double? ev = null;
|
||
string? spiegazioneEv = null;
|
||
var pronto = false;
|
||
var value = ValueOf(auction);
|
||
|
||
if (settings.LearningGateEnabled && value is > 0)
|
||
{
|
||
var ctx = LearningService.BuildContext(auction, state, DateTime.Now, currentUsername);
|
||
var prob = ctx is { } c0 ? LearningService.PredictUnanswered(c0) : (double?)null;
|
||
if (prob is { } p && ctx is { } c)
|
||
{
|
||
var costo = settings.AverageBidCostEuro * Math.Max(0.1, settings.LearningEvMultiplier);
|
||
var speso = (auction.BidsUsedOnThisAuction ?? 0) * auction.BidCost;
|
||
|
||
var situazione = new Theory.BidSituation(
|
||
PWin: p,
|
||
Value: value.Value,
|
||
CurrentPrice: state.Price,
|
||
BidCost: costo,
|
||
TransactionFee: settings.TransactionFeeEuro,
|
||
Shipping: auction.ShippingCost ?? 0,
|
||
ListPrice: auction.BuyNowPrice,
|
||
BidsSpentEuro: speso,
|
||
HedgeEnabled: settings.BuyNowHedgeEnabled);
|
||
|
||
var valore = Theory.BidExpectedValue(situazione);
|
||
|
||
auction.LearnedUnansweredProbability = p;
|
||
auction.LearnedExpectedValue = valore;
|
||
|
||
pronto = LearningService.IsReady(settings);
|
||
ev = valore;
|
||
decision.Probability = p;
|
||
decision.ExpectedValue = valore;
|
||
|
||
var margine = value.Value - state.Price - Theory.PriceIncrement - settings.TransactionFeeEuro - (auction.ShippingCost ?? 0);
|
||
spiegazioneEv =
|
||
$"valore atteso negativo: P(senza risposta) {p:P2} × margine {margine:F2} € " +
|
||
$"− puntata {costo:F2} €{(Theory.LossBranchCost(situazione) == 0 ? " (coperta)" : "")} = {valore:+0.000;-0.000} €";
|
||
|
||
LearningService.RecordDecision(auction.Name, state.Price, p, valore,
|
||
blocked: pronto && valore < 0, ready: pronto);
|
||
|
||
// La seconda policy, sempre in shadow: il bandit dice la sua sullo
|
||
// stesso istante, e alla chiusura entrambe vengono giudicate sugli esiti.
|
||
var key = ThompsonBandit.KeyOf(c);
|
||
var (altBid, altP) = LearningService.Bandit.Decide(key, situazione, _rng);
|
||
decision.ContextKey = key;
|
||
decision.AltAction = altBid ? "BID" : "NO-OP";
|
||
decision.AltP = altP;
|
||
}
|
||
}
|
||
|
||
// ── 5. Regime di concorrenza ───────────────────────────────────
|
||
if (pronto && ev is { } valoreAtteso)
|
||
{
|
||
var statoPrima = auction.Regime.StatoAttuale;
|
||
var consentito = auction.Regime.Osserva(valoreAtteso >= 0);
|
||
decision.Regime = auction.Regime.StatoAttuale.ToString();
|
||
|
||
if (auction.Regime.StatoAttuale != statoPrima)
|
||
{
|
||
auction.AddLog($"[REGIME] {statoPrima} → {auction.Regime.StatoAttuale}: {auction.Regime.Spiegazione()}",
|
||
AuctionLogLevel.Strategy, AuctionLogCategory.Strategy);
|
||
}
|
||
|
||
if (!consentito)
|
||
{
|
||
decision.ShouldBid = false;
|
||
decision.Reason = auction.Regime.StatoAttuale == CompetitionRegime.Stato.Calmo
|
||
? spiegazioneEv
|
||
: auction.Regime.Spiegazione();
|
||
return decision;
|
||
}
|
||
|
||
decision.Reason = $"valore atteso {valoreAtteso:+0.000;-0.000} € (P {ev:P2}), regime {auction.Regime.StatoAttuale}";
|
||
}
|
||
else if (ev is { } evNonPronto)
|
||
{
|
||
decision.Reason = $"modello non ancora pronto ({LearningService.AuctionsLearned}/{settings.LearningMinAuctions} aste): parere EV {evNonPronto:+0.000;-0.000} €, si punta";
|
||
}
|
||
else
|
||
{
|
||
decision.Reason = "nessuna stima disponibile (valore ignoto o nessuna puntata recente): si punta";
|
||
}
|
||
|
||
return decision;
|
||
}
|
||
|
||
private BankrollCheckResult CheckBankrollLimits(AuctionInfo auction, AppSettings settings)
|
||
{
|
||
var result = new BankrollCheckResult { CanBid = true };
|
||
|
||
var maxPerAuction = auction.MaxBidsOverride ?? settings.MaxBidsPerAuction;
|
||
if (maxPerAuction > 0 && auction.SessionBidCount >= maxPerAuction)
|
||
{
|
||
result.CanBid = false;
|
||
result.Reason = $"Limite puntate per asta raggiunto ({auction.SessionBidCount}/{maxPerAuction})";
|
||
return result;
|
||
}
|
||
|
||
if (settings.MaxBidsPerSession > 0 && _sessionTotalBids >= settings.MaxBidsPerSession)
|
||
{
|
||
result.CanBid = false;
|
||
result.Reason = $"Limite puntate per sessione raggiunto ({_sessionTotalBids}/{settings.MaxBidsPerSession})";
|
||
return result;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// La sospensione a fascia oraria vale per quest'asta? Tre livelli, dal più
|
||
/// specifico al più generale: l'asta scavalca il prodotto, il prodotto scavalca
|
||
/// le impostazioni generali.
|
||
/// </summary>
|
||
public static bool QuietHoursApply(AuctionInfo auction, AppSettings settings)
|
||
{
|
||
if (auction?.QuietHoursEnabledOverride is { } perAsta) return perAsta;
|
||
|
||
var scheda = WatchedProductsStore.FindByName(auction?.Name);
|
||
if (scheda?.QuietHoursEnabled is { } perProdotto) return perProdotto;
|
||
|
||
return settings.QuietHoursEnabled;
|
||
}
|
||
|
||
/// <summary>Registra una puntata inviata, riuscita o no.</summary>
|
||
public void RecordBidAttempt(AuctionInfo auction, bool success)
|
||
{
|
||
auction.SessionBidCount++;
|
||
_sessionTotalBids++;
|
||
|
||
if (success)
|
||
{
|
||
auction.SuccessfulBidCount++;
|
||
auction.LastMyBidAtUtc = DateTime.UtcNow;
|
||
auction.Regime.PuntataPiazzata();
|
||
}
|
||
else
|
||
{
|
||
auction.FailedBidCount++;
|
||
}
|
||
}
|
||
|
||
/// <summary>Un ciclo perso perche' la puntata e' arrivata a giochi chiusi.</summary>
|
||
public void RecordTimerExpired(AuctionInfo auction)
|
||
{
|
||
auction.TimerExpiredCount++;
|
||
}
|
||
|
||
public void ResetSession()
|
||
{
|
||
_sessionTotalBids = 0;
|
||
_sessionStartedAt = DateTime.UtcNow;
|
||
}
|
||
|
||
public SessionStats GetSessionStats()
|
||
{
|
||
return new SessionStats
|
||
{
|
||
TotalBids = _sessionTotalBids,
|
||
SessionDuration = DateTime.UtcNow - _sessionStartedAt
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>Il verdetto della strategia, con i numeri che l'hanno prodotto.</summary>
|
||
public class BidDecision
|
||
{
|
||
public bool ShouldBid { get; set; }
|
||
public string? Reason { get; set; }
|
||
|
||
/// <summary>P(senza risposta) stimata dal modello, se c'era.</summary>
|
||
public double? Probability { get; set; }
|
||
|
||
/// <summary>Valore atteso in euro della puntata, se c'era.</summary>
|
||
public double? ExpectedValue { get; set; }
|
||
|
||
/// <summary>Regime di concorrenza al momento della decisione.</summary>
|
||
public string? Regime { get; set; }
|
||
|
||
/// <summary>Quale parte ha deciso: rischio, paletti, duello, ev-regime.</summary>
|
||
public string Policy { get; set; } = "";
|
||
|
||
/// <summary>Chiave del contesto per il bandit, se il modello ha parlato.</summary>
|
||
public string? ContextKey { get; set; }
|
||
|
||
/// <summary>Cosa avrebbe fatto il bandit Thompson sullo stesso istante.</summary>
|
||
public string? AltAction { get; set; }
|
||
|
||
/// <summary>La probabilità estratta dal bandit.</summary>
|
||
public double? AltP { get; set; }
|
||
}
|
||
|
||
public class BankrollCheckResult
|
||
{
|
||
public bool CanBid { get; set; }
|
||
public string? Reason { get; set; }
|
||
}
|
||
|
||
public class SessionStats
|
||
{
|
||
public int TotalBids { get; set; }
|
||
public TimeSpan SessionDuration { get; set; }
|
||
}
|
||
}
|