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 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 15:20:34 +02:00
co-authored by Claude Opus 5
parent 20434ca273
commit 9b94e2d405
7 changed files with 353 additions and 8 deletions
+41
View File
@@ -565,6 +565,47 @@ namespace AutoBidder.Models
/// </summary> /// </summary>
[JsonIgnore] [JsonIgnore]
public bool IsInSoftRetreat { get; set; } = false; public bool IsInSoftRetreat { get; set; } = false;
// ── Duello con l'autopuntata avversaria ──────────────────────────
// Vedi Utilities/AutoBidDuel per il perche' e per i numeri misurati.
/// <summary>Istante dell'ultima puntata nostra, per misurare quanto tarda la risposta.</summary>
[JsonIgnore]
public DateTime? LastMyBidAtUtc { get; set; }
/// <summary>
/// Risposte avversarie di fila con la firma dell'autopuntata (~6 s). Oltre la
/// soglia si smette di puntare su quest'asta: vedi <see cref="AutoBidDuelDetected"/>.
/// </summary>
[JsonIgnore]
public int AutoResponsesInARow { get; set; }
/// <summary>Riconosciuta un'autopuntata armata dall'altra parte.</summary>
[JsonIgnore]
public bool AutoBidDuelDetected { get; set; }
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary> /// <summary>
/// Contatore puntate effettuate in questa sessione su questa asta /// Contatore puntate effettuate in questa sessione su questa asta
+29 -3
View File
@@ -14,11 +14,37 @@ namespace AutoBidder.Models
[JsonPropertyName("Price")] [JsonPropertyName("Price")]
public decimal Price { get; set; } public decimal Price { get; set; }
/// <summary>Puntata fatta dall'autopuntata del sito: scatta a 2 secondi dalla fine.</summary>
public const string TipoAuto = "Auto";
/// <summary>Puntata fatta a mano. Vale 3,6 volte una automatica: vedi <see cref="Utilities.AutoBidDuel"/>.</summary>
public const string TipoManuale = "Manuale";
/// <summary> /// <summary>
/// Tipo di puntata (Auto/Manuale) /// Il tipo non lo ha detto il server.
///
/// <para>Serve perché non tutte le puntate in elenco vengono dallo storico di
/// <c>data.php</c>: 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.</para>
/// </summary>
public const string TipoSconosciuto = "—";
/// <summary>
/// Tipo di puntata: <see cref="TipoAuto"/>, <see cref="TipoManuale"/> oppure
/// <see cref="TipoSconosciuto"/>. Lo dichiara Bidoo nello storico dell'asta.
/// </summary> /// </summary>
[JsonPropertyName("BidType")] [JsonPropertyName("BidType")]
public string BidType { get; set; } = "Auto"; public string BidType { get; set; } = TipoSconosciuto;
/// <summary>Vero solo se il server ha davvero detto che era automatica.</summary>
[JsonIgnore]
public bool IsAuto => string.Equals(BidType, TipoAuto, StringComparison.OrdinalIgnoreCase);
/// <summary>Vero solo se il server ha davvero detto che era manuale.</summary>
[JsonIgnore]
public bool IsManual => string.Equals(BidType, TipoManuale, StringComparison.OrdinalIgnoreCase);
/// <summary> /// <summary>
/// Timestamp della puntata (Unix timestamp) /// Timestamp della puntata (Unix timestamp)
@@ -55,7 +81,7 @@ namespace AutoBidder.Models
} }
/// <summary> /// <summary>
/// Indica se la puntata è stata fatta dall'utente corrente /// Indica se la puntata stata fatta dall'utente corrente
/// </summary> /// </summary>
[JsonIgnore] [JsonIgnore]
public bool IsMyBid { get; set; } public bool IsMyBid { get; set; }
+13 -3
View File
@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -620,7 +620,7 @@ namespace AutoBidder.Services
Username = state.LastBidder, Username = state.LastBidder,
Price = statePrice, Price = statePrice,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
BidType = "Auto" BidType = BidHistoryEntry.TipoSconosciuto
}); });
} }
} }
@@ -1193,10 +1193,20 @@ namespace AutoBidder.Services
Username = currentBidder, Username = currentBidder,
Price = statePrice, Price = statePrice,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), 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, // I conteggi per utente restano a carico di UpdateBidderStatsFromRecentBids,
// unico punto che li tocca: contarli anche qui li raddoppierebbe. // unico punto che li tocca: contarli anche qui li raddoppierebbe.
} }
+26 -1
View File
@@ -169,7 +169,27 @@ namespace AutoBidder.Services
{ {
return decision; 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! // ? RIMOSSO: Entry Point - Era sbagliato!
// I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI. // I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
// Se l'utente imposta MaxPrice=2, vuole puntare FINO A 2, non fino al 70%! // 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.SuccessfulBidCount++;
auction.ConsecutiveCollisions = 0; 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 else
{ {
+136
View File
@@ -0,0 +1,136 @@
using System;
using AutoBidder.Models;
using AutoBidder.Utilities;
using Xunit;
namespace AutoBidder.Tests;
/// <summary>
/// Riconoscimento dell'autopuntata avversaria.
///
/// <para>La proprieta' da difendere non e' "riconosce le macchine": e' che <b>non si ritira
/// da un'asta che si poteva vincere</b>. 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.</para>
///
/// <para>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.</para>
/// </summary>
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);
}
}
+80
View File
@@ -0,0 +1,80 @@
using System;
namespace AutoBidder.Utilities
{
/// <summary>
/// Riconosce l'avversario che ha l'autopuntata di Bidoo armata, e dice quando smettere.
///
/// <para><b>Il meccanismo.</b> 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 <b>6,0 s</b>, primo quartile 5,3 s, terzo quartile 7,1 s.</para>
///
/// <para><b>Perché serve ritirarsi.</b> 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.</para>
///
/// <para><b>Perché non basta puntare tardi.</b> 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.</para>
///
/// <para>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.</para>
/// </summary>
public static class AutoBidDuel
{
/// <summary>Ritardo tipico della risposta automatica, in secondi.</summary>
public const double RispostaTipicaSecondi = 6.0;
/// <summary>
/// Quanto ci si allontana dai 6 secondi continuando a chiamarla automatica.
/// Coincide grosso modo con l'intervallo fra primo e terzo quartile misurati
/// (5,37,1 s), allargato per la latenza di rete e per l'imprecisione del polling.
/// </summary>
public const double TolleranzaSecondi = 2.5;
/// <summary>
/// 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.
/// </summary>
public const int RispostePerConcludere = 5;
/// <summary>
/// Questo intervallo ha la firma della risposta automatica?
/// </summary>
public static bool ERispostaAutomatica(double secondiDopoLaMiaPuntata) =>
secondiDopoLaMiaPuntata > 0 &&
Math.Abs(secondiDopoLaMiaPuntata - RispostaTipicaSecondi) <= TolleranzaSecondi;
/// <summary>
/// Il conteggio aggiornato dopo aver visto una risposta avversaria.
///
/// <para>Una risposta che <b>non</b> 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.</para>
/// </summary>
public static int Aggiorna(int consecutiveFinora, double secondiDopoLaMiaPuntata) =>
ERispostaAutomatica(secondiDopoLaMiaPuntata) ? consecutiveFinora + 1 : 0;
/// <summary>
/// Si è capito che dall'altra parte c'è un'autopuntata armata?
/// </summary>
public static bool DuelloRiconosciuto(int consecutive, int soglia = RispostePerConcludere) =>
consecutive >= Math.Max(1, soglia);
/// <summary>Come spiegarlo nel registro, in una riga.</summary>
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.";
}
}
+28 -1
View File
@@ -38,7 +38,21 @@ namespace AutoBidder.Utilities
/// dossier non possono dire di piu', perche' Bidoo dichiara la scadenza al secondo /// dossier non possono dire di piu', perche' Bidoo dichiara la scadenza al secondo
/// intero.</para> /// intero.</para>
/// </summary> /// </summary>
public int DefaultBidBeforeDeadlineMs { get; set; } = 1000; ///
/// <para><b>Aggiornato a 500 ms.</b> 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.</para>
///
/// <para>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.</para>
/// </summary>
public int DefaultBidBeforeDeadlineMs { get; set; } = 500;
public double DefaultMinPrice { get; set; } = 0; public double DefaultMinPrice { get; set; } = 0;
public double DefaultMaxPrice { get; set; } = 0; public double DefaultMaxPrice { get; set; } = 0;
public int DefaultMaxClicks { get; set; } = 0; public int DefaultMaxClicks { get; set; } = 0;
@@ -546,6 +560,19 @@ namespace AutoBidder.Utilities
/// Default: false (DISABILITATO - non blocca mai) /// Default: false (DISABILITATO - non blocca mai)
/// </summary> /// </summary>
public bool HardcodedAntiCollisionEnabled { get; set; } = false; public bool HardcodedAntiCollisionEnabled { get; set; } = false;
/// <summary>
/// Ritirarsi quando dall'altra parte c'è un'autopuntata armata. Acceso di serie.
/// Vedi <see cref="AutoBidDuel"/> per il meccanismo e i numeri misurati.
/// </summary>
public bool AutoBidDuelWithdrawEnabled { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public int AutoBidDuelResponses { get; set; } = AutoBidDuel.RispostePerConcludere;
// 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥 // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
// RILEVAMENTO COMPETIZIONE E HEAT METRIC // RILEVAMENTO COMPETIZIONE E HEAT METRIC