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>
522 lines
27 KiB
C#
522 lines
27 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using AutoBidder.Models;
|
|
using AutoBidder.Utilities;
|
|
|
|
namespace AutoBidder.Data
|
|
{
|
|
/// <summary>
|
|
/// Registra nel database tutto ciò che succede a un'asta mentre la si segue: ogni
|
|
/// interrogazione, ogni puntata di ogni utente con l'ora al millisecondo, ogni reset
|
|
/// con la profondità del ciclo, ogni nostra puntata con anticipo voluto ed effettivo,
|
|
/// ogni riga di registro, e alla fine il riepilogo.
|
|
///
|
|
/// <para>È l'erede del dossier JSON per asta, con la stessa superficie: il motore
|
|
/// chiama gli stessi metodi di prima e non sa che dietro c'è SQLite. Cambia dove
|
|
/// finiscono i dati — una riga per tabella invece di una riga per file — e il fatto
|
|
/// che da lì si possano interrogare.</para>
|
|
///
|
|
/// <para>Perché tanto dettaglio: questi dati <b>non esistono a posteriori</b>. Bidoo
|
|
/// non espone lo storico dei prezzi né i tempi delle puntate di un'asta conclusa. O li
|
|
/// si raccoglie mentre l'asta va avanti, o sono persi — e sono esattamente quelli su
|
|
/// cui il modello impara.</para>
|
|
///
|
|
/// <para>Nessuna scrittura tocca il disco sul thread che segue l'asta: ogni riga va
|
|
/// nella coda del database e prosegue.</para>
|
|
/// </summary>
|
|
public sealed class AuctionRecorder
|
|
{
|
|
private static readonly ConcurrentDictionary<string, AuctionRecorder> Open = new(StringComparer.Ordinal);
|
|
|
|
private static readonly JsonSerializerOptions Json = new()
|
|
{
|
|
WriteIndented = false,
|
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
/// <summary>
|
|
/// Il nostro nome utente su Bidoo. Lo imposta il motore appena conosce la
|
|
/// sessione; finisce sull'asta, così chi rilegge sa quali puntate erano nostre.
|
|
/// </summary>
|
|
public static volatile string CurrentUsername = "";
|
|
|
|
private static AuctionDatabase Db => AuctionDatabase.Instance;
|
|
|
|
private readonly DateTime _startedAt;
|
|
private readonly List<int> _pings = new();
|
|
private readonly object _sync = new();
|
|
|
|
private long _polls;
|
|
private long _events;
|
|
private bool _closed;
|
|
|
|
// Coalescenza dei poll: si scrive quando cambia qualcosa, e comunque una volta
|
|
// al secondo. Misurato sui dossier: l'88,8% delle interrogazioni ha la stessa
|
|
// faccia della precedente.
|
|
private string? _lastPollSignature;
|
|
private DateTime _lastPollWrittenAt = DateTime.MinValue;
|
|
|
|
// Del ciclo in corso: fin dove è sceso il cronometro e quando è cominciato.
|
|
private double _cycleMinTimer = double.MaxValue;
|
|
private double _cycleStartedElapsed = -1;
|
|
private double _completedCycleMinTimer = double.NaN;
|
|
private double _completedCycleSeconds = double.NaN;
|
|
private double _lastPollPrice = double.NaN;
|
|
|
|
private const int MaxPingSamples = 50_000;
|
|
|
|
public string AuctionId { get; }
|
|
|
|
/// <summary>Righe scritte finora per quest'asta.</summary>
|
|
public long EventCount => _events;
|
|
|
|
private AuctionRecorder(string auctionId, DateTime startedAt)
|
|
{
|
|
AuctionId = auctionId;
|
|
_startedAt = startedAt;
|
|
}
|
|
|
|
// ── Apertura e chiusura ──────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Apre (o riapre) la registrazione di quest'asta. Riaprendo l'applicazione
|
|
/// mentre l'asta è ancora viva si <b>riprende la stessa riga</b>: un'asta seguita
|
|
/// a cavallo di un riavvio deve restare una storia sola.
|
|
/// </summary>
|
|
public static AuctionRecorder? OpenFor(AuctionInfo auction, AppSettings settings)
|
|
{
|
|
if (auction == null || !settings.RecordAuctions) return null;
|
|
if (string.IsNullOrEmpty(auction.AuctionId)) return null;
|
|
|
|
return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings));
|
|
}
|
|
|
|
/// <summary>La registrazione già aperta per quest'asta, se c'è.</summary>
|
|
public static AuctionRecorder? For(string auctionId) =>
|
|
auctionId != null && Open.TryGetValue(auctionId, out var r) ? r : null;
|
|
|
|
/// <summary>Fa confluire nel database tutte le righe di registro delle aste. Una volta all'avvio.</summary>
|
|
public static void CaptureAuctionLogs()
|
|
{
|
|
AuctionInfo.LogSink = (auction, entry) =>
|
|
For(auction.AuctionId)?.Log(
|
|
entry.Level.ToString().ToLowerInvariant(),
|
|
entry.Category.ToString(),
|
|
entry.Message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// L'asta è stata tolta dal monitor prima della fine: la riga resta, marcata
|
|
/// come interrotta, perché una serie di prezzi che si ferma a metà sembra
|
|
/// un'asta finita a quel prezzo.
|
|
/// </summary>
|
|
public static void Abandon(string auctionId, string reason)
|
|
{
|
|
if (auctionId == null) return;
|
|
if (!Open.TryRemove(auctionId, out var rec)) return;
|
|
|
|
rec.LogRow("info", "State", $"asta tolta dal monitor prima della conclusione: {reason}");
|
|
Db.Enqueue(
|
|
"UPDATE auctions SET status = CASE WHEN outcome IS NULL THEN 'abandoned' ELSE status END, " +
|
|
"abandon_reason = ?2, closed_at = COALESCE(closed_at, ?3), polls = ?4 WHERE auction_id = ?1",
|
|
auctionId, reason, AuctionDatabase.Now(), rec._polls);
|
|
}
|
|
|
|
/// <summary>Aspetta che tutto ciò che è in coda sia sul disco.</summary>
|
|
public static void FlushAll() => Db.Flush();
|
|
|
|
/// <summary>
|
|
/// Una decisione del motore, in Osserva (shadow) come in Attiva (live): cosa
|
|
/// avrebbe fatto o ha fatto, con che stima e per quale motivo. Restituisce l'id,
|
|
/// che la puntata porta con sé. L'esito si scrive alla chiusura dell'asta.
|
|
/// </summary>
|
|
public static long RecordDecision(
|
|
AuctionInfo auction, AuctionState state, string action, double? pWin, double? ev,
|
|
string? reason, string mode, string? regime, string? policy, double? leadMs,
|
|
string? contextKey = null, string? altAction = null, double? altP = null)
|
|
{
|
|
var id = Db.NextDecisionId();
|
|
var rec = For(auction.AuctionId);
|
|
|
|
var stateJson = JsonSerializer.Serialize(new
|
|
{
|
|
price = state.Price,
|
|
timer = Math.Round(state.Timer, 2),
|
|
lastBidder = string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder,
|
|
pingMs = state.PollingLatencyMs,
|
|
bidsUsed = auction.BidsUsedOnThisAuction,
|
|
resets = auction.ResetCount,
|
|
leadMs = leadMs ?? auction.BidBeforeDeadlineMs,
|
|
duel = auction.AutoBidDuelDetected,
|
|
autoResponses = auction.AutoResponsesInARow,
|
|
recentBidders = auction.SnapshotRecentBids(10).Select(b => b.Username).Distinct(StringComparer.OrdinalIgnoreCase).Count()
|
|
}, Json);
|
|
|
|
Db.Enqueue(
|
|
"INSERT INTO bot_decisions(decision_id, auction_id, ts, t, price, timer, action, p_win, ev_estimate, model_version, policy, regime, mode, reason_detail, state_json, context_key, alt_policy, alt_action, alt_p) " +
|
|
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
|
|
id, auction.AuctionId, AuctionDatabase.Now(), rec?.Elapsed(), state.Price, Math.Round(state.Timer, 2), action,
|
|
pWin, ev, Ml.LearningService.ModelVersion, policy, regime, mode, reason, stateJson,
|
|
contextKey, altAction == null ? null : "bandit", altAction, altP);
|
|
|
|
return id;
|
|
}
|
|
|
|
public static int OpenCount => Open.Count;
|
|
|
|
private static AuctionRecorder Create(AuctionInfo auction, AppSettings settings)
|
|
{
|
|
var rec = new AuctionRecorder(auction.AuctionId, DateTime.Now);
|
|
var now = AuctionDatabase.Now();
|
|
var productKey = ProductKeyHelper.GenerateProductKey(auction.Name);
|
|
|
|
var config = JsonSerializer.Serialize(new
|
|
{
|
|
state = auction.State.ToString(),
|
|
bidBeforeDeadlineMs = auction.BidBeforeDeadlineMs > 0 ? auction.BidBeforeDeadlineMs : settings.DefaultBidBeforeDeadlineMs,
|
|
minPrice = auction.MinPrice,
|
|
maxPrice = auction.MaxPrice,
|
|
maxBids = auction.MaxClicks,
|
|
minResets = auction.MinResets,
|
|
maxResets = auction.MaxResets,
|
|
bidLeadIsManual = auction.BidLeadIsManual,
|
|
adaptiveLead = settings.AdaptiveLeadEnabled,
|
|
leadMinMs = settings.LeadMinMs,
|
|
leadMaxMs = settings.LeadMaxMs,
|
|
valueCheckEnabled = settings.ValueCheckEnabled,
|
|
minSavingsPercentage = settings.MinSavingsPercentage,
|
|
rawPollsIncluded = settings.RecordPolls
|
|
}, Json);
|
|
|
|
var me = string.IsNullOrEmpty(CurrentUsername) ? null : CurrentUsername;
|
|
|
|
Db.EnqueueBatch(new (string, object?[])[]
|
|
{
|
|
("INSERT INTO items(product_key, title, retail_price_bidoo, shipping, first_seen_at, updated_at) " +
|
|
"VALUES(?1, ?2, ?3, ?4, ?5, ?5) " +
|
|
"ON CONFLICT(product_key) DO UPDATE SET " +
|
|
" title = CASE WHEN excluded.title != '' THEN excluded.title ELSE title END, " +
|
|
" retail_price_bidoo = COALESCE(excluded.retail_price_bidoo, retail_price_bidoo), " +
|
|
" shipping = COALESCE(excluded.shipping, shipping), updated_at = excluded.updated_at",
|
|
new object?[] { productKey, auction.Name ?? "", auction.BuyNowPrice, auction.ShippingCost, now }),
|
|
|
|
("INSERT INTO auctions(auction_id, item_id, product_key, name, url, status, me, app_version, " +
|
|
" added_at, first_seen_at, opened_at, bid_fee, shipping, buy_now_price, market_value, has_win_limit, win_limit, " +
|
|
" configured_lead_ms, lead_is_manual, config_json) " +
|
|
"VALUES(?1, (SELECT item_id FROM items WHERE product_key = ?2), ?2, ?3, ?4, 'open', ?5, ?6, ?7, ?8, ?8, ?9, ?10, ?11, " +
|
|
" (SELECT market_price_est FROM items WHERE product_key = ?2), ?12, ?13, ?14, ?15, ?16) " +
|
|
"ON CONFLICT(auction_id) DO UPDATE SET " +
|
|
" me = COALESCE(excluded.me, me), app_version = excluded.app_version, " +
|
|
" buy_now_price = COALESCE(excluded.buy_now_price, buy_now_price), " +
|
|
" shipping = COALESCE(excluded.shipping, shipping), " +
|
|
" configured_lead_ms = excluded.configured_lead_ms, lead_is_manual = excluded.lead_is_manual, " +
|
|
" config_json = excluded.config_json",
|
|
new object?[]
|
|
{
|
|
auction.AuctionId, productKey, auction.Name ?? "", auction.OriginalUrl ?? "", me, AppInfo.Version,
|
|
AuctionDatabase.Iso(auction.AddedAt), now, auction.BidCost, auction.ShippingCost, auction.BuyNowPrice,
|
|
auction.HasWinLimit, auction.WinLimitDescription,
|
|
auction.BidBeforeDeadlineMs > 0 ? auction.BidBeforeDeadlineMs : settings.DefaultBidBeforeDeadlineMs,
|
|
auction.BidLeadIsManual, config
|
|
})
|
|
});
|
|
|
|
rec.LogRow("info", "State", "registrazione aperta");
|
|
return rec;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Chiude la registrazione con il riepilogo. Da qui in avanti la riga non cambia
|
|
/// più: è ciò che dice a chi analizza che la storia è completa.
|
|
/// </summary>
|
|
public static void Close(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
|
|
{
|
|
if (auction == null) return;
|
|
if (!Open.TryRemove(auction.AuctionId, out var rec)) return;
|
|
|
|
rec.WriteSummary(auction, detail, finalState);
|
|
}
|
|
|
|
private void WriteSummary(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
if (_closed) return;
|
|
_closed = true;
|
|
}
|
|
|
|
var pings = PingStats();
|
|
var series = JsonSerializer.Serialize(detail.PriceSeries.Select(p => new { t = Math.Round(p.T, 2), price = p.Price }), Json);
|
|
var byUser = JsonSerializer.Serialize(detail.BidsByUser, Json);
|
|
|
|
Db.EnqueueBatch(new (string, object?[])[]
|
|
{
|
|
("UPDATE auctions SET status = 'closed', closed_at = ?2, end_time = ?3, outcome = ?4, won_by_me = ?5, winner_user = ?6, " +
|
|
" final_price = ?7, final_status = ?8, buy_now_price = COALESCE(?9, buy_now_price), shipping = COALESCE(?10, shipping), " +
|
|
" bid_fee = ?11, my_bids = ?12, resets = ?13, distinct_bidders = ?14, total_observed_bids = ?15, top_bidder_share = ?16, " +
|
|
" observed_from_start = ?17, observed_to_end = ?18, observed_minutes = ?19, first_seen_at = ?20, " +
|
|
" avg_ping_ms = ?21, p95_ping_ms = ?22, polls = ?23, poll_errors = ?24, configured_lead_ms = ?25, lead_is_manual = ?26, " +
|
|
" timer_expired = ?27, successful_bids = ?28, failed_bids = ?29, price_velocity_per_minute = ?30, " +
|
|
" price_series_json = ?31, bids_by_user_json = ?32 " +
|
|
"WHERE auction_id = ?1",
|
|
new object?[]
|
|
{
|
|
AuctionId, AuctionDatabase.Now(), AuctionDatabase.Iso(detail.EndedAt), detail.Outcome, detail.WonByMe, detail.Winner,
|
|
detail.FinalPrice, finalState?.Status.ToString(), detail.BuyNowPrice, detail.ShippingCost,
|
|
auction.BidCost, detail.MyBids, detail.Resets, detail.DistinctBidders, detail.TotalObservedBids, Math.Round(detail.TopBidderShare, 4),
|
|
auction.ObservedFromStart, auction.ObservedToEnd, Math.Round(detail.ObservedMinutes, 2), AuctionDatabase.Iso(detail.FirstSeenAt),
|
|
pings.Avg, pings.P95, _polls, detail.PollErrors, detail.ConfiguredLeadMs, auction.BidLeadIsManual,
|
|
auction.TimerExpiredCount, auction.SuccessfulBidCount, auction.FailedBidCount, Math.Round(detail.PriceVelocityPerMinute, 4),
|
|
series, byUser
|
|
}),
|
|
|
|
// Chi ha puntato in quest'asta: aggregato una volta, alla chiusura.
|
|
("INSERT INTO bidders(username, first_seen, last_seen, n_auctions, n_bids, n_auto, n_manual, win_count) " +
|
|
"SELECT username, MIN(local_ts), MAX(local_ts), 1, COUNT(*), " +
|
|
" SUM(CASE WHEN bid_type = 'Auto' THEN 1 ELSE 0 END), SUM(CASE WHEN bid_type = 'Manuale' THEN 1 ELSE 0 END), 0 " +
|
|
"FROM bids WHERE auction_id = ?1 GROUP BY username " +
|
|
"ON CONFLICT(username) DO UPDATE SET last_seen = excluded.last_seen, first_seen = COALESCE(first_seen, excluded.first_seen), " +
|
|
" n_auctions = n_auctions + 1, n_bids = n_bids + excluded.n_bids, n_auto = n_auto + excluded.n_auto, n_manual = n_manual + excluded.n_manual",
|
|
new object?[] { AuctionId }),
|
|
|
|
("UPDATE bidders SET win_count = win_count + 1 WHERE username = ?1 AND ?1 != ''",
|
|
new object?[] { detail.Winner ?? "" }),
|
|
|
|
("UPDATE items SET retail_price_bidoo = COALESCE(?2, retail_price_bidoo), shipping = COALESCE(?3, shipping), updated_at = ?4 " +
|
|
"WHERE product_key = ?1",
|
|
new object?[] { detail.ProductKey, detail.BuyNowPrice, detail.ShippingCost, AuctionDatabase.Now() }),
|
|
|
|
// L'esito di ogni decisione, ora che si sa come è finita. Per una puntata
|
|
// partita davvero il prezzo dopo di lei era price + 0,01; per una
|
|
// decisione shadow o un NO-OP il prezzo è quello del momento. Se il prezzo
|
|
// finale non l'ha superato, nessuno ha più puntato: quella sarebbe stata
|
|
// (o è stata) la puntata vincente.
|
|
("UPDATE bot_decisions SET outcome = CASE " +
|
|
" WHEN price + 0.01 >= ?2 - 0.005 THEN 'won' ELSE 'answered' END " +
|
|
"WHERE auction_id = ?1 AND executed = 1 AND action = 'BID' AND (outcome IS NULL OR outcome = 'sent')",
|
|
new object?[] { AuctionId, detail.FinalPrice }),
|
|
|
|
("UPDATE bot_decisions SET outcome = CASE " +
|
|
" WHEN price >= ?2 - 0.005 THEN (CASE WHEN action = 'BID' THEN 'would_win' ELSE 'missed' END) " +
|
|
" ELSE (CASE WHEN action = 'BID' THEN 'would_be_answered' ELSE 'right_skip' END) END " +
|
|
"WHERE auction_id = ?1 AND executed = 0 AND outcome IS NULL",
|
|
new object?[] { AuctionId, detail.FinalPrice })
|
|
});
|
|
|
|
_events++;
|
|
}
|
|
|
|
// ── Eventi ───────────────────────────────────────────────────────
|
|
|
|
/// <summary>Una singola interrogazione a Bidoo.</summary>
|
|
public void Poll(AuctionState state, bool includeRaw)
|
|
{
|
|
_polls++;
|
|
|
|
lock (_sync)
|
|
{
|
|
if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs);
|
|
|
|
var now = Elapsed();
|
|
if (!double.IsNaN(_lastPollPrice) && state.Price > _lastPollPrice)
|
|
{
|
|
_completedCycleMinTimer = _cycleMinTimer == double.MaxValue ? double.NaN : _cycleMinTimer;
|
|
_completedCycleSeconds = _cycleStartedElapsed >= 0 ? now - _cycleStartedElapsed : double.NaN;
|
|
_cycleMinTimer = double.MaxValue;
|
|
_cycleStartedElapsed = now;
|
|
}
|
|
else if (_cycleStartedElapsed < 0)
|
|
{
|
|
_cycleStartedElapsed = now;
|
|
}
|
|
_lastPollPrice = state.Price;
|
|
|
|
if (state.Timer > 0 && state.Timer < _cycleMinTimer) _cycleMinTimer = state.Timer;
|
|
}
|
|
|
|
NetworkSampler.Note(state);
|
|
|
|
if (!includeRaw) return;
|
|
|
|
var signature = string.Concat(
|
|
state.Price.ToString("F2"), '|', state.LastBidder, '|', state.Status, '|',
|
|
(int)state.Timer, '|', state.IsMyBid ? '1' : '0');
|
|
|
|
var wall = DateTime.Now;
|
|
if (signature == _lastPollSignature && (wall - _lastPollWrittenAt).TotalMilliseconds < 1000)
|
|
return;
|
|
|
|
_lastPollSignature = signature;
|
|
_lastPollWrittenAt = wall;
|
|
|
|
Write("INSERT INTO polls(auction_id, t, local_ts, price, timer, status, last_bidder, is_mine, ping_ms, expiry_unix, server_unix, clock_offset_ms) " +
|
|
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
|
AuctionId, Elapsed(), AuctionDatabase.Now(), state.Price, Math.Round(state.Timer, 2), state.Status.ToString(),
|
|
string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder, state.IsMyBid, state.PollingLatencyMs,
|
|
state.ExpiryUnixSeconds, state.ServerUnixSeconds, NetworkSampler.ClockOffsetMs(state));
|
|
}
|
|
|
|
/// <summary>Una puntata altrui (o nostra, vista dallo storico), come l'ha riportata Bidoo.</summary>
|
|
public void ForeignBid(BidHistoryEntry entry, double price)
|
|
{
|
|
double? timerBefore;
|
|
lock (_sync) timerBefore = _cycleMinTimer == double.MaxValue ? null : Math.Round(_cycleMinTimer, 2);
|
|
|
|
// Il tipo dichiarato dal server vince su quello ignoto di una riga già presente;
|
|
// una puntata ricostruita da un cambio di prezzo non sovrascrive mai un tipo vero.
|
|
Write("INSERT INTO bids(auction_id, username, price_after, bid_type, is_mine, server_ts, seen_t, local_ts, timer_before) " +
|
|
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " +
|
|
"ON CONFLICT(auction_id, price_after) DO UPDATE SET " +
|
|
" bid_type = CASE WHEN excluded.bid_type != '—' THEN excluded.bid_type ELSE bid_type END, " +
|
|
" server_ts = COALESCE(NULLIF(excluded.server_ts, 0), server_ts), " +
|
|
" is_mine = MAX(is_mine, excluded.is_mine), " +
|
|
" username = CASE WHEN excluded.username != '' THEN excluded.username ELSE username END",
|
|
AuctionId, entry.Username ?? "", price, entry.BidType ?? BidHistoryEntry.TipoSconosciuto, entry.IsMyBid,
|
|
entry.Timestamp > 0 ? entry.Timestamp : (object?)null, Elapsed(), AuctionDatabase.Now(), timerBefore);
|
|
}
|
|
|
|
/// <summary>Una mia puntata, con tutto ciò che serve a giudicarla.</summary>
|
|
public void MyBid(
|
|
double price,
|
|
int plannedLeadMs,
|
|
double actualLeadMs,
|
|
int pingMs,
|
|
bool success,
|
|
string? error,
|
|
int? bidsUsed,
|
|
int? remainingBids,
|
|
int rttMs = 0,
|
|
long? decisionId = null)
|
|
{
|
|
Write("INSERT INTO my_bids(auction_id, t, local_ts, price, planned_lead_ms, actual_lead_ms, ping_ms, rtt_ms, success, error, bids_used, remaining_bids, decision_id) " +
|
|
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
|
AuctionId, Elapsed(), AuctionDatabase.Now(), price, plannedLeadMs, Math.Round(actualLeadMs, 1), pingMs, rttMs,
|
|
success, error, bidsUsed, remainingBids, decisionId);
|
|
|
|
if (decisionId is { } id)
|
|
Db.Enqueue("UPDATE bot_decisions SET executed = 1, outcome = ?2 WHERE decision_id = ?1", id, success ? "sent" : "failed:" + (error ?? "?"));
|
|
}
|
|
|
|
/// <summary>Il timer è stato azzerato da una puntata: l'asta continua.</summary>
|
|
public void Reset(int resetCount, double price, string? bidder)
|
|
{
|
|
double? minTimer, cycleSeconds;
|
|
lock (_sync)
|
|
{
|
|
minTimer = double.IsNaN(_completedCycleMinTimer) ? null : Math.Round(_completedCycleMinTimer, 2);
|
|
cycleSeconds = double.IsNaN(_completedCycleSeconds) ? null : Math.Round(_completedCycleSeconds, 2);
|
|
_completedCycleMinTimer = double.NaN;
|
|
_completedCycleSeconds = double.NaN;
|
|
}
|
|
|
|
Write("INSERT INTO resets(auction_id, t, local_ts, reset_count, price, bidder, min_timer, cycle_seconds) " +
|
|
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
AuctionId, Elapsed(), AuctionDatabase.Now(), resetCount, price, bidder, minTimer, cycleSeconds);
|
|
}
|
|
|
|
/// <summary>Una riga del registro dell'asta.</summary>
|
|
public void Log(string level, string category, string message)
|
|
{
|
|
// Le righe "[RESET #n]" ripetono l'evento reset che sta già nella sua tabella.
|
|
if (message != null && message.StartsWith("[RESET #", StringComparison.Ordinal)) return;
|
|
LogRow(level, category, message ?? "");
|
|
}
|
|
|
|
/// <summary>Cambio di modo: Ferma, Osserva, Attiva.</summary>
|
|
public void StateChanged(string from, string to) => LogRow("info", "State", $"{from} → {to}");
|
|
|
|
private void LogRow(string level, string category, string message)
|
|
{
|
|
Write("INSERT INTO auction_log(auction_id, t, local_ts, level, category, msg) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
|
|
AuctionId, Elapsed(), AuctionDatabase.Now(), level, category, message);
|
|
}
|
|
|
|
// ── Interno ──────────────────────────────────────────────────────
|
|
|
|
private void Write(string sql, params object?[] args)
|
|
{
|
|
if (_closed) return;
|
|
Db.Enqueue(sql, args);
|
|
_events++;
|
|
}
|
|
|
|
internal double Elapsed() => Math.Round((DateTime.Now - _startedAt).TotalSeconds, 3);
|
|
|
|
private (int Count, double Avg, int P95) PingStats()
|
|
{
|
|
int[] copy;
|
|
lock (_sync) copy = _pings.Where(p => p > 0).ToArray();
|
|
if (copy.Length == 0) return (0, 0, 0);
|
|
Array.Sort(copy);
|
|
return (copy.Length, Math.Round(copy.Average(), 1), copy[Math.Min(copy.Length - 1, (int)(copy.Length * 0.95))]);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Misure di rete aggregate: una riga ogni dieci secondi con mediana, p95, jitter e
|
|
/// scarto dell'orologio, su tutte le interrogazioni di tutte le aste. Una riga per
|
|
/// poll sarebbero milioni di righe uguali; una ogni dieci secondi racconta la stessa
|
|
/// storia in un decimillesimo dello spazio.
|
|
/// </summary>
|
|
public static class NetworkSampler
|
|
{
|
|
private static readonly object Sync = new();
|
|
private static readonly List<int> _pings = new(512);
|
|
private static long _windowStartedTicks = Environment.TickCount64;
|
|
private static int _minOffset = int.MaxValue;
|
|
private const int WindowMs = 10_000;
|
|
|
|
/// <summary>
|
|
/// Scarto fra il nostro orologio e quello del server, in ms, per questa risposta.
|
|
/// Bidoo dichiara i secondi interi, quindi il valore è quantizzato: il minimo su
|
|
/// una finestra converge al confine reale del secondo.
|
|
/// </summary>
|
|
public static int ClockOffsetMs(AuctionState state)
|
|
{
|
|
if (state.ServerUnixSeconds <= 0) return 0;
|
|
var localMs = new DateTimeOffset(state.SnapshotTime.Kind == DateTimeKind.Utc ? state.SnapshotTime : state.SnapshotTime.ToUniversalTime())
|
|
.ToUnixTimeMilliseconds() - state.PollingLatencyMs / 2;
|
|
return (int)Math.Clamp(localMs - state.ServerUnixSeconds * 1000L, int.MinValue / 2, int.MaxValue / 2);
|
|
}
|
|
|
|
public static void Note(AuctionState state)
|
|
{
|
|
if (state.PollingLatencyMs <= 0) return;
|
|
|
|
List<int>? flush = null;
|
|
int offset = 0;
|
|
|
|
lock (Sync)
|
|
{
|
|
_pings.Add(state.PollingLatencyMs);
|
|
var o = ClockOffsetMs(state);
|
|
if (state.ServerUnixSeconds > 0 && o < _minOffset) _minOffset = o;
|
|
|
|
if (Environment.TickCount64 - _windowStartedTicks >= WindowMs)
|
|
{
|
|
flush = new List<int>(_pings);
|
|
offset = _minOffset == int.MaxValue ? 0 : _minOffset;
|
|
_pings.Clear();
|
|
_minOffset = int.MaxValue;
|
|
_windowStartedTicks = Environment.TickCount64;
|
|
}
|
|
}
|
|
|
|
if (flush == null || flush.Count == 0) return;
|
|
|
|
flush.Sort();
|
|
var p50 = flush[flush.Count / 2];
|
|
var p95 = flush[Math.Min(flush.Count - 1, (int)(flush.Count * 0.95))];
|
|
|
|
AuctionDatabase.Instance.Enqueue(
|
|
"INSERT INTO network_metrics(ts, rtt_ms, jitter_ms, p95_ms, clock_offset_ms, samples, source) VALUES(?1, ?2, ?3, ?4, ?5, ?6, 'poll')",
|
|
AuctionDatabase.Now(), p50, p95 - p50, p95, offset, flush.Count);
|
|
}
|
|
}
|
|
}
|