using System; using System.Collections.Generic; using System.Linq; using AutoBidder.Models; using AutoBidder.Utilities; namespace AutoBidder.Services { /// /// Servizio per strategie avanzate di puntata. /// Implementa: adaptive latency, jitter, dynamic offset, heat metric, /// competition detection, soft retreat, probabilistic bidding, opponent profiling. /// public class BidStrategyService { private readonly Random _random = new(); private int _sessionTotalBids = 0; private DateTime _sessionStartedAt = DateTime.UtcNow; /// /// Calcola l'offset ottimale per una puntata considerando tutti i fattori /// public BidTimingResult CalculateOptimalTiming(AuctionInfo auction, AppSettings settings) { var result = new BidTimingResult { BaseOffsetMs = auction.BidBeforeDeadlineMs, FinalOffsetMs = auction.BidBeforeDeadlineMs, ShouldBid = true }; // 1. Adaptive Latency Compensation if (settings.AdaptiveLatencyEnabled) { result.LatencyCompensationMs = (int)auction.AverageLatencyMs; result.FinalOffsetMs += result.LatencyCompensationMs; } // 2. Dynamic Offset (basato su heat, storico, volatilità) if (settings.DynamicOffsetEnabled) { var dynamicAdjustment = CalculateDynamicOffset(auction, settings); result.DynamicAdjustmentMs = dynamicAdjustment; result.FinalOffsetMs += dynamicAdjustment; } // 3. Jitter (randomizzazione) if (settings.JitterEnabled || (auction.JitterEnabledOverride ?? settings.JitterEnabled)) { result.JitterMs = _random.Next(-settings.JitterRangeMs, settings.JitterRangeMs + 1); result.FinalOffsetMs += result.JitterMs; } // 4. Clamp ai limiti result.FinalOffsetMs = Math.Clamp(result.FinalOffsetMs, settings.MinimumOffsetMs, settings.MaximumOffsetMs); // Salva offset calcolato nell'asta auction.DynamicOffsetMs = result.FinalOffsetMs; return result; } /// /// Calcola offset dinamico basato su heat, storico e volatilità /// private int CalculateDynamicOffset(AuctionInfo auction, AppSettings settings) { int adjustment = 0; // Più l'asta è "calda", più anticipo serve if (auction.HeatMetric > 50) { adjustment += (auction.HeatMetric - 50) / 2; // +0-25ms per heat 50-100 } // Se ci sono state collisioni recenti, anticipa di più if (auction.ConsecutiveCollisions > 0) { adjustment += auction.ConsecutiveCollisions * 10; // +10ms per ogni collisione } // Se la latenza è volatile (alta deviazione), aggiungi margine if (auction.LatencyHistory.Count >= 5) { var avg = auction.LatencyHistory.Average(); var variance = auction.LatencyHistory.Sum(x => Math.Pow(x - avg, 2)) / auction.LatencyHistory.Count; var stdDev = Math.Sqrt(variance); if (stdDev > 20) // Alta variabilità { adjustment += (int)(stdDev / 2); } } return adjustment; } /// /// Aggiorna heat metric per un'asta /// 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); } } /// /// Identifica e tracca bidder aggressivi (basato su ultime N puntate, esclude utente corrente) /// 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); } } } /// /// 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 /// 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; } } /// /// Verifica se è il caso di puntare considerando tutte le strategie /// 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; } // ?? 1. ENTRY POINT - Verifica se il prezzo è conveniente // Punta solo se prezzo < (MaxPrice * 0.7) if (settings.EntryPointEnabled && auction.MaxPrice > 0) { var entryThreshold = auction.MaxPrice * 0.7; if (state.Price >= entryThreshold) { decision.ShouldBid = false; decision.Reason = $"Entry point: €{state.Price:F2} >= 70% di max €{auction.MaxPrice:F2}"; return decision; } } // ?? 2. 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; } } // ?? 3. 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 } // 4. 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; } } return decision; } /// /// Rileva pattern bot analizzando i delta timing degli ultimi bid /// 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(); 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); } /// /// Verifica se un utente è esausto (molte puntate, può mollare) /// 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, ""); } /// /// Calcola probabilità di puntata basata su competizione e ROI /// 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); } /// /// Verifica limiti bankroll /// 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; } /// /// Registra una puntata effettuata (per tracking) /// 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++; } } /// /// Registra un timer scaduto /// public void RecordTimerExpired(AuctionInfo auction) { auction.TimerExpiredCount++; auction.ConsecutiveCollisions++; // Conta come "mancato" } /// /// Reset contatori sessione /// public void ResetSession() { _sessionTotalBids = 0; _sessionStartedAt = DateTime.UtcNow; } /// /// Ottiene statistiche sessione corrente /// public SessionStats GetSessionStats() { return new SessionStats { TotalBids = _sessionTotalBids, SessionDuration = DateTime.UtcNow - _sessionStartedAt }; } } /// /// Risultato calcolo timing puntata /// 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; } } /// /// Decisione se puntare /// public class BidDecision { public bool ShouldBid { get; set; } public string? Reason { get; set; } } /// /// Risultato verifica bankroll /// public class BankrollCheckResult { public bool CanBid { get; set; } public string? Reason { get; set; } } /// /// Statistiche sessione /// public class SessionStats { public int TotalBids { get; set; } public TimeSpan SessionDuration { get; set; } } }