From 9b94e2d405917609f304c6f4f8105d457d916ca9 Mon Sep 17 00:00:00 2001 From: Alberto Balbo Date: Fri, 7 Aug 2026 15:20:34 +0200 Subject: [PATCH] Ritirati dai duelli con l'autopuntata, e punta a mezzo secondo La strategia A, dai numeri dei dossier: 5410 aste, 2,08 milioni di puntate. Bidoo dichiara per ogni puntata se e' automatica o manuale. Le automatiche sono il 75,3% del totale ma vincono il 46,0% delle aste; le manuali sono il 24,7% e ne vincono il 53,9%. Una puntata manuale vale 3,6 volte una automatica: 178 puntate per vittoria contro 634. Il meccanismo del duello e' stato ricostruito esattamente. L'intervallo mediano fra due puntate consecutive e' 6,0 s su 2.799.681 intervalli: il timer si azzera a ~8 s e l'autopuntata scatta a 2 s, quindi 8-2=6. Il ciclo da 13,0 s osservato nelle prove reali era la somma di 7 s di attesa nostra e 6 s della sua risposta. Puntare tardi non evita l'autopuntata avversaria: la sveglia, perche' si riarma quando il suo padrone perde la testa della classifica, non a un orario fisso. Contro una macchina cosi' non esiste attrito vincibile. Adesso il motore conta le risposte con la firma dei 6+/-2,5 s e dopo cinque di fila smette di puntare su quell'asta. Una risposta fuori firma azzera il conteggio invece di scalarlo: l'errore costoso e' ritirarsi da un'asta che si poteva vincere, non spendere cinque puntate di troppo. Rigiocato sui dossier veri in cui il bot ha puntato: 366 puntate spese, 13 aste, zero vittorie. Con la regola a soglia 3 sarebbero state 31 puntate, 335 risparmiate (91,5%), nessuna vittoria persa. L'anticipo passa da 1000 a 500 ms. Le due ragioni per stare larghi sono cadute: il p99,9 del ping a 444 ms era misurato col tetto di 40 richieste al secondo che accodava le chiamate, e senza tetto il ping sta fra 47 e 78 ms; e la concorrenza sotto i due secondi non e' fitta ma rada, visto che tre quarti degli avversari sono autopuntate che hanno gia' sparato. Il tipo di puntata non viene piu' inventato. In due punti il motore ricostruiva una puntata da un cambio di prezzo fra due interrogazioni e la marcava "Auto" comunque: adesso quelle portano un trattino, perche' il tipo non lo ha detto nessuno. La colonna in cronologia mostra quindi solo tipi dichiarati dal server. Co-Authored-By: Claude Opus 5 --- Mimante/Models/AuctionInfo.cs | 41 ++++++++ Mimante/Models/BidHistoryEntry.cs | 32 +++++- Mimante/Services/AuctionMonitor.cs | 16 ++- Mimante/Services/BidStrategyService.cs | 27 ++++- Mimante/Tests/AutoBidDuelTests.cs | 136 +++++++++++++++++++++++++ Mimante/Utilities/AutoBidDuel.cs | 80 +++++++++++++++ Mimante/Utilities/SettingsManager.cs | 29 +++++- 7 files changed, 353 insertions(+), 8 deletions(-) create mode 100644 Mimante/Tests/AutoBidDuelTests.cs create mode 100644 Mimante/Utilities/AutoBidDuel.cs 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