Files
Mimante/Mimante/Services/AuctionMonitor.cs
T
Alby96 e18a09e1da Gestione massiva limiti prodotto e ottimizzazione ticker
Aggiunta barra azioni per gestione massiva limiti prodotto in Statistics.razor (applica, salva, attiva/disattiva, copia consigliati). Uniformati simboli euro e messaggi in italiano. Ottimizzata la logica del ticker: controllo puntata ora avviene prima del polling, gestione fine asta differita tramite PendingEndState. Introdotto controllo esplicito su MaxClicks per asta. Implementata cache delle impostazioni in SettingsManager per ridurre accessi disco. Vari fix minori e miglioramenti di robustezza.
2026-03-03 08:53:38 +01:00

1326 lines
58 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio centrale per monitoraggio aste
/// Sistema di timing ottimizzato: punta solo se necessario, poco prima della scadenza
/// Integra BidStrategyService per strategie avanzate
/// </summary>
public class AuctionMonitor
{
private readonly BidooApiClient _apiClient;
private readonly BidStrategyService _bidStrategy;
private readonly List<AuctionInfo> _auctions = new();
private CancellationTokenSource? _monitoringCts;
private Task? _monitoringTask;
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 InitializeSession(string authToken, string username)
{
_apiClient.InitializeSession(authToken, username);
OnLog?.Invoke($"[OK] Sessione configurata per: {username}");
}
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();
}
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})");
}
}
}
public void RemoveAuction(string auctionId)
{
lock (_auctions)
{
var auction = _auctions.FirstOrDefault(a => a.AuctionId == auctionId);
if (auction != null)
{
// ?? Se l'asta è terminata, salva le statistiche prima di rimuoverla
if (!auction.IsActive && auction.LastState != null)
{
OnLog?.Invoke($"[STATS] Asta terminata rilevata: {auction.Name} - Salvataggio statistiche in corso...");
try
{
// Determina se è stata vinta dall'utente
var won = IsAuctionWonByUser(auction);
OnLog?.Invoke($"[STATS] Asta {auction.Name} - Stato: {(won ? "VINTA" : "PERSA")}");
// Emetti evento per salvare le statistiche
// Questo trigger sarà gestito in Program.cs con scraping HTML
OnAuctionCompleted?.Invoke(auction, auction.LastState, won);
}
catch (Exception ex)
{
OnLog?.Invoke($"[STATS ERROR] Errore durante salvataggio statistiche per {auction.Name}: {ex.Message}");
}
}
else
{
OnLog?.Invoke($"[REMOVE] Rimozione asta non terminata: {auction.Name} (non salvata nelle statistiche)");
}
// ?? IMPORTANTE: Pulisci tutti i dati in memoria per liberare RAM
try
{
auction.ClearData();
}
catch { /* Ignora errori durante pulizia */ }
_auctions.Remove(auction);
}
}
}
/// <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 = ProductStatisticsService.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 (_monitoringTask != null && !_monitoringTask.IsCompleted)
{
OnLog?.Invoke("[WARN] Monitoraggio gia' attivo");
return;
}
_monitoringCts = new CancellationTokenSource();
_monitoringTask = Task.Run(() => MonitoringLoop(_monitoringCts.Token));
OnLog?.Invoke("[START] Monitoraggio avviato");
}
public void Stop()
{
_monitoringCts?.Cancel();
_monitoringTask?.Wait(TimeSpan.FromSeconds(2));
_monitoringCts = null;
_monitoringTask = null;
OnLog?.Invoke("[STOP] Monitoraggio fermato");
}
private async Task MonitoringLoop(CancellationToken token)
{
var settings = SettingsManager.Load();
int tickerIntervalMs = Math.Max(100, settings.TickerIntervalMs); // Minimo 100ms
int pollingIntervalMs = 500; // Poll API ogni 500ms max
DateTime lastPoll = DateTime.MinValue;
DateTime lastDiagnostic = DateTime.MinValue;
OnLog?.Invoke($"[TICKER] Avviato con intervallo={tickerIntervalMs}ms, polling={pollingIntervalMs}ms");
while (!token.IsCancellationRequested)
{
try
{
// === FASE 1: Raccogli aste attive ===
List<AuctionInfo> activeAuctions;
lock (_auctions)
{
activeAuctions = _auctions.Where(a =>
a.IsActive &&
!IsAuctionTerminated(a)
).ToList();
}
if (activeAuctions.Count == 0)
{
// Nessuna asta attiva - polling molto lento
await Task.Delay(2000, token);
continue;
}
// === DIAGNOSTICA PERIODICA (ogni 30s) ===
var nowDiag = DateTime.UtcNow;
if ((nowDiag - lastDiagnostic).TotalSeconds >= 30)
{
lastDiagnostic = nowDiag;
settings = SettingsManager.Load(); // Ricarica impostazioni
foreach (var a in activeAuctions.Where(x => !x.IsPaused))
{
var timer = a.LastState?.Timer ?? 0;
var price = a.LastState?.Price ?? 0;
int offset = a.BidBeforeDeadlineMs > 0 ? a.BidBeforeDeadlineMs : settings.DefaultBidBeforeDeadlineMs;
double estimatedMs = GetEstimatedTimerMs(a);
var statusParts = new List<string>();
statusParts.Add($"Timer={timer:F1}s");
statusParts.Add($"Stima={estimatedMs:F0}ms");
statusParts.Add($"€{price:F2}");
statusParts.Add($"Offset={offset}ms");
statusParts.Add($"RawMs={a.LastRawTimer:F0}");
// Indica perché potrebbe non puntare
if (a.MaxPrice > 0 && price > a.MaxPrice)
statusParts.Add($"⛔MaxPrice={a.MaxPrice:F2}");
if (a.MinPrice > 0 && price < a.MinPrice)
statusParts.Add($"⛔MinPrice={a.MinPrice:F2}");
a.AddLog($"[DIAG] {string.Join(" | ", statusParts)}");
}
}
// === FASE 2: PRE-BID CHECK (zero I/O, solo timer locale) ===
// CRITICO: Controlla il ticker PRIMA del poll per le aste nella zona di puntata.
// Il poll prende 40-100ms di rete. Con più aste near-deadline,
// Task.WhenAll aspetta il poll più lento → il tick effettivo è 100-200ms.
// Se l'offset è 200ms, la finestra di puntata è PIÙ STRETTA del tick:
// Tick N: estimated=300ms > offset → non trigghera
// [poll prende 150ms]
// Tick N+1: estimated=-50ms → fuori finestra → MANCATA!
// Il pre-bid check usa lo stato dal poll PRECEDENTE (< 100ms fa),
// che è sufficientemente fresco per IsMyBid, prezzo e ultimo puntatore.
foreach (var auction in activeAuctions)
{
if (auction.IsPaused || auction.LastState == null) continue;
double estimatedTimerMs = GetEstimatedTimerMs(auction);
int offsetMs = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
if (estimatedTimerMs <= offsetMs && estimatedTimerMs > -tickerIntervalMs)
{
await TryPlaceBidTicker(auction, estimatedTimerMs, offsetMs, settings, token);
}
}
// === FASE 3: Poll API ===
// Il poll aggiorna dati (prezzo, ultimo puntatore, timer) per il prossimo tick.
// Se rileva la fine dell'asta, la salva in PendingEndState (non la processa subito).
// Le aste nella zona critica (< offset*2 ms) NON vengono pollate:
// il poll prenderebbe tempo prezioso e i dati locali sono sufficienti.
var now = DateTime.UtcNow;
bool shouldPollAll = (now - lastPoll).TotalMilliseconds >= pollingIntervalMs;
if (shouldPollAll)
{
// Poll normale: tutte le aste attive
var pollTasks = activeAuctions.Select(a => PollAuctionState(a, token));
await Task.WhenAll(pollTasks);
lastPoll = now;
}
else
{
// Poll rapido: SOLO le aste vicine alla scadenza MA non nella zona critica
// Se il timer è < offset*2, il poll ruberebbe tempo al prossimo pre-bid check
var nearDeadlineAuctions = activeAuctions.Where(a =>
{
double est = GetEstimatedTimerMs(a);
int off = a.BidBeforeDeadlineMs > 0
? a.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
return est < settings.StrategyCheckThresholdMs && est > off * 2;
}).ToList();
if (nearDeadlineAuctions.Count > 0)
{
var pollTasks = nearDeadlineAuctions.Select(a => PollAuctionState(a, token));
await Task.WhenAll(pollTasks);
}
}
// === FASE 4: POST-POLL TICKER CHECK ===
// Per aste che sono entrate nella zona di puntata DURANTE il poll.
// Le aste già puntate dal pre-bid check vengono skippate da BidScheduled.
foreach (var auction in activeAuctions)
{
if (auction.IsPaused || auction.LastState == null) continue;
double estimatedTimerMs = GetEstimatedTimerMs(auction);
int offsetMs = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
if (estimatedTimerMs <= offsetMs && estimatedTimerMs > -tickerIntervalMs)
{
await TryPlaceBidTicker(auction, estimatedTimerMs, offsetMs, settings, token);
}
}
// === FASE 4: Processa aste terminate (deferred) ===
// Solo ORA gestiamo le aste che il poll ha marcato come terminate.
// Il ticker ha avuto la sua ultima occasione nella Fase 3.
foreach (var auction in activeAuctions)
{
if (auction.PendingEndState != null)
{
HandleAuctionEnded(auction, auction.PendingEndState);
auction.PendingEndState = null;
}
}
// === FASE 5: Delay fisso del ticker ===
await Task.Delay(tickerIntervalMs, token);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
OnLog?.Invoke($"[ERRORE] Ticker loop: {ex.Message}");
await Task.Delay(500, token);
}
}
OnLog?.Invoke("[TICKER] Fermato");
}
/// <summary>
/// Calcola il timer stimato localmente (interpolazione tra poll)
/// </summary>
private double GetEstimatedTimerMs(AuctionInfo auction)
{
if (!auction.LastDeadlineUpdateUtc.HasValue || auction.LastRawTimer <= 0)
{
return auction.LastState?.Timer * 1000 ?? 0;
}
// Tempo trascorso dall'ultimo poll
var elapsed = (DateTime.UtcNow - auction.LastDeadlineUpdateUtc.Value).TotalMilliseconds;
// Timer stimato = timer raw - tempo trascorso
// NON clampare a 0: il ticker usa valori leggermente negativi
// per catturare la finestra quando il timer scade tra due tick
double estimated = auction.LastRawTimer - elapsed;
return estimated;
}
/// <summary>
/// Poll dello stato dell'asta (SOLO aggiornamento dati, nessuna logica di puntata)
/// </summary>
private async Task PollAuctionState(AuctionInfo auction, CancellationToken token)
{
try
{
var state = await _apiClient.PollAuctionStateAsync(auction.AuctionId, auction.OriginalUrl, token);
if (state == null) return;
// Log primo poll per debug
if (!auction.IsTrackedFromStart && auction.BidHistory.Count == 0)
{
auction.AddLog($"[START] Monitoraggio avviato - Timer: {state.Timer:F1}s, Prezzo: €{state.Price:F2}");
}
// Aggiorna latenza
auction.AddLatencyMeasurement(state.PollingLatencyMs);
// Tracking inizio
if (!auction.IsTrackedFromStart && auction.BidHistory.Count == 0)
{
auction.IsTrackedFromStart = true;
auction.TrackingStartedAt = DateTime.UtcNow;
}
// Aggiorna storia puntate
if (state.RecentBidsHistory != null && state.RecentBidsHistory.Count > 0)
{
MergeBidHistory(auction, state.RecentBidsHistory);
}
if (!string.IsNullOrEmpty(state.LastBidder) && state.Price > 0)
{
EnsureCurrentBidInHistory(auction, state);
}
// Gestione fine asta — DIFFERITA
// Non chiamare HandleAuctionEnded qui: il ticker deve avere
// un'ultima occasione di puntare con i dati freschi del poll.
// Lo stato di fine viene salvato in PendingEndState e processato
// dal loop principale DOPO il ticker check.
if (state.Status == AuctionStatus.EndedWon ||
state.Status == AuctionStatus.EndedLost ||
state.Status == AuctionStatus.Closed)
{
// Aggiorna LastState con i dati più freschi (ultimo puntatore, prezzo)
// così il ticker può fare controlli accurati (es. IsMyBid, prezzo)
auction.LastState = state;
auction.PendingEndState = state;
return;
}
// Notifica SOLO se ci sono cambiamenti significativi
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;
// ?? FIX CRITICO: Aggiorna timer locale per interpolazione tra poll
// Senza questo, GetEstimatedTimerMs restituisce sempre il valore
// statico dell'ultimo poll e il countdown non funziona
if (state.Timer > 0)
{
auction.LastRawTimer = state.Timer * 1000; // Converti secondi → millisecondi
auction.LastDeadlineUpdateUtc = DateTime.UtcNow;
}
if (shouldNotify)
{
OnAuctionUpdated?.Invoke(state);
}
UpdateAuctionHistory(auction, state);
// Calcola valore prodotto
UpdateProductValue(auction, state);
}
catch (Exception ex)
{
auction.AddLog($"[POLL ERROR] {ex.Message}");
}
}
/// <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;
// Fix: Aggiungi ultima puntata se mancante
if (!string.IsNullOrEmpty(state.LastBidder) && state.Price > 0)
{
var statePrice = (decimal)state.Price;
var alreadyExists = auction.RecentBids.Any(b =>
Math.Abs(b.Price - statePrice) < 0.001m &&
b.Username.Equals(state.LastBidder, StringComparison.OrdinalIgnoreCase));
if (!alreadyExists)
{
auction.RecentBids.Insert(0, new BidHistoryEntry
{
Username = state.LastBidder,
Price = statePrice,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
BidType = "Auto"
});
}
}
auction.IsActive = false;
auction.LastState = state;
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 */ }
}
private bool IsAuctionTerminated(AuctionInfo auction)
{
var lastHistory = auction.BidHistory.LastOrDefault();
if (lastHistory != null)
{
if (lastHistory.Notes != null &&
(lastHistory.Notes.Contains("VINTA") ||
lastHistory.Notes.Contains("Persa") ||
lastHistory.Notes.Contains("Chiusa")))
{
return true;
}
}
return false;
}
/// <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>
/// TICKER: Tenta la puntata quando il timer locale raggiunge l'offset configurato.
/// NESSUNA compensazione automatica della latenza - l'utente decide il timing esatto.
/// </summary>
private async Task TryPlaceBidTicker(AuctionInfo auction, double estimatedTimerMs, int offsetMs, AppSettings settings, CancellationToken token)
{
var state = auction.LastState;
if (state == null || state.IsMyBid || estimatedTimerMs <= 0) return;
// Log timing dettagliato per ogni check del ticker
auction.AddLog($"Timer={estimatedTimerMs:F0}ms | Offset={offsetMs}ms | Prezzo=€{state.Price:F2} | Ultimo={state.LastBidder ?? "-"}",
Models.AuctionLogLevel.Timing, Models.AuctionLogCategory.Ticker);
// === PROTEZIONE DOPPIA PUNTATA ===
if (estimatedTimerMs > auction.LastScheduledTimerMs + 500)
{
if (auction.BidScheduled)
{
auction.AddLog($"Reset ciclo: timer salito {auction.LastScheduledTimerMs:F0}→{estimatedTimerMs:F0}ms (qualcuno ha puntato)",
Models.AuctionLogLevel.Timing, Models.AuctionLogCategory.Ticker);
}
auction.BidScheduled = false;
}
if (auction.LastClickAt.HasValue &&
(DateTime.UtcNow - auction.LastClickAt.Value).TotalSeconds > 10)
{
auction.BidScheduled = false;
}
// Skip se già schedulata per questo ciclo
if (auction.BidScheduled && Math.Abs(auction.LastScheduledTimerMs - estimatedTimerMs) < 200)
{
auction.AddLog($"Skip: già schedulata per questo ciclo (Δ={Math.Abs(auction.LastScheduledTimerMs - estimatedTimerMs):F0}ms)",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Ticker);
return;
}
// Cooldown 1 secondo tra puntate
if (auction.LastClickAt.HasValue &&
(DateTime.UtcNow - auction.LastClickAt.Value).TotalMilliseconds < 1000)
{
var cooldownRemaining = 1000 - (DateTime.UtcNow - auction.LastClickAt.Value).TotalMilliseconds;
auction.AddLog($"Cooldown attivo: {cooldownRemaining:F0}ms rimanenti",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Ticker);
return;
}
// === OTTIMIZZAZIONE: Controlla strategie SOLO se vicino al target ===
if (estimatedTimerMs > settings.StrategyCheckThresholdMs)
{
return;
}
// === CONTROLLI FONDAMENTALI ===
auction.AddLog($"→ Inizio controlli puntata (timer={estimatedTimerMs:F0}ms ≤ soglia={settings.StrategyCheckThresholdMs}ms)",
Models.AuctionLogLevel.Info, Models.AuctionLogCategory.BidAttempt);
if (!ShouldBid(auction, state, settings))
{
return;
}
// === STRATEGIE AVANZATE ===
var session = _apiClient.GetSession();
var decision = _bidStrategy.ShouldPlaceBid(auction, state, settings, session?.Username ?? "");
if (!decision.ShouldBid)
{
auction.AddLog($"⛔ Strategia blocca: {decision.Reason}",
Models.AuctionLogLevel.Strategy, Models.AuctionLogCategory.Strategy);
LogBlockThrottled(auction, "STRATEGY", $"[STRATEGY] {auction.Name}: {decision.Reason}");
return;
}
auction.AddLog($"✓ Tutti i controlli superati → PUNTATA!",
Models.AuctionLogLevel.Bid, Models.AuctionLogCategory.BidAttempt);
// === ESEGUI PUNTATA ===
auction.BidScheduled = true;
auction.LastScheduledTimerMs = estimatedTimerMs;
auction.AddLog($"[BID] Timer locale={estimatedTimerMs:F0}ms, Offset utente={offsetMs}ms");
await ExecuteBid(auction, state, settings, token);
}
// ═══════════════════════════════════════════════════════════════════
// METODO LEGACY - Mantenuto per compatibilità ma non più usato
// ═══════════════════════════════════════════════════════════════════
[Obsolete("Usare TryPlaceBidTicker con il nuovo sistema Ticker Loop")]
private async Task PollAndProcessAuction(AuctionInfo auction, CancellationToken token)
{
// Ora gestito da PollAuctionState + TryPlaceBidTicker
await PollAuctionState(auction, token);
}
/// <summary>
/// Sistema di puntata SEMPLICE.
/// Punta esattamente a `offset` ms dalla scadenza.
/// Le strategie decidono SE puntare, non QUANDO.
/// </summary>
[Obsolete("Usare TryPlaceBidTicker")]
private async Task TryPlaceBid(AuctionInfo auction, AuctionState state, CancellationToken token)
{
// Reindirizza al nuovo sistema
var settings = SettingsManager.Load();
int offsetMs = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
double timerMs = state.Timer * 1000;
await TryPlaceBidTicker(auction, timerMs, offsetMs, settings, token);
}
/// <summary>
/// Esegue la puntata e registra metriche
/// </summary>
private async Task ExecuteBid(AuctionInfo auction, AuctionState state, AppSettings settings, CancellationToken token)
{
try
{
// Esegui la puntata immediatamente
var result = await _apiClient.PlaceBidAsync(auction.AuctionId, auction.OriginalUrl);
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")
});
}
catch (Exception ex)
{
auction.AddLog($"[BID EXCEPTION] {ex.Message}");
OnLog?.Invoke($"[BID EXCEPTION] [{auction.AuctionId}] {ex.Message}");
}
}
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 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++;
// 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
{
// Carica impostazioni per limite massimo
var settings = Utilities.SettingsManager.Load();
var maxEntries = settings?.MaxBidHistoryEntries ?? 50; // Default aumentato a 50
// ?? FIX: Usa lock per thread-safety
lock (auction.RecentBids)
{
// Se la lista esistente è vuota, semplicemente copia le nuove
if (auction.RecentBids.Count == 0)
{
auction.RecentBids = newBids.ToList();
// ?? FIX: Ordina per timestamp DESC, poi per prezzo DESC (per puntate stesso secondo)
auction.RecentBids = auction.RecentBids
.OrderByDescending(b => b.Timestamp)
.ThenByDescending(b => b.Price)
.ToList();
// Limita se necessario
if (maxEntries > 0 && auction.RecentBids.Count > maxEntries)
{
auction.RecentBids = auction.RecentBids
.Take(maxEntries)
.ToList();
}
// Aggiorna statistiche bidder basandosi su RecentBids
UpdateBidderStatsFromRecentBids(auction);
return;
}
// Crea un HashSet delle puntate esistenti per ricerca veloce
// Usiamo una chiave composta: timestamp + username + price per identificare univocamente una puntata
var existingBidsKeys = new HashSet<string>(
auction.RecentBids.Select(b => $"{b.Timestamp}_{b.Username}_{b.Price:F2}")
);
// Aggiungi solo le puntate nuove (non duplicate)
var bidsToAdd = newBids
.Where(b => !existingBidsKeys.Contains($"{b.Timestamp}_{b.Username}_{b.Price:F2}"))
.ToList();
if (bidsToAdd.Count > 0)
{
auction.RecentBids.AddRange(bidsToAdd);
// ?? FIX: Ordina per timestamp DESC, poi per prezzo DESC (per puntate stesso secondo)
auction.RecentBids = auction.RecentBids
.OrderByDescending(b => b.Timestamp)
.ThenByDescending(b => b.Price)
.ToList();
// Limita al numero massimo di puntate (mantieni le più recenti = prime della lista)
// ?? MEMORY: Limite ridotto per ottimizzazione RAM
int limit = maxEntries > 0 ? Math.Min(maxEntries, 50) : 50;
if (auction.RecentBids.Count > limit)
{
auction.RecentBids = auction.RecentBids
.Take(limit)
.ToList();
}
// Aggiorna statistiche bidder basandosi su RecentBids
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;
// ?? VERIFICA: Controlla se questa puntata è già presente nella lista
// Evitiamo duplicati controllando prezzo + utente in TUTTA la lista
var alreadyExists = auction.RecentBids.Any(b =>
Math.Abs(b.Price - statePrice) < 0.001m &&
b.Username.Equals(currentBidder, StringComparison.OrdinalIgnoreCase));
if (alreadyExists)
{
return; // Già presente, non serve aggiungere
}
// ?? NUOVA PUNTATA: Aggiungi solo se non esiste già
var lastBidTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
auction.RecentBids.Insert(0, new BidHistoryEntry
{
Username = currentBidder,
Price = statePrice,
Timestamp = lastBidTimestamp,
BidType = "Auto"
});
// ? RIMOSSO: Non incrementare BidderStats qui
// È gestito SOLO da UpdateBidderStatsFromRecentBids per evitare duplicazioni
// La puntata è stata aggiunta a RecentBids, sarà contata al prossimo aggiornamento
}
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)
{
try
{
// ?? FIX: Conta direttamente le puntate in RecentBids per ogni utente
// Non usare delta, conta sempre da zero basandosi sulla lista attuale
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();
}
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;
}
}
}