- Timing di puntata ora gestito solo da offset fisso configurabile, rimosse strategie di compensazione latenza/jitter/offset dinamico - Aggiunto controllo convenienza: blocca puntate se il costo supera il "Compra Subito" oltre una soglia configurabile - Logging granulare: nuove opzioni per log selettivo (puntate, strategie, valore, competizione, timing, errori, stato, profiling avversari) - Persistenza stato browser aste (categoria, ricerca) tramite ApplicationStateService - Fix conteggio puntate per bidder, rimosso rilevamento "Last Second Sniper", aggiunta strategia "Price Momentum" - Refactoring e pulizia: rimozione codice obsoleto, migliorata documentazione e thread-safety
545 lines
22 KiB
C#
545 lines
22 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AutoBidder.Models;
|
|
using AutoBidder.Utilities;
|
|
|
|
namespace AutoBidder.Services
|
|
{
|
|
/// <summary>
|
|
/// Servizio per strategie avanzate di puntata.
|
|
/// Implementa: adaptive latency, jitter, dynamic offset, heat metric,
|
|
/// competition detection, soft retreat, probabilistic bidding, opponent profiling.
|
|
/// </summary>
|
|
public class BidStrategyService
|
|
{
|
|
private readonly Random _random = new();
|
|
private int _sessionTotalBids = 0;
|
|
private DateTime _sessionStartedAt = DateTime.UtcNow;
|
|
|
|
/// <summary>
|
|
/// Aggiorna heat metric per un'asta
|
|
/// </summary>
|
|
public void UpdateHeatMetric(AuctionInfo auction, AppSettings settings, string currentUsername = "")
|
|
{
|
|
if (!settings.CompetitionDetectionEnabled) return;
|
|
|
|
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
|
var windowStart = now - settings.CompetitionWindowSeconds;
|
|
|
|
// Conta bidder unici nella finestra temporale (escludo me stesso)
|
|
var recentBids = auction.RecentBids
|
|
.Where(b => b.Timestamp >= windowStart)
|
|
.Where(b => !b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
auction.ActiveBiddersCount = recentBids
|
|
.Select(b => b.Username)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Count();
|
|
|
|
// Conta collisioni (puntate nello stesso secondo)
|
|
var bidsBySecond = recentBids
|
|
.GroupBy(b => b.Timestamp)
|
|
.Where(g => g.Count() > 1)
|
|
.Count();
|
|
|
|
auction.CollisionCount = bidsBySecond;
|
|
|
|
// Calcola heat metric (0-100)
|
|
// Fattori: bidder attivi (40%), frequenza puntate (30%), collisioni (30%)
|
|
|
|
int bidderScore = Math.Min(auction.ActiveBiddersCount * 15, 40); // Max 40 punti
|
|
int frequencyScore = Math.Min(recentBids.Count * 3, 30); // Max 30 punti
|
|
int collisionScore = Math.Min(auction.CollisionCount * 10, 30); // Max 30 punti
|
|
|
|
auction.HeatMetric = bidderScore + frequencyScore + collisionScore;
|
|
|
|
// Identifica bidder aggressivi e situazioni di duello
|
|
if (settings.OpponentProfilingEnabled)
|
|
{
|
|
UpdateAggressiveBidders(auction, settings, currentUsername);
|
|
DetectDuelSituation(auction, settings, currentUsername);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Identifica e tracca bidder aggressivi (basato su ultime N puntate, esclude utente corrente)
|
|
/// </summary>
|
|
private void UpdateAggressiveBidders(AuctionInfo auction, AppSettings settings, string currentUsername)
|
|
{
|
|
// ?? FIX: Usa finestra scorrevole di ultime N puntate
|
|
var windowSize = settings.AggressiveBidderWindowSize > 0 ? settings.AggressiveBidderWindowSize : 30;
|
|
var recentWindow = auction.RecentBids
|
|
.Take(windowSize)
|
|
.ToList();
|
|
|
|
var bidCounts = recentWindow
|
|
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
|
|
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
|
|
.ToList();
|
|
|
|
auction.AggressiveBidders.Clear();
|
|
|
|
foreach (var bidder in bidCounts)
|
|
{
|
|
// ?? FIX: NON aggiungere l'utente corrente come aggressivo!
|
|
if (bidder.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
// ?? FIX: Soglia più permissiva - usa percentuale invece di conteggio assoluto
|
|
// Un bidder è "aggressivo" se ha più del 40% delle puntate nella finestra (configurabile)
|
|
var percentageThreshold = settings.AggressiveBidderPercentageThreshold > 0 ? settings.AggressiveBidderPercentageThreshold : 40.0;
|
|
|
|
if (bidder.Percentage >= percentageThreshold || bidder.Count >= settings.AggressiveBidderThreshold)
|
|
{
|
|
auction.AggressiveBidders.Add(bidder.Username);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rileva situazione di "duello" (solo 2 bidder attivi che si contendono l'asta)
|
|
/// In questa situazione bisogna essere pronti perché se uno si ritira l'altro vince
|
|
/// </summary>
|
|
private void DetectDuelSituation(AuctionInfo auction, AppSettings settings, string currentUsername)
|
|
{
|
|
var windowSize = settings.DuelDetectionWindowSize > 0 ? settings.DuelDetectionWindowSize : 20;
|
|
var recentWindow = auction.RecentBids.Take(windowSize).ToList();
|
|
|
|
if (recentWindow.Count < 6) // Serve un minimo di puntate per rilevare un pattern
|
|
{
|
|
auction.IsDuelSituation = false;
|
|
auction.DuelOpponent = null;
|
|
return;
|
|
}
|
|
|
|
var bidders = recentWindow
|
|
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
|
|
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
|
|
.OrderByDescending(b => b.Count)
|
|
.ToList();
|
|
|
|
// Duello: esattamente 2 bidder dominanti che coprono almeno l'80% delle puntate
|
|
if (bidders.Count >= 2)
|
|
{
|
|
var top2Percentage = bidders.Take(2).Sum(b => b.Percentage);
|
|
|
|
if (top2Percentage >= 80 && bidders.Count <= 3)
|
|
{
|
|
auction.IsDuelSituation = true;
|
|
|
|
// Trova l'avversario (chi NON sono io)
|
|
var opponent = bidders.FirstOrDefault(b =>
|
|
!b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
|
|
|
|
auction.DuelOpponent = opponent?.Username;
|
|
|
|
// Calcola chi sta dominando
|
|
var myStats = bidders.FirstOrDefault(b =>
|
|
b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
|
|
|
|
auction.DuelAdvantage = myStats != null && opponent != null
|
|
? myStats.Percentage - opponent.Percentage
|
|
: 0;
|
|
}
|
|
else
|
|
{
|
|
auction.IsDuelSituation = false;
|
|
auction.DuelOpponent = null;
|
|
auction.DuelAdvantage = 0;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
auction.IsDuelSituation = false;
|
|
auction.DuelOpponent = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifica se è il caso di puntare considerando tutte le strategie
|
|
/// </summary>
|
|
public BidDecision ShouldPlaceBid(AuctionInfo auction, AuctionState state, AppSettings settings, string currentUsername)
|
|
{
|
|
var decision = new BidDecision { ShouldBid = true };
|
|
|
|
// Se le strategie avanzate sono disabilitate per questa asta, salta tutto
|
|
if (auction.AdvancedStrategiesEnabled == false)
|
|
{
|
|
return decision;
|
|
}
|
|
|
|
// ? RIMOSSO: Entry Point - Era sbagliato!
|
|
// I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
|
|
// Se l'utente imposta MaxPrice=2€, vuole puntare FINO A 2€, non fino al 70%!
|
|
// I controlli MinPrice/MaxPrice sono già gestiti in AuctionMonitor.ShouldBid()
|
|
// L'Entry Point può essere usato SOLO per calcolare limiti CONSIGLIATI, non per bloccare.
|
|
|
|
// ?? 1. ANTI-BOT - Rileva pattern bot (timing identico)
|
|
if (settings.AntiBotDetectionEnabled && !string.IsNullOrEmpty(state.LastBidder))
|
|
{
|
|
var botCheck = DetectBotPattern(auction, state.LastBidder, currentUsername);
|
|
if (botCheck.IsBot)
|
|
{
|
|
decision.ShouldBid = false;
|
|
decision.Reason = $"Anti-bot: {state.LastBidder} pattern sospetto (var={botCheck.TimingVarianceMs:F0}ms)";
|
|
return decision;
|
|
}
|
|
}
|
|
|
|
// ?? 2. USER EXHAUSTION - Sfrutta utenti stanchi (info solo, non blocca)
|
|
if (settings.UserExhaustionEnabled && !string.IsNullOrEmpty(state.LastBidder))
|
|
{
|
|
var exhaustionCheck = CheckUserExhaustion(auction, state.LastBidder, currentUsername);
|
|
// Non blocchiamo, ma potremmo loggare per info
|
|
}
|
|
|
|
// 3. Verifica soft retreat
|
|
if (settings.SoftRetreatEnabled || (auction.SoftRetreatEnabledOverride ?? settings.SoftRetreatEnabled))
|
|
{
|
|
if (auction.IsInSoftRetreat)
|
|
{
|
|
var retreatEnd = auction.LastSoftRetreatAt?.AddSeconds(settings.SoftRetreatDurationSeconds);
|
|
if (retreatEnd > DateTime.UtcNow)
|
|
{
|
|
decision.ShouldBid = false;
|
|
decision.Reason = $"Soft retreat attivo (termina tra {(retreatEnd.Value - DateTime.UtcNow).TotalSeconds:F0}s)";
|
|
return decision;
|
|
}
|
|
else
|
|
{
|
|
// Fine soft retreat
|
|
auction.IsInSoftRetreat = false;
|
|
auction.ConsecutiveCollisions = 0;
|
|
}
|
|
}
|
|
|
|
// Verifica se attivare soft retreat
|
|
if (auction.ConsecutiveCollisions >= settings.SoftRetreatAfterCollisions)
|
|
{
|
|
auction.IsInSoftRetreat = true;
|
|
auction.LastSoftRetreatAt = DateTime.UtcNow;
|
|
decision.ShouldBid = false;
|
|
decision.Reason = $"Soft retreat attivato dopo {auction.ConsecutiveCollisions} collisioni";
|
|
return decision;
|
|
}
|
|
}
|
|
|
|
// 2. Verifica competition threshold
|
|
if (settings.CompetitionDetectionEnabled)
|
|
{
|
|
if (auction.ActiveBiddersCount >= settings.CompetitionThreshold)
|
|
{
|
|
// Controlla se l'ultimo bidder sono io - se sì, posso continuare
|
|
var lastBid = auction.RecentBids.OrderByDescending(b => b.Timestamp).FirstOrDefault();
|
|
if (lastBid != null && !lastBid.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (settings.AutoPauseHotAuctions && auction.HeatMetric >= settings.HeatThresholdForPause)
|
|
{
|
|
decision.ShouldBid = false;
|
|
decision.Reason = $"Asta troppo calda (heat={auction.HeatMetric}%, bidder={auction.ActiveBiddersCount})";
|
|
return decision;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Verifica opponent profiling
|
|
if (settings.OpponentProfilingEnabled && auction.AggressiveBidders.Count > 0)
|
|
{
|
|
if (settings.AggressiveBidderAction == "Avoid")
|
|
{
|
|
decision.ShouldBid = false;
|
|
decision.Reason = $"Bidder aggressivi rilevati: {string.Join(", ", auction.AggressiveBidders.Take(3))}";
|
|
return decision;
|
|
}
|
|
}
|
|
|
|
// 4. Probabilistic bidding
|
|
if (settings.ProbabilisticBiddingEnabled)
|
|
{
|
|
var probability = CalculateBidProbability(auction, settings);
|
|
var roll = _random.NextDouble();
|
|
|
|
if (roll > probability)
|
|
{
|
|
decision.ShouldBid = false;
|
|
decision.Reason = $"Skip probabilistico (p={probability:P0}, roll={roll:P0})";
|
|
return decision;
|
|
}
|
|
}
|
|
|
|
// 5. Bankroll manager
|
|
if (settings.BankrollManagerEnabled)
|
|
{
|
|
var bankrollCheck = CheckBankrollLimits(auction, settings);
|
|
if (!bankrollCheck.CanBid)
|
|
{
|
|
decision.ShouldBid = false;
|
|
decision.Reason = bankrollCheck.Reason;
|
|
return decision;
|
|
}
|
|
}
|
|
|
|
// ? RIMOSSO: DetectLastSecondSniper - causava falsi positivi
|
|
// In un duello, TUTTI i bidder hanno pattern regolari (ogni reset del timer)
|
|
// Questa strategia bloccava puntate legittime e faceva perdere aste
|
|
|
|
// ?? 7. STRATEGIA: Price Momentum (con soglia più alta)
|
|
// Se il prezzo sta salendo TROPPO velocemente, pausa
|
|
var priceVelocity = CalculatePriceVelocity(auction);
|
|
if (priceVelocity > 0.10) // +10 centesimi/secondo = MOLTO veloce
|
|
{
|
|
decision.ShouldBid = false;
|
|
decision.Reason = $"Prezzo sale troppo veloce ({priceVelocity:F3}€/s)";
|
|
return decision;
|
|
}
|
|
|
|
return decision;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calcola la velocità di crescita del prezzo (€/secondo)
|
|
/// </summary>
|
|
private double CalculatePriceVelocity(AuctionInfo auction)
|
|
{
|
|
if (auction.RecentBids.Count < 5) return 0;
|
|
|
|
var recentBids = auction.RecentBids.Take(10).ToList();
|
|
if (recentBids.Count < 2) return 0;
|
|
|
|
var first = recentBids.Last();
|
|
var last = recentBids.First();
|
|
|
|
var timeDiffSeconds = last.Timestamp - first.Timestamp;
|
|
if (timeDiffSeconds <= 0) return 0;
|
|
|
|
var priceDiff = last.Price - first.Price;
|
|
return (double)priceDiff / timeDiffSeconds;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rileva pattern bot analizzando i delta timing degli ultimi bid
|
|
/// </summary>
|
|
private (bool IsBot, double TimingVarianceMs) DetectBotPattern(AuctionInfo auction, string? lastBidder, string currentUsername)
|
|
{
|
|
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
|
|
return (false, 999);
|
|
|
|
// Ottieni gli ultimi 3+ bid di questo utente
|
|
var userBids = auction.RecentBids
|
|
.Where(b => b.Username.Equals(lastBidder, StringComparison.OrdinalIgnoreCase))
|
|
.OrderByDescending(b => b.Timestamp)
|
|
.Take(4)
|
|
.ToList();
|
|
|
|
if (userBids.Count < 3)
|
|
return (false, 999);
|
|
|
|
// Calcola i delta tra bid consecutivi
|
|
var deltas = new List<long>();
|
|
for (int i = 0; i < userBids.Count - 1; i++)
|
|
{
|
|
deltas.Add(userBids[i].Timestamp - userBids[i + 1].Timestamp);
|
|
}
|
|
|
|
if (deltas.Count < 2)
|
|
return (false, 999);
|
|
|
|
// Calcola varianza dei delta
|
|
var avg = deltas.Average();
|
|
var variance = deltas.Sum(d => Math.Pow(d - avg, 2)) / deltas.Count;
|
|
var stdDev = Math.Sqrt(variance) * 1000; // Converti in ms
|
|
|
|
// Se la varianza è < 50ms, probabilmente è un bot
|
|
return (stdDev < 50, stdDev);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifica se un utente è esausto (molte puntate, può mollare)
|
|
/// </summary>
|
|
private (bool ShouldExploit, string Reason) CheckUserExhaustion(AuctionInfo auction, string? lastBidder, string currentUsername)
|
|
{
|
|
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
|
|
return (false, "");
|
|
|
|
// Verifica se l'utente è un "heavy user" (>50 puntate totali)
|
|
if (auction.BidderStats.TryGetValue(lastBidder, out var stats))
|
|
{
|
|
if (stats.BidCount > 50)
|
|
{
|
|
// Se ci sono pochi altri bidder attivi, può essere un buon momento
|
|
var activeBidders = auction.BidderStats.Values.Count(b => b.BidCount > 5);
|
|
if (activeBidders <= 3)
|
|
{
|
|
return (true, $"{lastBidder} ha {stats.BidCount} puntate, potrebbe mollare");
|
|
}
|
|
}
|
|
}
|
|
|
|
return (false, "");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calcola probabilità di puntata basata su competizione e ROI
|
|
/// </summary>
|
|
private double CalculateBidProbability(AuctionInfo auction, AppSettings settings)
|
|
{
|
|
var probability = settings.BaseBidProbability;
|
|
|
|
// Riduci probabilità per ogni bidder attivo oltre la soglia
|
|
var extraBidders = Math.Max(0, auction.ActiveBiddersCount - settings.CompetitionThreshold);
|
|
probability -= extraBidders * settings.ProbabilityReductionPerBidder;
|
|
|
|
// Riduci per heat metric alto
|
|
if (auction.HeatMetric > 70)
|
|
{
|
|
probability -= 0.1;
|
|
}
|
|
|
|
// Aumenta se abbiamo un buon ROI potenziale
|
|
if (auction.CalculatedValue?.Savings > 0)
|
|
{
|
|
probability += 0.1;
|
|
}
|
|
|
|
return Math.Clamp(probability, 0.1, 1.0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifica limiti bankroll
|
|
/// </summary>
|
|
private BankrollCheckResult CheckBankrollLimits(AuctionInfo auction, AppSettings settings)
|
|
{
|
|
var result = new BankrollCheckResult { CanBid = true };
|
|
|
|
// Limite puntate per asta
|
|
var maxPerAuction = auction.MaxBidsOverride ?? settings.MaxBidsPerAuction;
|
|
if (maxPerAuction > 0 && auction.SessionBidCount >= maxPerAuction)
|
|
{
|
|
result.CanBid = false;
|
|
result.Reason = $"Limite puntate per asta raggiunto ({auction.SessionBidCount}/{maxPerAuction})";
|
|
return result;
|
|
}
|
|
|
|
// Limite puntate per sessione
|
|
if (settings.MaxBidsPerSession > 0 && _sessionTotalBids >= settings.MaxBidsPerSession)
|
|
{
|
|
result.CanBid = false;
|
|
result.Reason = $"Limite puntate per sessione raggiunto ({_sessionTotalBids}/{settings.MaxBidsPerSession})";
|
|
return result;
|
|
}
|
|
|
|
// Budget giornaliero
|
|
if (settings.DailyBudgetEuro > 0)
|
|
{
|
|
var spent = _sessionTotalBids * settings.AverageBidCostEuro;
|
|
if (spent >= settings.DailyBudgetEuro)
|
|
{
|
|
result.CanBid = false;
|
|
result.Reason = $"Budget giornaliero esaurito (€{spent:F2}/€{settings.DailyBudgetEuro:F2})";
|
|
return result;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registra una puntata effettuata (per tracking)
|
|
/// </summary>
|
|
public void RecordBidAttempt(AuctionInfo auction, bool success, bool collision = false)
|
|
{
|
|
auction.SessionBidCount++;
|
|
_sessionTotalBids++;
|
|
|
|
if (success)
|
|
{
|
|
auction.SuccessfulBidCount++;
|
|
auction.ConsecutiveCollisions = 0;
|
|
}
|
|
else
|
|
{
|
|
auction.FailedBidCount++;
|
|
}
|
|
|
|
if (collision)
|
|
{
|
|
auction.CollisionCount++;
|
|
auction.ConsecutiveCollisions++;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registra un timer scaduto
|
|
/// </summary>
|
|
public void RecordTimerExpired(AuctionInfo auction)
|
|
{
|
|
auction.TimerExpiredCount++;
|
|
auction.ConsecutiveCollisions++; // Conta come "mancato"
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reset contatori sessione
|
|
/// </summary>
|
|
public void ResetSession()
|
|
{
|
|
_sessionTotalBids = 0;
|
|
_sessionStartedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ottiene statistiche sessione corrente
|
|
/// </summary>
|
|
public SessionStats GetSessionStats()
|
|
{
|
|
return new SessionStats
|
|
{
|
|
TotalBids = _sessionTotalBids,
|
|
SessionDuration = DateTime.UtcNow - _sessionStartedAt
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Risultato calcolo timing puntata
|
|
/// </summary>
|
|
public class BidTimingResult
|
|
{
|
|
public int BaseOffsetMs { get; set; }
|
|
public int LatencyCompensationMs { get; set; }
|
|
public int DynamicAdjustmentMs { get; set; }
|
|
public int JitterMs { get; set; }
|
|
public int FinalOffsetMs { get; set; }
|
|
public bool ShouldBid { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decisione se puntare
|
|
/// </summary>
|
|
public class BidDecision
|
|
{
|
|
public bool ShouldBid { get; set; }
|
|
public string? Reason { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Risultato verifica bankroll
|
|
/// </summary>
|
|
public class BankrollCheckResult
|
|
{
|
|
public bool CanBid { get; set; }
|
|
public string? Reason { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Statistiche sessione
|
|
/// </summary>
|
|
public class SessionStats
|
|
{
|
|
public int TotalBids { get; set; }
|
|
public TimeSpan SessionDuration { get; set; }
|
|
}
|
|
}
|