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 { /// /// 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. /// /// È 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. /// /// Perché tanto dettaglio: questi dati non esistono a posteriori. 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. /// /// Nessuna scrittura tocca il disco sul thread che segue l'asta: ogni riga va /// nella coda del database e prosegue. /// public sealed class AuctionRecorder { private static readonly ConcurrentDictionary Open = new(StringComparer.Ordinal); private static readonly JsonSerializerOptions Json = new() { WriteIndented = false, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; /// /// 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. /// public static volatile string CurrentUsername = ""; private static AuctionDatabase Db => AuctionDatabase.Instance; private readonly DateTime _startedAt; private readonly List _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; } /// Righe scritte finora per quest'asta. public long EventCount => _events; private AuctionRecorder(string auctionId, DateTime startedAt) { AuctionId = auctionId; _startedAt = startedAt; } // ── Apertura e chiusura ────────────────────────────────────────── /// /// Apre (o riapre) la registrazione di quest'asta. Riaprendo l'applicazione /// mentre l'asta è ancora viva si riprende la stessa riga: un'asta seguita /// a cavallo di un riavvio deve restare una storia sola. /// 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)); } /// La registrazione già aperta per quest'asta, se c'è. public static AuctionRecorder? For(string auctionId) => auctionId != null && Open.TryGetValue(auctionId, out var r) ? r : null; /// Fa confluire nel database tutte le righe di registro delle aste. Una volta all'avvio. public static void CaptureAuctionLogs() { AuctionInfo.LogSink = (auction, entry) => For(auction.AuctionId)?.Log( entry.Level.ToString().ToLowerInvariant(), entry.Category.ToString(), entry.Message); } /// /// 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. /// 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); } /// Aspetta che tutto ciò che è in coda sia sul disco. public static void FlushAll() => Db.Flush(); /// /// 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. /// 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; } /// /// 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. /// 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 ─────────────────────────────────────────────────────── /// Una singola interrogazione a Bidoo. 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)); } /// Una puntata altrui (o nostra, vista dallo storico), come l'ha riportata Bidoo. 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); } /// Una mia puntata, con tutto ciò che serve a giudicarla. 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 ?? "?")); } /// Il timer è stato azzerato da una puntata: l'asta continua. 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); } /// Una riga del registro dell'asta. 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 ?? ""); } /// Cambio di modo: Ferma, Osserva, Attiva. 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))]); } } /// /// 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. /// public static class NetworkSampler { private static readonly object Sync = new(); private static readonly List _pings = new(512); private static long _windowStartedTicks = Environment.TickCount64; private static int _minOffset = int.MaxValue; private const int WindowMs = 10_000; /// /// 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. /// 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? 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(_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); } } }