using System; using System.Collections.Generic; using System.Linq; using System.Text.Json.Serialization; namespace AutoBidder.Models { /// /// Stato operativo di un'asta nel monitor. /// public enum RunState { /// Ferma: nessuna richiesta verso Bidoo. Stopped, /// Osserva: segue prezzo, scadenza e avversari, ma non punta mai. Watch, /// Attiva: segue e punta secondo le regole configurate. Active } /// /// Informazioni base di un'asta monitorata /// Solo HTTP, nessuna modalit�, browser o multi-click /// public class AuctionInfo { /// /// Numero massimo di righe di log da mantenere per ogni asta /// Ridotto per ottimizzare consumo RAM /// private const int MAX_LOG_LINES = 200; public string AuctionId { get; set; } = ""; public string Name { get; set; } = ""; // Opzionale, pu� essere lasciato vuoto public string OriginalUrl { get; set; } = ""; // URL completo dell'asta (per referer) // Configurazione asta /// /// Millisecondi prima della scadenza (deadline) per inviare la puntata. /// Es: 200ms = punta 200ms prima che il timer raggiunga 0. /// public int BidBeforeDeadlineMs { get; set; } = 200; public double MinPrice { get; set; } = 0; public double MaxPrice { get; set; } = 0; public int MinResets { get; set; } = 0; // Numero minimo reset prima di puntare public int MaxResets { get; set; } = 0; // Numero massimo reset (0 = illimitati) /// /// Numero massimo di puntate consentite per questa asta (0 = illimitato). /// Impostato dall'utente nella griglia statistiche o dai limiti prodotto. /// Controllato in ShouldBid contro BidsUsedOnThisAuction. /// [JsonPropertyName("MaxClicks")] public int MaxClicks { get; set; } = 0; /// /// Tetto di spesa in euro per questa asta, contando solo le mie puntate /// (0 = nessun tetto). È il fratello di espresso in denaro /// invece che in conteggio: chi ragiona in euro non deve fare la divisione a mente. /// public double MaxTotalSpendEuro { get; set; } = 0; /// /// Fermati quando vincere costerebbe più di quanto vale il prodotto /// (prezzo + puntate spese + spedizione contro il "Compra Subito"). /// /// Acceso di predefinito: è il controllo che distingue "vincere" da "vincere /// senza perderci". Si può spegnere per un articolo che si vuole comunque, a /// qualunque prezzo. /// public bool StopAtBreakEven { get; set; } = true; // Stato asta // IsActive/IsPaused restano la fonte serializzata (retrocompatibilità con // auctions.json e con i binding della griglia); State ne è la lettura semantica. public bool IsActive { get; set; } = true; public bool IsPaused { get; set; } = false; /// /// Stato operativo dell'asta. È una vista su IsActive/IsPaused, non un campo a parte: /// così non esistono due verità da tenere allineate. /// /// Ferma = nessuna richiesta; Osserva = segue prezzo e scadenza ma non punta mai; /// Attiva = segue e punta secondo le regole configurate. /// [JsonIgnore] public RunState State { get => !IsActive ? RunState.Stopped : IsPaused ? RunState.Watch : RunState.Active; set { IsActive = value != RunState.Stopped; IsPaused = value == RunState.Watch; } } // Contatori public int ResetCount { get; set; } = 0; /// /// Puntate residue totali dell'utente (aggiornate dopo ogni puntata su questa asta) /// [JsonPropertyName("RemainingBids")] public int? RemainingBids { get; set; } /// /// Puntate usate specificamente su questa asta (da risposta server) /// [JsonPropertyName("BidsUsedOnThisAuction")] public int? BidsUsedOnThisAuction { get; set; } // Timestamp public DateTime AddedAt { get; set; } = DateTime.UtcNow; public DateTime? LastClickAt { get; set; } // ?? NUOVO: Sistema timing basato su deadline /// /// Scadenza del ciclo corrente in tick di Stopwatch, ancorata all'orologio del server. /// È il bersaglio su cui il cecchino calcola l'istante di fuoco: a differenza di /// DeadlineUtc non dipende dall'orologio locale né dalla granularità del secondo. /// 0 = non ancora nota. /// [JsonIgnore] public long DeadlineTicks { get; set; } /// Numero di poll eseguiti su questa asta nella sessione corrente. [JsonIgnore] public long PollCount { get; set; } /// Poll falliti (nessuna risposta utilizzabile) nella sessione corrente. [JsonIgnore] public long PollErrors { get; set; } /// /// Risposte consecutive che dichiarano l'asta conclusa. Ne servono due prima di /// chiudere davvero: un singolo OFF isolato è quasi sempre un artefatto del server. /// [JsonIgnore] public int TerminalStreak { get; set; } /// /// Andamento del prezzo mentre l'asta è in corso, campionato a ogni variazione. /// /// Va raccolto adesso perché a posteriori non esiste: Bidoo non espone lo storico /// dei prezzi di un'asta conclusa. È il dato più utile per distinguere un'asta /// tranquilla da una contesa. /// [JsonIgnore] public List PriceSeries { get; } = new(); /// Quando il monitor ha visto per la prima volta questa asta. [JsonIgnore] public DateTime? FirstSeenAt { get; set; } /// Numero massimo di campioni di prezzo: un'asta lunga non deve gonfiare la memoria. private const int MaxPricePoints = 600; /// Registra un prezzo, ma solo quando cambia davvero. public void TrackPrice(double price) { if (price <= 0) return; FirstSeenAt ??= DateTime.Now; lock (PriceSeries) { if (PriceSeries.Count > 0 && Math.Abs(PriceSeries[^1].Price - price) < 0.001) return; if (PriceSeries.Count >= MaxPricePoints) { // Si dimezza la risoluzione tenendo un campione su due: la forma della // curva resta leggibile e il costo in memoria smette di crescere. var thinned = PriceSeries.Where((_, i) => i % 2 == 0).ToList(); PriceSeries.Clear(); PriceSeries.AddRange(thinned); } PriceSeries.Add(new PricePoint { T = (DateTime.Now - FirstSeenAt.Value).TotalSeconds, Price = price }); } } /// Copia della serie prezzi, utilizzabile fuori dal lock. public List SnapshotPriceSeries() { lock (PriceSeries) return PriceSeries.ToList(); } /// /// Millisecondi stimati alla scadenza, dedotti dal contatore monotono. /// double.MaxValue quando la scadenza non è ancora nota. /// public double EstimatedRemainingMs() { if (DeadlineTicks == 0) return double.MaxValue; return AutoBidder.Engine.PrecisionWait.MsUntil(DeadlineTicks); } // Storico public List BidHistory { get; set; } = new List(); /// /// Esito dell'asta secondo lo storico interno: "won", "lost", /// "closed", oppure null se non risulta conclusa. /// /// A differenza di questo dato è serializzato, quindi /// sopravvive alla chiusura dell'applicazione. È la stessa fonte che usa il /// motore per decidere se accendere un runner: senza, l'interfaccia poteva mostrare /// "Osserva" su un'asta che il motore considerava già finita e non interrogava più. /// [JsonIgnore] public string? ConcludedOutcome { get { var notes = BidHistory != null && BidHistory.Count > 0 ? BidHistory[^1].Notes : null; if (string.IsNullOrEmpty(notes)) return null; if (notes.Contains("VINTA", StringComparison.OrdinalIgnoreCase)) return "won"; if (notes.Contains("Persa", StringComparison.OrdinalIgnoreCase)) return "lost"; if (notes.Contains("Chiusa", StringComparison.OrdinalIgnoreCase)) return "closed"; return null; } } public Dictionary BidderStats { get; set; } = new(StringComparer.OrdinalIgnoreCase); /// /// Storia delle ultime puntate effettuate sull'asta (da API) /// Questa � la fonte UFFICIALE per il conteggio puntate per utente /// [JsonPropertyName("RecentBids")] public List RecentBids { get; set; } = new List(); /// /// Oggetto di sincronizzazione per . /// /// Serve un lucchetto separato perché la lista viene sostituita (ordinata, /// troncata) mentre la si aggiorna: bloccare la lista stessa significherebbe che il /// thread successivo blocca un oggetto diverso, e la mutua esclusione sparisce. /// Il motore la scrive dal proprio thread, l'interfaccia la legge dal suo. /// [JsonIgnore] public object BidsLock { get; } = new(); /// /// Copia della cronologia utilizzabile fuori dal lock. È l'unico modo corretto per /// leggerla dall'interfaccia mentre il motore continua ad aggiornarla. /// public List SnapshotRecentBids(int max = 0) { lock (BidsLock) { if (RecentBids == null || RecentBids.Count == 0) return new List(); return max > 0 && RecentBids.Count > max ? RecentBids.Take(max).ToList() : RecentBids.ToList(); } } /// /// Copia delle statistiche per puntatore. Sono derivate da /// e aggiornate insieme a essa, quindi condividono lo stesso lucchetto: enumerare il /// dizionario mentre il motore lo riscrive sollevrebbe un'eccezione. /// public List SnapshotBidderStats() { lock (BidsLock) { if (BidderStats == null || BidderStats.Count == 0) return new List(); return BidderStats.Values.OrderByDescending(b => b.BidCount).ToList(); } } // Log per-asta strutturato (non serializzato) [System.Text.Json.Serialization.JsonIgnore] public List AuctionLog { get; set; } = new(); /// /// Osservatore globale delle righe di registro delle aste. Serve al dossier su file: /// le righe nascono in una quarantina di punti diversi del motore, e agganciarsi qui /// è l'unico modo per prenderle tutte senza dover ricordare di scrivere due volte /// ogni volta che se ne aggiunge una. /// /// Statico perché il modello non conosce né i percorsi né le impostazioni: chi /// lo collega decide dove finiscono le righe, e se non lo collega nessuno non /// succede nulla. /// [System.Text.Json.Serialization.JsonIgnore] public static Action? LogSink { get; set; } private void PublishToSink(AuctionLogEntry entry) { try { LogSink?.Invoke(this, entry); } catch { /* un osservatore rotto non deve fermare il motore */ } } // Flag runtime: indica che � in corso un'operazione di final attack per questa asta [System.Text.Json.Serialization.JsonIgnore] public bool IsAttackInProgress { get; set; } = false; // === INFORMAZIONI PRODOTTO PER CALCOLO VALORE === /// /// Prezzo "Compra Subito" del prodotto (valore nominale) /// public double? BuyNowPrice { get; set; } /// /// Spese di spedizione per questo prodotto /// public double? ShippingCost { get; set; } /// /// Indica se c'� un limite di vincita per questo prodotto (1 volta ogni X giorni) /// public bool HasWinLimit { get; set; } = false; /// /// Descrizione del limite di vincita (es: "1 volta ogni 30 giorni") /// public string? WinLimitDescription { get; set; } /// /// Costo medio per puntata da utilizzare nei calcoli (default: 0.20�) /// public double BidCost { get; set; } = 0.20; /// /// Ultimo valore calcolato del prodotto (costo reale considerando puntate) /// Null se non ancora calcolato /// [JsonIgnore] public ProductValue? CalculatedValue { get; set; } /// /// Ultimo stato ricevuto dal monitor per questa asta /// [JsonIgnore] public AuctionState? LastState { get; set; } /// /// Aggiunge una voce strutturata al log dell'asta con deduplicazione e limite righe. /// Parsifica automaticamente il tag [TAG] per determinare livello e categoria. /// public void AddLog(string message, int maxLines = 200) { // Protezione null-safety (dopo ClearData) if (AuctionLog == null) AuctionLog = new(); var now = DateTime.Now; // Parsifica tag dal messaggio per determinare livello e categoria var (level, category, cleanMessage) = ParseLogTag(message); // DEDUPLICAZIONE: Se l'ultimo messaggio � uguale, incrementa contatore if (AuctionLog.Count > 0) { var last = AuctionLog[^1]; if (last.Message == cleanMessage && last.Category == category) { last.RepeatCount++; last.Timestamp = now; return; } } var entry = new AuctionLogEntry { Timestamp = now, Level = level, Category = category, Message = cleanMessage }; AuctionLog.Add(entry); PublishToSink(entry); if (AuctionLog.Count > maxLines) { AuctionLog.RemoveRange(0, AuctionLog.Count - maxLines); } } /// /// Aggiunge una voce tipizzata al log dell'asta (senza parsing del tag). /// public void AddLog(string message, AuctionLogLevel level, AuctionLogCategory category) { // Protezione null-safety (dopo ClearData) if (AuctionLog == null) AuctionLog = new(); var now = DateTime.Now; if (AuctionLog.Count > 0) { var last = AuctionLog[^1]; if (last.Message == message && last.Category == category) { last.RepeatCount++; last.Timestamp = now; return; } } var entry = new AuctionLogEntry { Timestamp = now, Level = level, Category = category, Message = message }; AuctionLog.Add(entry); PublishToSink(entry); if (AuctionLog.Count > MAX_LOG_LINES) { AuctionLog.RemoveRange(0, AuctionLog.Count - MAX_LOG_LINES); } } /// /// Parsifica i tag [TAG] per determinare livello e categoria automaticamente. /// private static (AuctionLogLevel level, AuctionLogCategory category, string cleanMessage) ParseLogTag(string message) { // Cerca pattern [TAG] all'inizio del messaggio var tagMatch = System.Text.RegularExpressions.Regex.Match(message, @"^\[([A-Z_ ]+)\]\s*(.*)$"); if (!tagMatch.Success) return (AuctionLogLevel.Info, AuctionLogCategory.General, message); var tag = tagMatch.Groups[1].Value.Trim(); var cleanMsg = tagMatch.Groups[2].Value; return tag switch { // Bid/puntata "BID" => (AuctionLogLevel.Bid, AuctionLogCategory.BidAttempt, cleanMsg), "BID OK" => (AuctionLogLevel.Success, AuctionLogCategory.BidResult, cleanMsg), "BID FAIL" => (AuctionLogLevel.Error, AuctionLogCategory.BidResult, cleanMsg), "BID EXCEPTION" => (AuctionLogLevel.Error, AuctionLogCategory.BidResult, cleanMsg), "MANUAL BID" => (AuctionLogLevel.Bid, AuctionLogCategory.BidAttempt, cleanMsg), "MANUAL BID OK" => (AuctionLogLevel.Success, AuctionLogCategory.BidResult, cleanMsg), "MANUAL BID FAIL" => (AuctionLogLevel.Error, AuctionLogCategory.BidResult, cleanMsg), // Timing "TICKER" => (AuctionLogLevel.Timing, AuctionLogCategory.Ticker, cleanMsg), "TIMING" or "\u26a0\ufe0f TIMING" => (AuctionLogLevel.Warning, AuctionLogCategory.Ticker, cleanMsg), // Prezzi/limiti "PRICE" => (AuctionLogLevel.Warning, AuctionLogCategory.Price, cleanMsg), "VALUE" => (AuctionLogLevel.Warning, AuctionLogCategory.Value, cleanMsg), "LIMIT" => (AuctionLogLevel.Warning, AuctionLogCategory.Limit, cleanMsg), // Reset var r when r.StartsWith("RESET") => (AuctionLogLevel.Info, AuctionLogCategory.Reset, cleanMsg), // Strategie "STRATEGY" => (AuctionLogLevel.Strategy, AuctionLogCategory.Strategy, cleanMsg), "COMPETITION" => (AuctionLogLevel.Strategy, AuctionLogCategory.Competition, cleanMsg), // Diagnostica "DIAG" => (AuctionLogLevel.Debug, AuctionLogCategory.Diagnostic, cleanMsg), "DEBUG" => (AuctionLogLevel.Debug, AuctionLogCategory.General, cleanMsg), // Stato "START" => (AuctionLogLevel.Info, AuctionLogCategory.Status, cleanMsg), "ASTA TERMINATA" => (AuctionLogLevel.Warning, AuctionLogCategory.Status, cleanMsg), "\u26a0\ufe0f SUGGERIMENTO" => (AuctionLogLevel.Warning, AuctionLogCategory.Ticker, cleanMsg), // Polling "POLL ERROR" => (AuctionLogLevel.Error, AuctionLogCategory.Polling, cleanMsg), // Errori generici "ERROR" or "ERRORE" => (AuctionLogLevel.Error, AuctionLogCategory.General, cleanMsg), "WARN" => (AuctionLogLevel.Warning, AuctionLogCategory.General, cleanMsg), "OK" => (AuctionLogLevel.Success, AuctionLogCategory.General, cleanMsg), _ => (AuctionLogLevel.Info, AuctionLogCategory.General, message) }; } public int PollingLatencyMs { get; set; } = 0; // Ultima latenza polling ms // ??????????????????????????????????????????????????????????????? // TRACKING AVANZATO PER STRATEGIE // ??????????????????????????????????????????????????????????????? /// /// Storico latenze ultime N misurazioni (per media mobile) /// [JsonIgnore] public List LatencyHistory { get; set; } = new(); /// /// Numero massimo di latenze da memorizzare (ridotto per RAM) /// private const int MAX_LATENCY_HISTORY = 10; /// /// Aggiunge una misurazione di latenza allo storico /// public void AddLatencyMeasurement(int latencyMs) { LatencyHistory.Add(latencyMs); if (LatencyHistory.Count > MAX_LATENCY_HISTORY) LatencyHistory.RemoveAt(0); PollingLatencyMs = latencyMs; } /// /// Latenza media calcolata sullo storico /// [JsonIgnore] public double AverageLatencyMs => LatencyHistory.Count > 0 ? LatencyHistory.Average() : PollingLatencyMs > 0 ? PollingLatencyMs : 60; /// /// Heat metric (0-100) che indica quanto � "calda" l'asta /// Calcolato in base a: bidder attivi, frequenza puntate, collisioni /// [JsonIgnore] public int HeatMetric { get; set; } = 0; /// /// Numero di bidder unici attivi negli ultimi N secondi /// [JsonIgnore] public int ActiveBiddersCount { get; set; } = 0; /// /// Numero di collisioni rilevate (puntate nello stesso secondo) /// [JsonIgnore] public int CollisionCount { get; set; } = 0; /// /// Collisioni consecutive senza puntata vincente /// [JsonIgnore] public int ConsecutiveCollisions { get; set; } = 0; /// /// Timestamp dell'ultimo soft retreat /// [JsonIgnore] public DateTime? LastSoftRetreatAt { get; set; } /// /// Se true, l'asta � in soft retreat temporaneo /// [JsonIgnore] public bool IsInSoftRetreat { get; set; } = false; /// /// Contatore puntate effettuate in questa sessione su questa asta /// [JsonIgnore] public int SessionBidCount { get; set; } = 0; /// /// Numero di volte che il timer � scaduto prima della puntata /// [JsonIgnore] public int TimerExpiredCount { get; set; } = 0; /// /// Numero di puntate riuscite /// [JsonIgnore] public int SuccessfulBidCount { get; set; } = 0; /// /// Numero di puntate fallite /// [JsonIgnore] public int FailedBidCount { get; set; } = 0; /// /// Lista utenti identificati come aggressivi in questa asta /// [JsonIgnore] public HashSet AggressiveBidders { get; set; } = new(StringComparer.OrdinalIgnoreCase); /// /// Offset dinamico calcolato per questa asta (ms) /// [JsonIgnore] public int DynamicOffsetMs { get; set; } = 150; /// /// Offset effettivo usato nell'ultima puntata (include jitter) /// [JsonIgnore] public int LastUsedOffsetMs { get; set; } = 0; /// /// Indica se questa asta � stata seguita dall'inizio (per salvare storia completa) /// public bool IsTrackedFromStart { get; set; } = false; /// /// True se il monitor c'era prima che l'asta partisse: l'ha vista programmata, /// oppure al primo centesimo, cioè prima di qualunque puntata. /// /// Da non confondere con , che dice soltanto /// "il monitoraggio è cominciato": un'asta aggiunta a metà corsa lo mette a true lo /// stesso. Qui invece la domanda è un'altra — questi dati raccontano tutta la storia /// o solo la fine? — ed è quella che decide se un'asta vale come materiale di /// analisi. /// public bool ObservedFromStart { get; set; } /// True se la conclusione è stata vista dal vivo, non dedotta dopo. public bool ObservedToEnd { get; set; } /// /// Quando l'asta è finita davvero. /// /// Serve perché il momento in cui si registra un'asta conclusa non coincide /// con quello in cui è finita: un'asta terminata resta nel monitor finché non la si /// toglie, e senza questo campo lo storico segnerebbe come data di chiusura l'ora /// della rimozione — magari il giorno dopo. /// public DateTime? ConcludedAt { get; set; } /// /// Timestamp di inizio tracking /// public DateTime? TrackingStartedAt { get; set; } // ??????????????????????????????????????????????????????????????? // IMPOSTAZIONI PER-ASTA (override globali) // ??????????????????????????????????????????????????????????????? /// /// Override: abilita/disabilita strategie avanzate per questa asta /// null = usa impostazione globale /// public bool? AdvancedStrategiesEnabled { get; set; } /// /// Override: abilita/disabilita jitter per questa asta /// public bool? JitterEnabledOverride { get; set; } /// /// Override: abilita/disabilita soft retreat per questa asta /// public bool? SoftRetreatEnabledOverride { get; set; } /// /// Override: limite puntate per questa asta /// public int? MaxBidsOverride { get; set; } // ?? NUOVO: Rilevamento situazione di duello /// /// True se rilevata situazione di duello (solo 2 bidder dominanti) /// [JsonIgnore] public bool IsDuelSituation { get; set; } = false; /// /// Username dell'avversario in caso di duello /// [JsonIgnore] public string? DuelOpponent { get; set; } /// /// Vantaggio/svantaggio nel duello (% puntate mie - % puntate avversario) /// Positivo = sto dominando, Negativo = sto perdendo /// [JsonIgnore] public double DuelAdvantage { get; set; } = 0; // ??????????????????????????????????????????????????????????????????? // GESTIONE MEMORIA // ??????????????????????????????????????????????????????????????????? /// /// Pulisce tutti i dati in memoria dell'asta per liberare RAM. /// Chiamare prima di rimuovere l'asta dalla lista. /// public void ClearData() { // Pulisci liste storiche BidHistory?.Clear(); BidHistory = null!; RecentBids?.Clear(); RecentBids = null!; AuctionLog?.Clear(); AuctionLog = null!; BidderStats?.Clear(); BidderStats = null!; LatencyHistory?.Clear(); LatencyHistory = null!; AggressiveBidders?.Clear(); AggressiveBidders = null!; // Pulisci oggetti complessi LastState = null; CalculatedValue = null; DuelOpponent = null; WinLimitDescription = null; // Reset flag IsTrackedFromStart = false; TrackingStartedAt = null; DeadlineTicks = 0; } /// /// Compatta i dati mantenendo solo le informazioni recenti. /// Utile per ridurre la memoria senza eliminare completamente i dati. /// public void CompactData(int maxBidHistory = 50, int maxRecentBids = 30, int maxLogLines = 100) { // Compatta BidHistory if (BidHistory != null && BidHistory.Count > maxBidHistory) { var recent = BidHistory.TakeLast(maxBidHistory).ToList(); BidHistory.Clear(); BidHistory.AddRange(recent); BidHistory.TrimExcess(); } // Compatta RecentBids if (RecentBids != null && RecentBids.Count > maxRecentBids) { var recent = RecentBids.TakeLast(maxRecentBids).ToList(); RecentBids.Clear(); RecentBids.AddRange(recent); RecentBids.TrimExcess(); } // Compatta AuctionLog if (AuctionLog != null && AuctionLog.Count > maxLogLines) { var recent = AuctionLog.TakeLast(maxLogLines).ToList(); AuctionLog.Clear(); AuctionLog.AddRange(recent); AuctionLog.TrimExcess(); } // Compatta LatencyHistory if (LatencyHistory != null && LatencyHistory.Count > 10) { var recent = LatencyHistory.TakeLast(10).ToList(); LatencyHistory.Clear(); LatencyHistory.AddRange(recent); LatencyHistory.TrimExcess(); } // Compatta BidderStats - mantieni solo i top bidders if (BidderStats != null && BidderStats.Count > 20) { var topBidders = BidderStats .OrderByDescending(kv => kv.Value.BidCount) .Take(20) .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase); BidderStats.Clear(); foreach (var kv in topBidders) BidderStats[kv.Key] = kv.Value; } } } /// /// Rappresenta il valore calcolato di un prodotto all'asta /// public class ProductValue { /// /// Prezzo attuale dell'asta in euro /// public double CurrentPrice { get; set; } /// /// Numero totale di puntate effettuate sull'asta /// public int TotalBids { get; set; } /// /// Numero di puntate effettuate dall'utente /// public int MyBids { get; set; } /// /// Costo delle puntate dell'utente (MyBids � BidCost) /// public double MyBidsCost { get; set; } /// /// Costo totale per l'utente se vince (CurrentPrice + MyBidsCost + ShippingCost) /// public double TotalCostIfWin { get; set; } /// /// Prezzo "Compra Subito" del prodotto /// public double? BuyNowPrice { get; set; } /// /// Spese di spedizione /// public double? ShippingCost { get; set; } /// /// Risparmio rispetto al prezzo "Compra Subito" (pu� essere negativo) /// public double? Savings { get; set; } /// /// Percentuale di risparmio rispetto al prezzo "Compra Subito" /// public double? SavingsPercentage { get; set; } /// /// Indica se conviene continuare (risparmio positivo) /// public bool IsWorthIt { get; set; } /// /// Timestamp del calcolo /// public DateTime CalculatedAt { get; set; } /// /// Messaggio riassuntivo del valore /// public string Summary { get; set; } = ""; } }