diff --git a/Mimante/Models/AuctionInfo.cs b/Mimante/Models/AuctionInfo.cs index 46e9f30..58c7e00 100644 --- a/Mimante/Models/AuctionInfo.cs +++ b/Mimante/Models/AuctionInfo.cs @@ -565,6 +565,47 @@ namespace AutoBidder.Models /// [JsonIgnore] public bool IsInSoftRetreat { get; set; } = false; + + // ── Duello con l'autopuntata avversaria ────────────────────────── + // Vedi Utilities/AutoBidDuel per il perche' e per i numeri misurati. + + /// Istante dell'ultima puntata nostra, per misurare quanto tarda la risposta. + [JsonIgnore] + public DateTime? LastMyBidAtUtc { get; set; } + + /// + /// Risposte avversarie di fila con la firma dell'autopuntata (~6 s). Oltre la + /// soglia si smette di puntare su quest'asta: vedi . + /// + [JsonIgnore] + public int AutoResponsesInARow { get; set; } + + /// Riconosciuta un'autopuntata armata dall'altra parte. + [JsonIgnore] + public bool AutoBidDuelDetected { get; set; } + + /// + /// Registra la risposta avversaria arrivata adesso e aggiorna il conteggio. + /// Restituisce true se proprio con questa risposta il duello viene riconosciuto, + /// cosi' chi chiama puo' scriverlo nel registro una volta sola. + /// + public bool NoteOpponentResponse(DateTime nowUtc, int soglia) + { + if (LastMyBidAtUtc is not { } miaUltima) return false; + + var ritardo = (nowUtc - miaUltima).TotalSeconds; + + // Consumata: la prossima risposta va misurata dalla prossima puntata nostra. + LastMyBidAtUtc = null; + + AutoResponsesInARow = Utilities.AutoBidDuel.Aggiorna(AutoResponsesInARow, ritardo); + + if (AutoBidDuelDetected) return false; + if (!Utilities.AutoBidDuel.DuelloRiconosciuto(AutoResponsesInARow, soglia)) return false; + + AutoBidDuelDetected = true; + return true; + } /// /// Contatore puntate effettuate in questa sessione su questa asta diff --git a/Mimante/Models/BidHistoryEntry.cs b/Mimante/Models/BidHistoryEntry.cs index 72b8502..6fd6207 100644 --- a/Mimante/Models/BidHistoryEntry.cs +++ b/Mimante/Models/BidHistoryEntry.cs @@ -14,11 +14,37 @@ namespace AutoBidder.Models [JsonPropertyName("Price")] public decimal Price { get; set; } + /// Puntata fatta dall'autopuntata del sito: scatta a 2 secondi dalla fine. + public const string TipoAuto = "Auto"; + + /// Puntata fatta a mano. Vale 3,6 volte una automatica: vedi . + public const string TipoManuale = "Manuale"; + /// - /// Tipo di puntata (Auto/Manuale) + /// Il tipo non lo ha detto il server. + /// + /// Serve perché non tutte le puntate in elenco vengono dallo storico di + /// data.php: quando il motore si accorge di un cambio di prezzo fra due + /// interrogazioni ne ricostruisce una da sé, e di quella il tipo non lo sa. Prima + /// venivano marcate "Auto" comunque, il che faceva contare come automatiche delle + /// puntate su cui nessuno aveva detto niente. + /// + public const string TipoSconosciuto = "—"; + + /// + /// Tipo di puntata: , oppure + /// . Lo dichiara Bidoo nello storico dell'asta. /// [JsonPropertyName("BidType")] - public string BidType { get; set; } = "Auto"; + public string BidType { get; set; } = TipoSconosciuto; + + /// Vero solo se il server ha davvero detto che era automatica. + [JsonIgnore] + public bool IsAuto => string.Equals(BidType, TipoAuto, StringComparison.OrdinalIgnoreCase); + + /// Vero solo se il server ha davvero detto che era manuale. + [JsonIgnore] + public bool IsManual => string.Equals(BidType, TipoManuale, StringComparison.OrdinalIgnoreCase); /// /// Timestamp della puntata (Unix timestamp) @@ -55,7 +81,7 @@ namespace AutoBidder.Models } /// - /// Indica se la puntata è stata fatta dall'utente corrente + /// Indica se la puntata � stata fatta dall'utente corrente /// [JsonIgnore] public bool IsMyBid { get; set; } diff --git a/Mimante/Services/AuctionMonitor.cs b/Mimante/Services/AuctionMonitor.cs index a5067ed..b6e514b 100644 --- a/Mimante/Services/AuctionMonitor.cs +++ b/Mimante/Services/AuctionMonitor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -620,7 +620,7 @@ namespace AutoBidder.Services Username = state.LastBidder, Price = statePrice, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - BidType = "Auto" + BidType = BidHistoryEntry.TipoSconosciuto }); } } @@ -1193,10 +1193,20 @@ namespace AutoBidder.Services Username = currentBidder, Price = statePrice, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - BidType = "Auto" + BidType = BidHistoryEntry.TipoSconosciuto }); } + // Una puntata avversaria: se arriva col ritardo tipico dell'autopuntata + // (~6 s dalla nostra) alimenta il conteggio del duello. + var impostazioni = SettingsManager.Load(); + if (impostazioni.AutoBidDuelWithdrawEnabled && + auction.NoteOpponentResponse(DateTime.UtcNow, impostazioni.AutoBidDuelResponses)) + { + auction.AddLog(AutoBidDuel.Spiegazione(auction.AutoResponsesInARow), + AuctionLogLevel.Warning, AuctionLogCategory.Strategy); + } + // I conteggi per utente restano a carico di UpdateBidderStatsFromRecentBids, // unico punto che li tocca: contarli anche qui li raddoppierebbe. } diff --git a/Mimante/Services/BidStrategyService.cs b/Mimante/Services/BidStrategyService.cs index c13f9f9..26bcf44 100644 --- a/Mimante/Services/BidStrategyService.cs +++ b/Mimante/Services/BidStrategyService.cs @@ -169,7 +169,27 @@ namespace AutoBidder.Services { return decision; } - + + // 0. DUELLO CON L'AUTOPUNTATA — il primo controllo, perché è quello che + // costa di più sbagliare. + // + // L'autopuntata del sito scatta a 2 secondi e si riarma ogni volta che il suo + // padrone perde la testa: risponde sempre, risponde in ~6 secondi, e non si + // stanca. Contro di lei non esiste attrito che si possa vincere — si + // scambiano puntate una a una finché uno dei due esaurisce il credito. + // + // Nelle prove reali il motore ci ha lasciato 191 puntate su tre aste per zero + // vittorie, con un ciclo regolare da 13,0 secondi (7 di attesa nostra più 6 + // della sua risposta). Rigiocando i dossier con questa regola, quelle aste + // sarebbero costate una manciata di puntate senza perdere nessuna vittoria, + // perché vittorie non ce n'erano. + if (settings.AutoBidDuelWithdrawEnabled && auction.AutoBidDuelDetected) + { + decision.ShouldBid = false; + decision.Reason = Utilities.AutoBidDuel.Spiegazione(auction.AutoResponsesInARow); + 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%! @@ -475,6 +495,11 @@ namespace AutoBidder.Services { auction.SuccessfulBidCount++; auction.ConsecutiveCollisions = 0; + + // Da qui si misura quanto tarda la risposta avversaria: e' l'unico punto + // attraversato sia dal motore dal vivo sia dalla rigiocata sui dossier, + // quindi la regola del duello vede le stesse cose in entrambi. + auction.LastMyBidAtUtc = DateTime.UtcNow; } else { diff --git a/Mimante/Tests/AutoBidDuelTests.cs b/Mimante/Tests/AutoBidDuelTests.cs new file mode 100644 index 0000000..f9b0c9d --- /dev/null +++ b/Mimante/Tests/AutoBidDuelTests.cs @@ -0,0 +1,136 @@ +using System; +using AutoBidder.Models; +using AutoBidder.Utilities; +using Xunit; + +namespace AutoBidder.Tests; + +/// +/// Riconoscimento dell'autopuntata avversaria. +/// +/// La proprieta' da difendere non e' "riconosce le macchine": e' che non si ritira +/// da un'asta che si poteva vincere. Sbagliare in quel verso costa un'asta; sbagliare +/// nell'altro costa cinque puntate. Da qui la scelta di azzerare il conteggio a ogni +/// risposta fuori firma invece di scalarlo. +/// +/// I numeri vengono dai dossier: 2.799.681 intervalli fra puntate consecutive in 5037 +/// aste, mediana 6,0 s, quartili 5,3 e 7,1 s. +/// +public class AutoBidDuelTests +{ + [Theory] + [InlineData(6.0, true)] // la mediana misurata + [InlineData(5.3, true)] // primo quartile + [InlineData(7.1, true)] // terzo quartile + [InlineData(3.4, false)] // troppo svelta: qualcuno che stava guardando + [InlineData(13.0, false)] // troppo lenta + [InlineData(0.0, false)] // nessun ritardo: non e' una risposta + [InlineData(-2.0, false)] // prima della nostra puntata: non e' una risposta + public void La_firma_e_sei_secondi_piu_o_meno_due_e_mezzo(double ritardo, bool atteso) + { + Assert.Equal(atteso, AutoBidDuel.ERispostaAutomatica(ritardo)); + } + + [Fact] + public void Le_risposte_in_firma_si_sommano() + { + var n = 0; + for (var i = 0; i < 5; i++) n = AutoBidDuel.Aggiorna(n, 6.0); + + Assert.Equal(5, n); + Assert.True(AutoBidDuel.DuelloRiconosciuto(n)); + } + + [Fact] + public void Una_risposta_fuori_firma_azzera_tutto() + { + // Il punto della regola: quattro indizi piu' uno contrario non fanno quattro + // indizi. Serve una sequenza pulita, perche' ritirarsi per sbaglio costa l'asta. + var n = 0; + for (var i = 0; i < 4; i++) n = AutoBidDuel.Aggiorna(n, 6.0); + Assert.Equal(4, n); + + n = AutoBidDuel.Aggiorna(n, 2.0); + + Assert.Equal(0, n); + Assert.False(AutoBidDuel.DuelloRiconosciuto(n)); + } + + [Fact] + public void Sotto_la_soglia_non_ci_si_ritira() + { + var n = 0; + for (var i = 0; i < 4; i++) n = AutoBidDuel.Aggiorna(n, 6.0); + + Assert.False(AutoBidDuel.DuelloRiconosciuto(n)); + } + + // ── Lo stato sull'asta ─────────────────────────────────────────────── + + private static AuctionInfo Asta() => new() { AuctionId = "1", Name = "prova" }; + + [Fact] + public void Il_duello_si_riconosce_una_volta_sola() + { + var a = Asta(); + var t = DateTime.UtcNow; + + var riconosciuto = 0; + for (var i = 0; i < 8; i++) + { + a.LastMyBidAtUtc = t; + t = t.AddSeconds(6); + if (a.NoteOpponentResponse(t, 5)) riconosciuto++; + t = t.AddSeconds(7); + } + + Assert.True(a.AutoBidDuelDetected); + Assert.Equal(1, riconosciuto); // non una volta per ogni risposta successiva + } + + [Fact] + public void Senza_una_nostra_puntata_non_si_misura_niente() + { + // Una risposta avversaria che non risponde a noi non dice nulla sulla loro + // autopuntata: puo' essere un terzo che punta per conto suo. + var a = Asta(); + + Assert.False(a.NoteOpponentResponse(DateTime.UtcNow, 5)); + Assert.Equal(0, a.AutoResponsesInARow); + Assert.False(a.AutoBidDuelDetected); + } + + [Fact] + public void Ogni_risposta_si_misura_dalla_puntata_sua() + { + // Consumato il riferimento, una seconda risposta di fila non deve contare due + // volte lo stesso intervallo. + var a = Asta(); + var t = DateTime.UtcNow; + + a.LastMyBidAtUtc = t; + a.NoteOpponentResponse(t.AddSeconds(6), 5); + Assert.Equal(1, a.AutoResponsesInARow); + + a.NoteOpponentResponse(t.AddSeconds(12), 5); + Assert.Equal(1, a.AutoResponsesInARow); + } + + [Fact] + public void Un_avversario_umano_irregolare_non_viene_scambiato_per_macchina() + { + var a = Asta(); + var t = DateTime.UtcNow; + + // Ritardi veri, presi dalla distribuzione misurata fuori dalla firma. + foreach (var ritardo in new[] { 6.0, 3.1, 6.2, 9.4, 5.9, 2.2, 6.0 }) + { + a.LastMyBidAtUtc = t; + t = t.AddSeconds(ritardo); + a.NoteOpponentResponse(t, 5); + t = t.AddSeconds(7); + } + + Assert.False(a.AutoBidDuelDetected); + } +} diff --git a/Mimante/Utilities/AutoBidDuel.cs b/Mimante/Utilities/AutoBidDuel.cs new file mode 100644 index 0000000..df1dad5 --- /dev/null +++ b/Mimante/Utilities/AutoBidDuel.cs @@ -0,0 +1,80 @@ +using System; + +namespace AutoBidder.Utilities +{ + /// + /// Riconosce l'avversario che ha l'autopuntata di Bidoo armata, e dice quando smettere. + /// + /// Il meccanismo. L'autopuntata del sito scatta a 2 secondi dalla chiusura, + /// e si riarma ogni volta che il suo padrone non è più in testa. Il timer si azzera + /// intorno agli 8 secondi, quindi la risposta arriva circa 6 secondi dopo la puntata che + /// l'ha svegliata. Misurato su 2.799.681 intervalli fra puntate consecutive in 5037 aste: + /// mediana 6,0 s, primo quartile 5,3 s, terzo quartile 7,1 s. + /// + /// Perché serve ritirarsi. Contro un'autopuntata armata non esiste attrito + /// che si possa vincere: risponde sempre, risponde in sei secondi, non dorme e non si + /// distrae. Puntare di nuovo significa comprare un altro giro di sei secondi al prezzo + /// di una puntata a testa, all'infinito, finché uno dei due esaurisce il credito. Nelle + /// prove reali sono state 191 puntate su tre aste per zero vittorie, con un ciclo + /// perfettamente regolare da 13,0 secondi: 7 di attesa nostra più 6 della loro risposta. + /// + /// Perché non basta puntare tardi. Puntare a un secondo dalla chiusura non + /// evita l'autopuntata avversaria: la sveglia. Lei scatta quando il suo padrone perde la + /// testa della classifica, non a un orario fisso, quindi ogni nostra puntata tardiva è + /// esattamente ciò che la fa ripartire. + /// + /// Classe pura, senza stato e senza orologio: prende i numeri e restituisce un + /// giudizio. Così la stessa identica logica serve il motore dal vivo e la rigiocata sui + /// dossier, senza due versioni che divergono. + /// + public static class AutoBidDuel + { + /// Ritardo tipico della risposta automatica, in secondi. + public const double RispostaTipicaSecondi = 6.0; + + /// + /// Quanto ci si allontana dai 6 secondi continuando a chiamarla automatica. + /// Coincide grosso modo con l'intervallo fra primo e terzo quartile misurati + /// (5,3–7,1 s), allargato per la latenza di rete e per l'imprecisione del polling. + /// + public const double TolleranzaSecondi = 2.5; + + /// + /// Risposte automatiche di fila oltre le quali si smette. Cinque perché è il punto in + /// cui la coincidenza smette di essere credibile: cinque intervalli indipendenti + /// dentro la finestra dei 6±2,5 s non capitano per caso, e cinque puntate sono un + /// prezzo che si può pagare per avere la certezza. + /// + public const int RispostePerConcludere = 5; + + /// + /// Questo intervallo ha la firma della risposta automatica? + /// + public static bool ERispostaAutomatica(double secondiDopoLaMiaPuntata) => + secondiDopoLaMiaPuntata > 0 && + Math.Abs(secondiDopoLaMiaPuntata - RispostaTipicaSecondi) <= TolleranzaSecondi; + + /// + /// Il conteggio aggiornato dopo aver visto una risposta avversaria. + /// + /// Una risposta che non ha la firma azzera il conteggio invece di + /// scalarlo: serve una sequenza pulita per concludere che c'è una macchina + /// dall'altra parte, e un solo intervallo umano in mezzo basta a rimettere tutto in + /// discussione. È la scelta prudente, perché l'errore costoso è ritirarsi da + /// un'asta che si poteva vincere. + /// + public static int Aggiorna(int consecutiveFinora, double secondiDopoLaMiaPuntata) => + ERispostaAutomatica(secondiDopoLaMiaPuntata) ? consecutiveFinora + 1 : 0; + + /// + /// Si è capito che dall'altra parte c'è un'autopuntata armata? + /// + public static bool DuelloRiconosciuto(int consecutive, int soglia = RispostePerConcludere) => + consecutive >= Math.Max(1, soglia); + + /// Come spiegarlo nel registro, in una riga. + public static string Spiegazione(int consecutive) => + $"autopuntata avversaria riconosciuta: {consecutive} risposte di fila a ~{RispostaTipicaSecondi:N0}s. " + + "Continuare significa scambiare puntate una a una con una macchina che non si stanca."; + } +} diff --git a/Mimante/Utilities/SettingsManager.cs b/Mimante/Utilities/SettingsManager.cs index c148212..3c2df4e 100644 --- a/Mimante/Utilities/SettingsManager.cs +++ b/Mimante/Utilities/SettingsManager.cs @@ -38,7 +38,21 @@ namespace AutoBidder.Utilities /// dossier non possono dire di piu', perche' Bidoo dichiara la scadenza al secondo /// intero. /// - public int DefaultBidBeforeDeadlineMs { get; set; } = 1000; + /// + /// Aggiornato a 500 ms. Le due ragioni per stare larghi sono cadute + /// entrambe. La prima — il p99,9 del ping a 444 ms — era misurata quando il tetto di + /// 40 richieste al secondo faceva accodare le chiamate: con il tetto tolto il ping + /// misurato sui dossier nuovi sta fra 47 e 78 ms, mediana 55. La seconda era la + /// paura della concorrenza fitta sotto i due secondi, ma i tipi di puntata dicono + /// il contrario: il 75,3% delle puntate avversarie è automatica e scatta a 2 s + /// esatti, quindi sotto quella soglia resta solo chi punta a mano. + /// + /// A 500 ms la puntata parte con circa nove volte il ping di margine, e cade + /// nella metà di secondo in cui l'autopuntata del sito ha già sparato. Più in là non + /// conviene andare: Bidoo dichiara la scadenza al secondo intero, quindi sotto i + /// 500 ms si starebbe scommettendo sull'arrotondamento. + /// + public int DefaultBidBeforeDeadlineMs { get; set; } = 500; public double DefaultMinPrice { get; set; } = 0; public double DefaultMaxPrice { get; set; } = 0; public int DefaultMaxClicks { get; set; } = 0; @@ -546,6 +560,19 @@ namespace AutoBidder.Utilities /// Default: false (DISABILITATO - non blocca mai) /// public bool HardcodedAntiCollisionEnabled { get; set; } = false; + + /// + /// Ritirarsi quando dall'altra parte c'è un'autopuntata armata. Acceso di serie. + /// Vedi per il meccanismo e i numeri misurati. + /// + public bool AutoBidDuelWithdrawEnabled { get; set; } = true; + + /// + /// Risposte automatiche di fila oltre le quali ci si ritira. Predefinito 5. + /// Più basso risparmia prima ma rischia di scambiare per macchina un avversario + /// umano regolare; più alto è prudente e costa puntate. + /// + public int AutoBidDuelResponses { get; set; } = AutoBidDuel.RispostePerConcludere; // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥 // RILEVAMENTO COMPETIZIONE E HEAT METRIC