Files
Mimante/Mimante/Services/AuctionMonitor.cs
T
Alby96andClaude Opus 5 9b94e2d405 Ritirati dai duelli con l'autopuntata, e punta a mezzo secondo
La strategia A, dai numeri dei dossier: 5410 aste, 2,08 milioni di puntate.

Bidoo dichiara per ogni puntata se e' automatica o manuale. Le automatiche sono
il 75,3% del totale ma vincono il 46,0% delle aste; le manuali sono il 24,7% e ne
vincono il 53,9%. Una puntata manuale vale 3,6 volte una automatica: 178 puntate
per vittoria contro 634.

Il meccanismo del duello e' stato ricostruito esattamente. L'intervallo mediano
fra due puntate consecutive e' 6,0 s su 2.799.681 intervalli: il timer si azzera
a ~8 s e l'autopuntata scatta a 2 s, quindi 8-2=6. Il ciclo da 13,0 s osservato
nelle prove reali era la somma di 7 s di attesa nostra e 6 s della sua risposta.
Puntare tardi non evita l'autopuntata avversaria: la sveglia, perche' si riarma
quando il suo padrone perde la testa della classifica, non a un orario fisso.

Contro una macchina cosi' non esiste attrito vincibile. Adesso il motore conta le
risposte con la firma dei 6+/-2,5 s e dopo cinque di fila smette di puntare su
quell'asta. Una risposta fuori firma azzera il conteggio invece di scalarlo:
l'errore costoso e' ritirarsi da un'asta che si poteva vincere, non spendere
cinque puntate di troppo.

Rigiocato sui dossier veri in cui il bot ha puntato: 366 puntate spese, 13 aste,
zero vittorie. Con la regola a soglia 3 sarebbero state 31 puntate, 335
risparmiate (91,5%), nessuna vittoria persa.

L'anticipo passa da 1000 a 500 ms. Le due ragioni per stare larghi sono cadute:
il p99,9 del ping a 444 ms era misurato col tetto di 40 richieste al secondo che
accodava le chiamate, e senza tetto il ping sta fra 47 e 78 ms; e la concorrenza
sotto i due secondi non e' fitta ma rada, visto che tre quarti degli avversari
sono autopuntate che hanno gia' sparato.

Il tipo di puntata non viene piu' inventato. In due punti il motore ricostruiva
una puntata da un cambio di prezzo fra due interrogazioni e la marcava "Auto"
comunque: adesso quelle portano un trattino, perche' il tipo non lo ha detto
nessuno. La colonna in cronologia mostra quindi solo tipi dichiarati dal server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:20:34 +02:00

1303 lines
56 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Engine;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio centrale per il monitoraggio delle aste.
///
/// Ogni asta avviata riceve il proprio <see cref="AuctionRunner"/>: un anello di polling
/// a cadenza adattiva e un anello "cecchino" che dorme fino all'istante di fuoco. Il
/// monitor non scandisce piu' nulla a ritmo fisso — si limita ad allineare l'insieme dei
/// motori attivi con lo stato scelto dall'utente e a fornire loro rete, strategie e log.
///
/// Il vantaggio rispetto a un ticker condiviso e' che una risposta lenta su un'asta non
/// ritarda di un millisecondo la puntata sulle altre.
/// </summary>
public class AuctionMonitor : IAuctionRunnerHost
{
private readonly BidooApiClient _apiClient;
private readonly BidStrategyService _bidStrategy;
private readonly List<AuctionInfo> _auctions = new();
/// <summary>Un motore per asta, indicizzato per AuctionId.</summary>
private readonly ConcurrentDictionary<string, AuctionRunner> _runners = new();
/// <summary>Stima dell'orologio del server, condivisa: piu' aste, stima piu' solida.</summary>
private readonly ServerClock _clock = new();
private SystemTimerResolution? _timerResolution;
private CancellationTokenSource? _monitoringCts;
private Task? _supervisorTask;
public event Action<AuctionState>? OnAuctionUpdated;
public event Action<AuctionInfo, BidResult>? OnBidExecuted;
public event Action<string>? OnLog;
public event Action<string>? OnResetCountChanged;
// Throttling per log di blocco (evita spam nel log globale)
private readonly Dictionary<string, DateTime> _lastBlockLogTime = new();
/// <summary>
/// Evento fired quando un'asta termina (vinta, persa o chiusa).
/// Parametri: AuctionInfo, AuctionState finale, bool won (true se vinta dall'utente)
/// </summary>
public event Action<AuctionInfo, AuctionState, bool>? OnAuctionCompleted;
public AuctionMonitor(BidStrategyService? bidStrategy = null)
{
_apiClient = new BidooApiClient();
_bidStrategy = bidStrategy ?? new BidStrategyService();
_apiClient.OnAuctionLog += (auctionId, message) =>
{
try
{
lock (_auctions)
{
var auction = _auctions.FirstOrDefault(a => a.AuctionId == auctionId);
if (auction != null)
{
auction.AddLog(message);
}
}
}
catch { }
};
}
public void InitializeSessionWithCookie(string cookieString, string username)
{
_apiClient.InitializeSessionWithCookie(cookieString, username);
OnLog?.Invoke($"[OK] Sessione configurata (cookie) per: {username}");
}
public async Task<bool> UpdateUserInfoAsync()
{
return await _apiClient.UpdateUserInfoAsync();
}
public BidooSession GetSession()
{
return _apiClient.GetSession();
}
public async Task<UserData?> GetUserDataAsync()
{
return await _apiClient.GetUserDataAsync();
}
public async Task<UserBannerInfo?> GetUserBannerInfoAsync()
{
return await _apiClient.GetUserBannerInfoAsync();
}
/// <summary>Aste vinte in attesa di conferma su Bidoo. <c>null</c> = dato non disponibile.</summary>
public Task<int?> GetAuctionsWonToConfirmAsync(CancellationToken ct = default)
{
return _apiClient.GetAuctionsWonToConfirmAsync(ct);
}
public async Task<UserData?> GetUserDataFromHtmlAsync()
{
return await _apiClient.GetUserDataFromHtmlAsync();
}
public void AddAuction(AuctionInfo auction)
{
lock (_auctions)
{
if (!_auctions.Any(a => a.AuctionId == auction.AuctionId))
{
_auctions.Add(auction);
// ? RIMOSSO: Log ridondante - viene già loggato da MainWindow con defaults e stato
// OnLog?.Invoke($"[+] Asta aggiunta: {auction.Name} (ID: {auction.AuctionId})");
}
}
}
/// <summary>
/// Toglie un'asta dal monitor.
///
/// L'ordine conta: prima si sfila dall'elenco e si spegne il suo motore, e solo
/// dopo si liberano i dati. Liberarli prima significherebbe azzerare liste che il
/// runner sta ancora leggendo dal proprio thread.
/// </summary>
public void RemoveAuction(string auctionId)
{
AuctionInfo? auction;
lock (_auctions)
{
auction = _auctions.FirstOrDefault(a => a.AuctionId == auctionId);
if (auction == null) return;
_auctions.Remove(auction);
}
// Solo un'asta davvero conclusa finisce nello storico. Registrare anche quelle
// semplicemente fermate falserebbe medie, percentuali di vittoria e prezzo
// massimo consigliato — cioè i numeri su cui poi si tarano i limiti.
var finalState = auction.LastState;
if (finalState != null && IsTerminalStatus(finalState.Status))
{
try
{
var won = finalState.Status == AuctionStatus.EndedWon || IsAuctionWonByUser(auction);
OnAuctionCompleted?.Invoke(auction, finalState, won);
}
catch (Exception ex)
{
OnLog?.Invoke($"[STATS ERROR] Salvataggio statistiche per {auction.Name}: {ex.Message}");
}
}
// Il dossier di un'asta ancora viva va chiuso dicendo che è incompleto: se
// l'asta era conclusa il riepilogo l'ha già scritto OnAuctionCompleted, e questa
// chiamata non trova più nulla da chiudere.
AuctionDossier.Abandon(auctionId, "rimossa dal monitor");
// Spegnimento e pulizia fuori dal lock e fuori dal thread dell'interfaccia:
// StopAsync attende la fine dei due anelli, che possono essere in mezzo a una
// richiesta di rete.
_ = RetireAuctionAsync(auctionId, auction);
}
private async Task RetireAuctionAsync(string auctionId, AuctionInfo auction)
{
if (_runners.TryRemove(auctionId, out var runner))
{
try { await runner.StopAsync().ConfigureAwait(false); } catch { }
}
try { auction.ClearData(); } catch { }
}
private static bool IsTerminalStatus(AuctionStatus status) =>
status is AuctionStatus.EndedWon or AuctionStatus.EndedLost or AuctionStatus.Closed;
/// <summary>
/// Determina se l'asta è stata vinta dall'utente corrente
/// </summary>
private bool IsAuctionWonByUser(AuctionInfo auction)
{
if (auction.LastState == null) return false;
var session = _apiClient.GetSession();
var username = session?.Username;
if (string.IsNullOrEmpty(username)) return false;
// Controlla se l'ultimo puntatore è l'utente
return auction.LastState.LastBidder?.Equals(username, StringComparison.OrdinalIgnoreCase) == true;
}
public IReadOnlyList<AuctionInfo> GetAuctions()
{
lock (_auctions)
{
return _auctions.ToList();
}
}
/// <summary>
/// Applica i limiti consigliati a un'asta specifica
/// </summary>
public bool ApplyLimitsToAuction(string auctionId, double minPrice, double maxPrice, int minResets, int maxResets, int maxBids)
{
lock (_auctions)
{
var auction = _auctions.FirstOrDefault(a => a.AuctionId == auctionId);
if (auction == null) return false;
auction.MinPrice = minPrice;
auction.MaxPrice = maxPrice;
OnLog?.Invoke($"[LIMITS] Aggiornati limiti per {auction.Name}: MinPrice={minPrice:F2}, MaxPrice={maxPrice:F2}");
return true;
}
}
/// <summary>
/// Applica i limiti consigliati a tutte le aste con lo stesso ProductKey
/// </summary>
public int ApplyLimitsToProductAuctions(string productKey, double minPrice, double maxPrice, int minResets, int maxResets, int maxBids)
{
int count = 0;
lock (_auctions)
{
foreach (var auction in _auctions)
{
var auctionProductKey = Utilities.ProductKeyHelper.GenerateProductKey(auction.Name);
if (auctionProductKey == productKey)
{
auction.MinPrice = minPrice;
auction.MaxPrice = maxPrice;
count++;
OnLog?.Invoke($"[LIMITS] Aggiornati limiti per {auction.Name}");
}
}
}
if (count > 0)
{
OnLog?.Invoke($"[LIMITS] Applicati limiti a {count} aste con productKey={productKey}");
}
return count;
}
public void Start()
{
if (_supervisorTask != null && !_supervisorTask.IsCompleted)
{
OnLog?.Invoke("[WARN] Monitoraggio gia' attivo");
return;
}
var settings = SettingsManager.Load();
// Granularita' del timer di sistema a 1 ms: senza, ogni attesa puo' sforare
// di ~15 ms e l'anticipo configurato perde di significato.
if (settings.PrecisionTimerEnabled && _timerResolution == null)
{
_timerResolution = new SystemTimerResolution();
if (!_timerResolution.IsActive)
OnLog?.Invoke("[WARN] Timer ad alta risoluzione non disponibile: precisione ridotta");
}
_apiClient.Transport.Configure(settings.MaxRequestsPerSecond);
_ = _apiClient.Transport.WarmUpAsync();
_monitoringCts = new CancellationTokenSource();
_supervisorTask = Task.Run(() => SupervisorLoop(_monitoringCts.Token));
OnLog?.Invoke($"[START] Monitoraggio avviato (poll {settings.PollIntervalCriticalMs}-{settings.PollIntervalFarMs}ms, max {settings.MaxRequestsPerSecond:F0} req/s)");
}
public void Stop()
{
_monitoringCts?.Cancel();
_supervisorTask?.Wait(TimeSpan.FromSeconds(2));
_monitoringCts = null;
_supervisorTask = null;
StopAllRunnersAsync().GetAwaiter().GetResult();
_timerResolution?.Dispose();
_timerResolution = null;
OnLog?.Invoke("[STOP] Monitoraggio fermato");
}
/// <summary>
/// Non punta e non interroga: tiene solo allineato l'insieme dei runner attivi con lo
/// stato che l'utente ha impostato sulle aste. Ogni asta avviata riceve il proprio
/// motore indipendente, ogni asta fermata lo perde.
/// </summary>
private async Task SupervisorLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
List<AuctionInfo> snapshot;
lock (_auctions) snapshot = _auctions.ToList();
foreach (var auction in snapshot)
{
var shouldRun = auction.State != RunState.Stopped && !IsAuctionTerminated(auction);
if (shouldRun)
{
if (!_runners.ContainsKey(auction.AuctionId))
{
var runner = new AuctionRunner(auction, this);
_runners[auction.AuctionId] = runner;
runner.Start();
auction.AddLog($"Motore avviato in stato {StateLabel(auction.State)}",
AuctionLogLevel.Info, AuctionLogCategory.Status);
}
}
else if (_runners.TryRemove(auction.AuctionId, out var stale))
{
await stale.StopAsync().ConfigureAwait(false);
}
}
// Aste rimosse dalla lista mentre il loro motore girava ancora.
var known = new HashSet<string>(snapshot.Select(a => a.AuctionId));
foreach (var id in _runners.Keys.Where(k => !known.Contains(k)).ToList())
{
if (_runners.TryRemove(id, out var orphan))
await orphan.StopAsync().ConfigureAwait(false);
}
await Task.Delay(250, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
OnLog?.Invoke($"[ERRORE] Supervisore: {ex.Message}");
try { await Task.Delay(500, token).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
}
private async Task StopAllRunnersAsync()
{
var runners = _runners.Values.ToList();
_runners.Clear();
foreach (var runner in runners)
{
try { await runner.StopAsync().ConfigureAwait(false); } catch { }
}
}
private static string StateLabel(RunState state) => state switch
{
RunState.Active => "Attiva",
RunState.Watch => "Osserva",
_ => "Ferma"
};
// ═══════════════════════════════════════════════════════════════════
// IAuctionRunnerHost — cio' che il motore chiede al monitor
// ═══════════════════════════════════════════════════════════════════
/// <summary>Impostazioni correnti (SettingsManager mantiene una cache di 2 s).</summary>
public AppSettings Settings => SettingsManager.Load();
/// <summary>Stima dell'orologio del server, condivisa da tutte le aste.</summary>
public ServerClock Clock => _clock;
/// <summary>Richieste inviate e fallite dall'avvio, per la diagnostica.</summary>
public long RequestsSent => _apiClient.Transport.RequestsSent;
public long RequestsFailed => _apiClient.Transport.RequestsFailed;
/// <summary>Numero di aste con un motore in funzione.</summary>
public int ActiveRunners => _runners.Count;
/// <summary>Applica a caldo i limiti di ritmo, senza ricreare le connessioni.</summary>
public void ApplyTransportSettings(AppSettings settings)
{
_apiClient.Transport.Configure(settings.MaxRequestsPerSecond);
}
public async Task<AuctionState?> PollAsync(AuctionInfo auction, CancellationToken ct)
{
return await _apiClient
.PollAuctionStateAsync(auction.AuctionId, auction.OriginalUrl, ct)
.ConfigureAwait(false);
}
public bool IsFinished(AuctionInfo auction) => !auction.IsActive || IsAuctionTerminated(auction);
/// <summary>
/// Incorpora una risposta di data.php: aggiorna l'orologio server, fissa la scadenza
/// assoluta del ciclo corrente e aggiorna storico, valore e interfaccia.
/// </summary>
public void ApplySnapshot(AuctionInfo auction, AuctionState state)
{
try
{
// 1. L'orologio del server si affina a ogni risposta.
_clock.AddSample(state.ServerUnixSeconds, state.ReceivedTicks, state.PollingLatencyMs);
// 2. Scadenza assoluta del ciclo. Con l'orologio agganciato si usa il
// timestamp del server; finche' non lo e', si ripiega sull'istante di
// ricezione corretto per meta' round-trip.
if (state.ExpiryUnixSeconds > 0)
{
auction.DeadlineTicks = _clock.IsSynced
? _clock.ToLocalTicks(state.ExpiryUnixSeconds)
: state.ReceivedTicks + PrecisionWait.MsToTicks(state.Timer * 1000 - state.PollingLatencyMs / 2.0);
}
auction.AddLatencyMeasurement(state.PollingLatencyMs);
// Serie prezzi: va raccolta ora, a posteriori Bidoo non la espone.
auction.TrackPrice(state.Price);
if (!auction.IsTrackedFromStart && auction.BidHistory.Count == 0)
{
auction.AddLog($"[START] Monitoraggio avviato - Timer: {state.Timer:F1}s, Prezzo: €{state.Price:F2}");
auction.IsTrackedFromStart = true;
auction.TrackingStartedAt = DateTime.UtcNow;
// Il monitor c'era prima della partenza? Lo dicono la prima risposta
// ricevuta — asta ancora programmata — o il prezzo al primo centesimo,
// che significa "nessuno ha ancora puntato". È l'unico momento in cui
// questa distinzione si può fare: dopo non è più ricostruibile.
auction.ObservedFromStart =
state.Status is AuctionStatus.Scheduled or AuctionStatus.Pending ||
state.Price <= 0.02;
}
// Il dossier registra tutto ciò che passa di qui: è materiale che esiste
// solo adesso.
var pollSettings = Settings;
AuctionDossier.OpenFor(auction, pollSettings)?.Poll(state, pollSettings.DossierIncludeRawPolls);
if (state.RecentBidsHistory != null && state.RecentBidsHistory.Count > 0)
{
MergeBidHistory(auction, state.RecentBidsHistory);
}
if (!string.IsNullOrEmpty(state.LastBidder) && state.Price > 0)
{
EnsureCurrentBidInHistory(auction, state);
}
// 3. Fine asta: servono due risposte terminali consecutive. Un singolo OFF
// isolato capita, e chiudere l'asta per errore la toglie dal monitor.
if (IsTerminalStatus(state.Status))
{
auction.LastState = state;
auction.TerminalStreak++;
if (auction.TerminalStreak >= 2)
{
HandleAuctionEnded(auction, state);
}
return;
}
auction.TerminalStreak = 0;
bool shouldNotify = auction.LastState == null ||
Math.Abs(auction.LastState.Price - state.Price) > 0.001 ||
auction.LastState.LastBidder != state.LastBidder ||
Math.Abs(auction.LastState.Timer - state.Timer) > 1;
auction.LastState = state;
if (shouldNotify)
{
OnAuctionUpdated?.Invoke(state);
}
UpdateAuctionHistory(auction, state);
UpdateProductValue(auction, state);
}
catch (Exception ex)
{
auction.AddLog($"[POLL ERROR] {ex.Message}");
}
}
/// <summary>
/// Verdetto sulle strategie. Il runner decide QUANDO puntare, questo metodo SE.
/// Viene chiamato una volta per ciclo, ~60 ms prima dell'istante di fuoco.
/// </summary>
public bool EvaluateBid(AuctionInfo auction, AuctionState state)
{
var settings = Settings;
if (state.IsMyBid) return false;
// L'asta dev'essere davvero aperta. Questo controllo è sempre attivo e non
// costa nulla: usa l'ultima risposta ricevuta, che nella finestra critica ha
// al massimo qualche centinaio di millisecondi. Una verifica dedicata via rete
// costerebbe invece un intero giro proprio nell'istante peggiore.
if (state.Status != AuctionStatus.Running && state.Status != AuctionStatus.Pending)
{
auction.AddLog($"⛔ Asta non aperta ({state.Status}): puntata annullata",
AuctionLogLevel.Warning, AuctionLogCategory.BidAttempt);
return false;
}
if (!ShouldBid(auction, state, settings))
{
return false;
}
var session = _apiClient.GetSession();
var decision = _bidStrategy.ShouldPlaceBid(auction, state, settings, session?.Username ?? "");
if (!decision.ShouldBid)
{
auction.AddLog($"⛔ Strategia blocca: {decision.Reason}",
AuctionLogLevel.Strategy, AuctionLogCategory.Strategy);
LogBlockThrottled(auction, "STRATEGY", $"[STRATEGY] {auction.Name}: {decision.Reason}");
return false;
}
auction.AddLog("✓ Tutti i controlli superati → PUNTATA!",
AuctionLogLevel.Bid, AuctionLogCategory.BidAttempt);
return true;
}
public async Task FireBidAsync(AuctionInfo auction, AuctionState state, int leadMs, double actualLeadMs, CancellationToken ct)
{
auction.AddLog($"[BID] Anticipo richiesto={leadMs}ms, effettivo={actualLeadMs:F1}ms");
var result = await ExecuteBid(auction, state, Settings, ct).ConfigureAwait(false);
// La coppia anticipo pianificato/effettivo è il dato per cui esiste il dossier:
// è l'unico modo, rileggendo a freddo, per capire se il valore impostato era
// quello giusto o se la puntata arrivava sistematicamente troppo presto o tardi.
AuctionDossier.For(auction.AuctionId)?.MyBid(
price: state.Price,
plannedLeadMs: leadMs,
actualLeadMs: actualLeadMs,
pingMs: state.PollingLatencyMs,
success: result?.Success ?? false,
error: result?.Error,
bidsUsed: auction.BidsUsedOnThisAuction,
remainingBids: auction.RemainingBids);
// L'anticipo effettivo dice se il valore impostato e' quello giusto: da solo
// nel log non serviva a nulla, sommato su molte puntate diventa una taratura.
if (Settings.BidLeadTrackingEnabled && result != null)
{
BidLeadStats.Record(new BidLeadStats.Sample
{
At = DateTime.Now,
AuctionId = auction.AuctionId,
ConfiguredMs = leadMs,
ActualMs = actualLeadMs,
PingMs = auction.AverageLatencyMs,
Success = result.Success,
TooLate = !result.Success && IsLateBidError(result.Error)
});
}
}
/// <summary>Il server ha rifiutato perche' il tempo era gia' scaduto.</summary>
private static bool IsLateBidError(string? error)
{
if (string.IsNullOrEmpty(error)) return false;
return error.Contains("timer", StringComparison.OrdinalIgnoreCase)
|| error.Contains("scadut", StringComparison.OrdinalIgnoreCase)
|| error.Contains("closed", StringComparison.OrdinalIgnoreCase)
|| error.Contains("ended", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Gestisce la fine dell'asta
/// </summary>
private void HandleAuctionEnded(AuctionInfo auction, AuctionState state)
{
string statusMsg = state.Status == AuctionStatus.EndedWon ? "VINTA" :
state.Status == AuctionStatus.EndedLost ? "Persa" : "Chiusa";
bool won = state.Status == AuctionStatus.EndedWon;
// L'API a volte omette l'ultima puntata: la si aggiunge se manca.
if (!string.IsNullOrEmpty(state.LastBidder) && state.Price > 0)
{
var statePrice = (decimal)state.Price;
lock (auction.BidsLock)
{
var bids = auction.RecentBids;
if (bids != null)
{
var alreadyExists = bids.Any(b =>
Math.Abs(b.Price - statePrice) < 0.001m &&
b.Username.Equals(state.LastBidder, StringComparison.OrdinalIgnoreCase));
if (!alreadyExists)
{
bids.Insert(0, new BidHistoryEntry
{
Username = state.LastBidder,
Price = statePrice,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
BidType = BidHistoryEntry.TipoSconosciuto
});
}
}
}
}
auction.IsActive = false;
auction.LastState = state;
// La conclusione è vista dal vivo: si fissa qui l'ora vera della fine, che è
// l'unica corretta. L'asta resterà nel monitor finché non la si toglie, e senza
// questo lo storico segnerebbe come chiusura il momento della rimozione.
auction.ObservedToEnd = true;
auction.ConcludedAt ??= state.ExpiryUnixSeconds > 0
? DateTimeOffset.FromUnixTimeSeconds(state.ExpiryUnixSeconds).ToLocalTime().DateTime
: DateTime.Now;
auction.AddLog($"[ASTA TERMINATA] {statusMsg}");
OnLog?.Invoke($"[FINE] [{auction.AuctionId}] Asta {statusMsg}");
// Feedback per aste perse
var settings = SettingsManager.Load();
if (!won && settings.ShowLateBidWarning)
{
// Se abbiamo provato a puntare ma fallito con errore timer
var lastBidAttempt = auction.BidHistory
.Where(b => b.EventType == BidEventType.MyBid && !b.Success)
.OrderByDescending(b => b.Timestamp)
.FirstOrDefault();
if (lastBidAttempt != null &&
(lastBidAttempt.Notes?.Contains("timer") == true ||
lastBidAttempt.Notes?.Contains("scaduto") == true))
{
int currentOffset = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
auction.AddLog($"[⚠️ SUGGERIMENTO] Puntata arrivata troppo tardi! " +
$"Tempo attuale: {currentOffset}ms. " +
$"Prova ad aumentarlo a {currentOffset + 500}ms o più.");
OnLog?.Invoke($"[LATE] {auction.Name}: aumenta il tempo di puntata (attuale: {currentOffset}ms)");
}
}
auction.BidHistory.Add(new BidHistory
{
Timestamp = DateTime.UtcNow,
EventType = BidEventType.Reset,
Bidder = state.LastBidder,
Price = state.Price,
Timer = 0,
Notes = $"Asta {statusMsg}"
});
OnAuctionUpdated?.Invoke(state);
try
{
OnAuctionCompleted?.Invoke(auction, state, won);
}
catch (Exception ex)
{
OnLog?.Invoke($"[STATS ERROR] {ex.Message}");
}
}
/// <summary>
/// Aggiorna il valore calcolato del prodotto
/// </summary>
private void UpdateProductValue(AuctionInfo auction, AuctionState state)
{
if (!auction.BuyNowPrice.HasValue && !auction.ShippingCost.HasValue) return;
try
{
var productValue = Utilities.ProductValueCalculator.Calculate(
auction,
state.Price,
auction.RecentBids?.Count ?? 0
);
auction.CalculatedValue = productValue;
}
catch { /* Silenzioso */ }
}
/// <summary>
/// Un'asta è conclusa secondo lo storico interno, che sopravvive al riavvio.
/// Stessa fonte usata dall'interfaccia per il badge di stato: due letture diverse
/// dello stesso fatto porterebbero a mostrare "Osserva" su un'asta ferma.
/// </summary>
private static bool IsAuctionTerminated(AuctionInfo auction) => auction.ConcludedOutcome != null;
/// <summary>
/// Logga un blocco nel log globale con throttling per evitare spam.
/// Ogni chiave (auctionId+reason) può loggare al massimo una volta ogni 10 secondi.
/// </summary>
private void LogBlockThrottled(AuctionInfo auction, string reason, string message)
{
var key = $"{auction.AuctionId}_{reason}";
var now = DateTime.UtcNow;
lock (_lastBlockLogTime)
{
if (_lastBlockLogTime.TryGetValue(key, out var lastTime))
{
if ((now - lastTime).TotalSeconds < 10)
return; // Throttle: già loggato di recente
}
_lastBlockLogTime[key] = now;
// Pulizia periodica entries vecchie (max 100)
if (_lastBlockLogTime.Count > 100)
{
var oldKeys = _lastBlockLogTime
.Where(kv => (now - kv.Value).TotalMinutes > 5)
.Select(kv => kv.Key)
.ToList();
foreach (var k in oldKeys)
_lastBlockLogTime.Remove(k);
}
}
OnLog?.Invoke(message);
}
/// <summary>
/// Esegue la puntata e registra metriche
/// </summary>
private async Task<BidResult?> ExecuteBid(AuctionInfo auction, AuctionState state, AppSettings settings, CancellationToken token)
{
try
{
// Esegui la puntata immediatamente (corsia critica: nessuna coda, nessun limite)
var result = await _apiClient.PlaceBidAsync(auction.AuctionId, auction.OriginalUrl, token);
auction.LastClickAt = DateTime.UtcNow;
// Registra metriche
bool isCollision = result.Error?.Contains("timer") == true || result.Error?.Contains("scaduto") == true;
_bidStrategy.RecordBidAttempt(auction, result.Success, collision: isCollision);
if (!result.Success && isCollision)
{
_bidStrategy.RecordTimerExpired(auction);
}
// ?? FIX: Aggiorna contatore puntate
if (result.Success)
{
if (result.RemainingBids.HasValue)
{
auction.RemainingBids = result.RemainingBids.Value;
}
// Usa valore server se disponibile, altrimenti incrementa localmente
if (result.BidsUsedOnThisAuction.HasValue)
{
auction.BidsUsedOnThisAuction = result.BidsUsedOnThisAuction.Value;
}
else
{
// Incrementa contatore locale
auction.BidsUsedOnThisAuction = (auction.BidsUsedOnThisAuction ?? 0) + 1;
}
}
OnBidExecuted?.Invoke(auction, result);
if (result.Success)
{
// Log dettagliato con info ping per analisi timing
var pollingPing = auction.PollingLatencyMs;
auction.AddLog($"[BID OK] Latenza puntata: {result.LatencyMs}ms | Ping polling: {pollingPing}ms | Totale stimato: {result.LatencyMs + pollingPing}ms");
OnLog?.Invoke($"[OK] Puntata riuscita su {auction.Name} ({auction.AuctionId}): {result.LatencyMs}ms");
}
else
{
var pollingPing = auction.PollingLatencyMs;
// Rileva errore "timer scaduto" per feedback utente
bool isLateBid = result.Error?.Contains("timer") == true ||
result.Error?.Contains("scaduto") == true ||
result.Error?.Contains("closed") == true ||
result.Error?.Contains("ended") == true;
auction.AddLog($"[BID FAIL] {result.Error} | Ping: {pollingPing}ms");
OnLog?.Invoke($"[FAIL] Puntata fallita su {auction.Name} ({auction.AuctionId}): {result.Error}");
// Feedback per puntata tardiva
if (isLateBid && settings.ShowLateBidWarning)
{
int currentOffset = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
int suggestedOffset = currentOffset + 300 + pollingPing;
auction.AddLog($"[⚠️ TIMING] Puntata arrivata troppo tardi! " +
$"Offset attuale: {currentOffset}ms. Latenza totale: ~{pollingPing + result.LatencyMs}ms. " +
$"Suggerimento: aumenta a {suggestedOffset}ms");
OnLog?.Invoke($"[LATE] {auction.Name}: puntata tardiva, aumenta offset a {suggestedOffset}ms");
}
}
auction.BidHistory.Add(new BidHistory
{
Timestamp = result.Timestamp,
EventType = result.Success ? BidEventType.MyBid : BidEventType.OpponentBid,
Bidder = "Tu",
Price = state.Price,
Timer = state.Timer,
LatencyMs = result.LatencyMs,
Success = result.Success,
Notes = result.Success ? $"OK" : (result.Error ?? "Errore sconosciuto")
});
return result;
}
catch (Exception ex)
{
auction.AddLog($"[BID EXCEPTION] {ex.Message}");
OnLog?.Invoke($"[BID EXCEPTION] [{auction.AuctionId}] {ex.Message}");
return null;
}
}
private bool ShouldBid(AuctionInfo auction, AuctionState state, AppSettings? settings = null)
{
settings ??= Utilities.SettingsManager.Load();
// CONTROLLO 0: Verifica convenienza (se abilitato e dati disponibili)
if (settings.ValueCheckEnabled &&
auction.BuyNowPrice.HasValue &&
auction.BuyNowPrice.Value > 0 &&
auction.CalculatedValue != null &&
auction.CalculatedValue.Savings.HasValue &&
!auction.CalculatedValue.IsWorthIt)
{
if (auction.CalculatedValue.SavingsPercentage.HasValue &&
auction.CalculatedValue.SavingsPercentage.Value < settings.MinSavingsPercentage)
{
auction.AddLog($"⛔ Risparmio {auction.CalculatedValue.SavingsPercentage.Value:F1}% < {settings.MinSavingsPercentage:F1}% richiesto",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Value);
LogBlockThrottled(auction, "VALUE", $"[VALUE] {auction.Name}: risparmio {auction.CalculatedValue.SavingsPercentage.Value:F1}% insufficiente");
return false;
}
}
else
{
auction.AddLog($"✓ Convenienza OK (check={settings.ValueCheckEnabled}, buyNow={auction.BuyNowPrice?.ToString("F2") ?? "N/D"})",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Value);
}
// CONTROLLO ANTI-COLLISIONE (OPZIONALE)
if (settings.HardcodedAntiCollisionEnabled)
{
var recentBidsThreshold = 10;
var maxActiveBidders = 3;
try
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var recentBids = auction.RecentBids
.Where(b => now - b.Timestamp <= recentBidsThreshold)
.ToList();
var activeBidders = recentBids
.Select(b => b.Username)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
auction.AddLog($"Competizione: {activeBidders} bidder attivi (soglia={maxActiveBidders})",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Competition);
if (activeBidders >= maxActiveBidders)
{
var session = _apiClient.GetSession();
var lastBid = recentBids.OrderByDescending(b => b.Timestamp).FirstOrDefault();
if (lastBid != null &&
!lastBid.Username.Equals(session?.Username, StringComparison.OrdinalIgnoreCase))
{
auction.AddLog($"⛔ Asta affollata: {activeBidders} bidder attivi",
Models.AuctionLogLevel.Strategy, Models.AuctionLogCategory.Competition);
return false;
}
}
}
catch { }
}
// CONTROLLO 1: Limite minimo puntate residue
if (settings.MinimumRemainingBids > 0)
{
var session = _apiClient.GetSession();
if (session != null && session.RemainingBids <= settings.MinimumRemainingBids)
{
auction.AddLog($"⛔ Puntate residue ({session.RemainingBids}) ≤ limite ({settings.MinimumRemainingBids})",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "LIMIT", $"[LIMIT] {auction.Name}: puntate residue ({session.RemainingBids}) <= limite ({settings.MinimumRemainingBids})");
return false;
}
else if (session != null)
{
auction.AddLog($"✓ Puntate residue OK ({session.RemainingBids} > {settings.MinimumRemainingBids})",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Limit);
}
}
// CONTROLLO 2: Non puntare se sono già il vincitore corrente
if (state.IsMyBid)
{
auction.AddLog($"✓ Sono già vincitore corrente - skip",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.BidAttempt);
return false;
}
// CONTROLLO 3: Limite puntate per questa asta
// Controlla MaxClicks (impostato dall'utente nella griglia o da product stats)
{
int maxBids = auction.MaxClicks; // 0 = illimitato
int usedBids = auction.BidsUsedOnThisAuction ?? 0;
if (maxBids > 0 && usedBids >= maxBids)
{
auction.AddLog($"⛔ Limite puntate raggiunto ({usedBids}/{maxBids})",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "MAXBIDS", $"[LIMIT] {auction.Name}: puntate {usedBids}/{maxBids} - STOP");
return false;
}
if (maxBids > 0)
{
auction.AddLog($"✓ Puntate OK ({usedBids}/{maxBids})",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Limit);
}
}
// CONTROLLO 4: MinPrice/MaxPrice
if (auction.MinPrice > 0 && state.Price < auction.MinPrice)
{
auction.AddLog($"⛔ Prezzo €{state.Price:F2} < Min €{auction.MinPrice:F2}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Price);
LogBlockThrottled(auction, "PRICE_LOW", $"[PRICE] {auction.Name}: €{state.Price:F2} < Min €{auction.MinPrice:F2} - NON PUNTA");
return false;
}
if (auction.MaxPrice > 0 && state.Price > auction.MaxPrice)
{
auction.AddLog($"⛔ Prezzo €{state.Price:F2} > Max €{auction.MaxPrice:F2}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Price);
LogBlockThrottled(auction, "PRICE_HIGH", $"[PRICE] {auction.Name}: €{state.Price:F2} > Max €{auction.MaxPrice:F2} - NON PUNTA");
return false;
}
auction.AddLog($"✓ Prezzo OK (€{state.Price:F2} in [{auction.MinPrice:F2}, {(auction.MaxPrice > 0 ? auction.MaxPrice.ToString("F2") : "")}])",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Price);
// CONTROLLO 5-bis: BUDGET E PAREGGIO
//
// E' il controllo che rende vera la frase "vincere senza perderci soldi", ed e'
// l'unico che guarda il costo COMPLETO invece del solo prezzo: prezzo dell'asta
// piu' le puntate gia' spese. Sta qui, dopo i limiti di prezzo, perche' li
// completa: un tetto di 2 EUR su un buono da 11 EUR sembra prudente, ma
// arrivarci con sessanta puntate significa pagarlo quattordici.
{
var budget = BidBudget.Evaluate(new BidBudget.Situation(
CurrentPrice: state.Price,
BidsAlreadyUsed: auction.BidsUsedOnThisAuction ?? 0,
BidCostEuro: auction.BidCost,
ProductValue: auction.BuyNowPrice,
ShippingCost: auction.ShippingCost,
MaxBids: auction.MaxClicks,
MaxTotalSpendEuro: auction.MaxTotalSpendEuro,
MinSavingsPercentage: settings.MinSavingsPercentage,
StopAtBreakEven: auction.StopAtBreakEven));
if (!budget.CanBid)
{
auction.AddLog($"⛔ {budget.Reason}",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "BUDGET", $"[BUDGET] {auction.Name}: {budget.Reason}");
return false;
}
}
// CONTROLLO 6: Cooldown
if (auction.LastClickAt.HasValue)
{
var timeSinceLastClick = DateTime.UtcNow - auction.LastClickAt.Value;
if (timeSinceLastClick.TotalMilliseconds < 800)
{
auction.AddLog($"Cooldown: {timeSinceLastClick.TotalMilliseconds:F0}ms < 800ms",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Ticker);
return false;
}
}
auction.AddLog($"✓ Tutti i controlli ShouldBid superati",
Models.AuctionLogLevel.Info, Models.AuctionLogCategory.BidAttempt);
return true;
}
private DateTime? GetLastBidTime(AuctionInfo auction, string bidder)
{
if (string.IsNullOrEmpty(bidder))
return null;
if (auction.BidderStats.TryGetValue(bidder, out var info))
{
return info.LastBidTime;
}
return null;
}
private void UpdateAuctionHistory(AuctionInfo auction, AuctionState state)
{
var lastHistory = auction.BidHistory.LastOrDefault();
var lastPrice = lastHistory?.Price ?? 0;
var lastBidder = lastHistory?.Bidder;
bool isNewBid = false;
// Nuova puntata = CAMBIO PREZZO
if (state.Price > lastPrice && state.Price > 0)
{
isNewBid = true;
}
// Fallback: cambio utente
if (!isNewBid &&
!string.IsNullOrEmpty(lastBidder) &&
!string.IsNullOrEmpty(state.LastBidder) &&
!lastBidder.Equals(state.LastBidder, StringComparison.OrdinalIgnoreCase))
{
isNewBid = true;
}
if (isNewBid)
{
auction.ResetCount++;
AuctionDossier.For(auction.AuctionId)?.Reset(auction.ResetCount, state.Price, state.LastBidder);
// Log quando c'è una nuova puntata (se logging attivo)
var settings = SettingsManager.Load();
if (settings.LogAuctionStatus)
{
var session = _apiClient.GetSession();
var isMyBid = state.LastBidder?.Equals(session?.Username, StringComparison.OrdinalIgnoreCase) == true;
auction.AddLog($"[RESET #{auction.ResetCount}] {state.LastBidder} → €{state.Price:F2} | Timer: {state.Timer:F1}s{(isMyBid ? " (TU)" : "")}");
}
auction.BidHistory.Add(new BidHistory
{
Timestamp = DateTime.UtcNow,
EventType = BidEventType.Reset,
Bidder = state.LastBidder ?? "",
Price = state.Price,
Timer = state.Timer,
Notes = $"Puntata: EUR{state.Price:F2}"
});
// ?? Limita dimensione BidHistory per evitare memory leak
if (auction.BidHistory.Count > 100)
{
auction.BidHistory.RemoveRange(0, auction.BidHistory.Count - 100);
}
// ? RIMOSSO: Non incrementare qui - è già gestito da UpdateBidderStatsFromRecentBids
// L'incremento doppio causava conteggi gonfiati
OnResetCountChanged?.Invoke(auction.AuctionId);
}
}
private double GetLastTimer(AuctionInfo auction)
{
var lastEntry = auction.BidHistory.LastOrDefault();
return lastEntry?.Timer ?? 999;
}
/// <summary>
/// Unisce la storia puntate ricevuta dall'API con quella esistente,
/// mantenendo le puntate più vecchie e aggiungendo solo le nuove.
/// Le puntate sono ordinate con le più RECENTI in CIMA.
/// </summary>
private void MergeBidHistory(AuctionInfo auction, List<BidHistoryEntry> newBids)
{
try
{
var maxEntries = Settings.MaxBidHistoryEntries;
var limit = maxEntries > 0 ? Math.Min(maxEntries, 50) : 50;
lock (auction.BidsLock)
{
var bids = auction.RecentBids;
if (bids == null) return; // asta in fase di rimozione
// Chiave composta: stessa puntata = stesso istante, utente e prezzo.
var existing = new HashSet<string>(
bids.Select(b => $"{b.Timestamp}_{b.Username}_{b.Price:F2}"));
var added = 0;
var dossier = AuctionDossier.For(auction.AuctionId);
foreach (var bid in newBids)
{
if (!existing.Add($"{bid.Timestamp}_{bid.Username}_{bid.Price:F2}")) continue;
bids.Add(bid);
added++;
// Ogni puntata di ogni utente, con la sua ora: è la sequenza da cui
// si legge come si comportano gli avversari, e Bidoo non la
// conserva una volta che l'asta è chiusa.
dossier?.ForeignBid(bid, (double)bid.Price);
}
if (added == 0) return;
// Si ordina e si tronca SUL POSTO: sostituire la lista mentre altri
// la tengono in mano e' esattamente il difetto che stiamo togliendo.
bids.Sort((a, b) =>
{
var byTime = b.Timestamp.CompareTo(a.Timestamp);
return byTime != 0 ? byTime : b.Price.CompareTo(a.Price);
});
if (bids.Count > limit) bids.RemoveRange(limit, bids.Count - limit);
UpdateBidderStatsFromRecentBids(auction);
}
}
catch (Exception ex)
{
auction.AddLog($"[WARN] Errore merge storia puntate: {ex.Message}");
}
}
/// <summary>
/// Assicura che la puntata corrente (quella vincente) sia sempre presente nello storico.
/// Questo risolve il problema dell'API che a volte non include l'ultima puntata.
/// IMPORTANTE: Aggiunge solo se è una NUOVA puntata (prezzo/utente diverso dall'ultima registrata).
/// </summary>
private void EnsureCurrentBidInHistory(AuctionInfo auction, AuctionState state)
{
try
{
var statePrice = (decimal)state.Price;
var currentBidder = state.LastBidder;
lock (auction.BidsLock)
{
var bids = auction.RecentBids;
if (bids == null) return; // asta in fase di rimozione
// Gia' presente: stesso prezzo e stesso utente.
var alreadyExists = bids.Any(b =>
Math.Abs(b.Price - statePrice) < 0.001m &&
b.Username.Equals(currentBidder, StringComparison.OrdinalIgnoreCase));
if (alreadyExists) return;
bids.Insert(0, new BidHistoryEntry
{
Username = currentBidder,
Price = statePrice,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
BidType = BidHistoryEntry.TipoSconosciuto
});
}
// Una puntata avversaria: se arriva col ritardo tipico dell'autopuntata
// (~6 s dalla nostra) alimenta il conteggio del duello.
var impostazioni = SettingsManager.Load();
if (impostazioni.AutoBidDuelWithdrawEnabled &&
auction.NoteOpponentResponse(DateTime.UtcNow, impostazioni.AutoBidDuelResponses))
{
auction.AddLog(AutoBidDuel.Spiegazione(auction.AutoResponsesInARow),
AuctionLogLevel.Warning, AuctionLogCategory.Strategy);
}
// I conteggi per utente restano a carico di UpdateBidderStatsFromRecentBids,
// unico punto che li tocca: contarli anche qui li raddoppierebbe.
}
catch { /* Silenzioso */ }
}
/// <summary>
/// Aggiorna le statistiche dei bidder basandosi SOLO su RecentBids.
/// QUESTO È L'UNICO POSTO che aggiorna i conteggi.
/// IMPORTANTE: Il conteggio è basato SOLO sulle puntate in RecentBids, non cumulativo.
/// </summary>
private void UpdateBidderStatsFromRecentBids(AuctionInfo auction)
{
// Chiamata sempre con auction.BidsLock gia' acquisito: i conteggi derivano
// dalla stessa lista e devono restare coerenti con essa.
try
{
if (auction.RecentBids == null || auction.BidderStats == null) return;
// Si conta ogni volta da zero sulla lista attuale: i conteggi a delta
// accumulavano errori quando la lista veniva troncata.
var bidsByUser = auction.RecentBids
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => new
{
Count = g.Count(),
LastBidTime = DateTimeOffset.FromUnixTimeSeconds(g.Max(b => b.Timestamp)).DateTime
},
StringComparer.OrdinalIgnoreCase
);
// Resetta tutti i conteggi e ricalcola da zero
// Questo evita accumuli errati dovuti a delta sbagliati
foreach (var existing in auction.BidderStats.Values)
{
existing.BidCount = 0;
existing.RecentBidCount = 0;
}
// Aggiorna con i conteggi reali da RecentBids
foreach (var kvp in bidsByUser)
{
var username = kvp.Key;
var stats = kvp.Value;
if (!auction.BidderStats.ContainsKey(username))
{
auction.BidderStats[username] = new BidderInfo
{
Username = username,
BidCount = stats.Count,
RecentBidCount = stats.Count,
LastBidTime = stats.LastBidTime
};
}
else
{
var existing = auction.BidderStats[username];
existing.BidCount = stats.Count;
existing.RecentBidCount = stats.Count;
existing.LastBidTime = stats.LastBidTime;
}
}
}
catch (Exception ex)
{
auction.AddLog($"[WARN] Errore aggiornamento statistiche bidder: {ex.Message}");
}
}
public void Dispose()
{
Stop();
_apiClient?.Dispose();
_clock.Reset();
}
public async Task<BidResult> PlaceManualBidAsync(AuctionInfo auction)
{
return await _apiClient.PlaceBidAsync(auction.AuctionId, auction.OriginalUrl);
}
/// <summary>
/// Espone ApiClient per SessionService
/// </summary>
public BidooApiClient GetApiClient()
{
return _apiClient;
}
}
}