Via le due modalita' Addestramento/Gara, per scelta dell'utente: la misura aveva detto che il lavoro di registrazione costa mezzo punto di un core, e allora tanto vale registrare sempre. Resta la coalescenza verso l'interfaccia, che era la correzione vera. COSA IMPARA. Per ogni puntata di ogni asta chiusa: e' rimasta senza risposta? E' l'unica etichetta abbondante — 1,8 milioni di esempi sui dossier locali — contro le diciannove aste vinte dall'utente, che non farebbero un modello. Il comportamento avversario non dipende da chi ha puntato, e questo rende utilizzabili le puntate altrui. Regressione logistica online scritta a mano, senza librerie, in Ml/: un passo di gradiente per esempio, aggiornata a ogni chiusura, salvata subito. Variabili a fasce: durata del ciclo, quota di autopuntate recenti, puntatori distinti, prezzo sul valore, fascia oraria (le fasce ricalcano la curva misurata sulle chiusure), feriale/festivo, ora di aggancio, tipo di puntata, profondita' dell'asta, stesso puntatore di due fa, prodotto in 32 cassetti con hash stabile. In parallelo un profilo per prodotto x fascia oraria x tipo di giorno, con restringimento verso il prodotto e poi verso il globale dove i dati scarseggiano. COME DECIDE. In ShouldPlaceBid: probabilita' appresa per il margine residuo meno il costo della puntata; sotto zero non si punta, e il registro riporta i numeri. Il modello parla sempre ma decide solo dopo 300 aste apprese. Le previsioni sono calibrate in linea con il rapporto osservato/previsto sugli ultimi 20.000 esempi. QUANTO VALE, MISURATO. Valutazione prequenziale sui 3000 dossier piu' recenti (ogni asta prima prevista poi appresa, come nell'uso): sollevamento 10,8x nella cima dell'1%, 5,8x al 5%; calibrazione entro il 10-30% in ogni fascia. Sulle 116 puntate vere dell'utente di agosto: nessuna era finale, e il cancello ne avrebbe fermate 45 senza perderne una vincente. La valutazione congelata (addestra sui vecchi, giudica sui nuovi) sovrastima di 1,5-1,8x per deriva del mercato, non per passo o regolarizzazione: provati entrambi. Griglia e prequenziale stanno nel test MlModelBacktest, target Apprendimento, attivita' VS Code "valuta apprendimento". PRIMO ADDESTRAMENTO. In sottofondo, dal piu' recente, con un tempo massimo per avvio. Il lettore salta le righe di poll prima del JSON: 6289 dossier per 8,8 GB in 64 secondi, verificato dal vivo, archivio completo al primo avvio. ACQUISIZIONE. I poll nel dossier si scrivono solo quando cambia qualcosa di visibile, e comunque uno al secondo: -88,8% misurato, senza perdere la profondita' di nessun ciclo. Il reset porta il minimo del cronometro e la durata del ciclo chiuso. Via le righe di log che copiavano i reset parola per parola. Corretto un difetto vecchio: nessun dossier marcava le puntate nostre, perche' il parser lo faceva solo se riceveva il nome utente e nel percorso dei poll non gli arrivava. Ora si marcano in MergeBidHistory e l'intestazione porta il nome utente; per i dossier vecchi il lettore accetta il nome da fuori. Trovato dal vivo e corretto: salvataggi concorrenti all'avvio quando piu' aste chiudono insieme. Il modello salvato non rilegge piu' i parametri (passo, regolarizzazione): sono scelte del codice, e un cambio vale dal riavvio dopo. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
303 lines
11 KiB
C#
303 lines
11 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using AutoBidder.Models;
|
|
using AutoBidder.Utilities;
|
|
using Xunit;
|
|
|
|
namespace AutoBidder.Tests;
|
|
|
|
/// <summary>
|
|
/// Il dossier è l'unico posto in cui certi dati esistono: Bidoo non espone né i tempi delle
|
|
/// puntate né l'andamento del prezzo di un'asta conclusa. Le proprietà da difendere sono
|
|
/// quindi tre — che il file <b>si scriva</b>, che ogni riga sia <b>JSON valido da sola</b>
|
|
/// (è quello che permette di leggerlo a pezzi e di sopravvivere a un'interruzione), e che il
|
|
/// riepilogo finale dica se la storia è completa.
|
|
/// </summary>
|
|
public class AuctionDossierTests
|
|
{
|
|
private static AppSettings Settings(bool rawPolls = true) => new()
|
|
{
|
|
WriteAuctionDossiers = true,
|
|
DossierIncludeRawPolls = rawPolls
|
|
};
|
|
|
|
private static AuctionInfo NewAuction() => new()
|
|
{
|
|
AuctionId = "test-" + Guid.NewGuid().ToString("N")[..8],
|
|
Name = "Cuffie Sony WH-1000XM5",
|
|
BidBeforeDeadlineMs = 800,
|
|
BuyNowPrice = 299,
|
|
ShippingCost = 6.90
|
|
};
|
|
|
|
private static AuctionState State(double price, double timer, int ping = 70) => new()
|
|
{
|
|
Price = price,
|
|
Timer = timer,
|
|
PollingLatencyMs = ping,
|
|
Status = AuctionStatus.Running,
|
|
LastBidder = "marco_82",
|
|
ExpiryUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (long)timer
|
|
};
|
|
|
|
private static AuctionDetailRecord Detail(AuctionInfo auction) => new()
|
|
{
|
|
AuctionId = auction.AuctionId,
|
|
Name = auction.Name,
|
|
Outcome = "Persa",
|
|
FinalPrice = 4.87,
|
|
BuyNowPrice = auction.BuyNowPrice,
|
|
ShippingCost = auction.ShippingCost,
|
|
MyBids = 9,
|
|
EndedAt = DateTime.Now,
|
|
FirstSeenAt = DateTime.Now.AddMinutes(-35),
|
|
ObservedMinutes = 35,
|
|
ConfiguredLeadMs = 800
|
|
};
|
|
|
|
private static string[] LinesOf(AuctionDossier dossier)
|
|
{
|
|
FileLogWriter.FlushAll();
|
|
return File.ReadAllLines(dossier.Path).Where(l => l.Length > 0).ToArray();
|
|
}
|
|
|
|
[Fact]
|
|
public void Aprire_il_dossier_scrive_lintestazione()
|
|
{
|
|
var auction = NewAuction();
|
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
|
|
|
var header = JsonDocument.Parse(LinesOf(dossier)[0]).RootElement;
|
|
|
|
Assert.Equal("header", header.GetProperty("type").GetString());
|
|
Assert.Equal(auction.AuctionId, header.GetProperty("auctionId").GetString());
|
|
|
|
// Il valore del prodotto deve stare nel file: senza, il prezzo finale non dice
|
|
// se è stato un affare.
|
|
Assert.Equal(299, header.GetProperty("product").GetProperty("buyNowPrice").GetDouble());
|
|
|
|
// E con che anticipo la si stava seguendo: due aste con anticipi diversi non sono
|
|
// confrontabili fra loro.
|
|
Assert.Equal(800, header.GetProperty("config").GetProperty("bidBeforeDeadlineMs").GetInt32());
|
|
|
|
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
|
}
|
|
|
|
// ── Coalescenza dei poll ─────────────────────────────────────────────
|
|
//
|
|
// Con ~17 interrogazioni al secondo per asta, su 40 dossier veri l'88,8% dei poll
|
|
// aveva la stessa faccia del precedente. Si scrive solo quando cambia qualcosa di
|
|
// visibile: la profondita' di ogni ciclo resta ricostruibile, il file pesa un decimo.
|
|
|
|
private static int Conta(AuctionDossier dossier, string tipo) =>
|
|
LinesOf(dossier).Count(l => l.Contains($"\"type\":\"{tipo}\"", StringComparison.Ordinal));
|
|
|
|
[Fact]
|
|
public void I_poll_identici_di_fila_si_scrivono_una_volta_sola()
|
|
{
|
|
var auction = NewAuction();
|
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
|
|
|
for (var i = 0; i < 10; i++) dossier.Poll(State(1.24, 15.6), includeRaw: true);
|
|
Assert.Equal(1, Conta(dossier, "poll"));
|
|
|
|
// Cambia il secondo sul cronometro: si scrive, perche' e' da qui che si legge
|
|
// fin dove e' sceso il timer.
|
|
dossier.Poll(State(1.24, 14.9), includeRaw: true);
|
|
Assert.Equal(2, Conta(dossier, "poll"));
|
|
|
|
// Cambia il prezzo: si scrive.
|
|
dossier.Poll(State(1.25, 15.0), includeRaw: true);
|
|
Assert.Equal(3, Conta(dossier, "poll"));
|
|
|
|
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
|
}
|
|
|
|
[Fact]
|
|
public void Il_reset_porta_il_minimo_del_ciclo_chiuso_e_la_sua_durata()
|
|
{
|
|
var auction = NewAuction();
|
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
|
|
|
dossier.Poll(State(1.24, 8.0), includeRaw: true);
|
|
dossier.Poll(State(1.24, 3.4), includeRaw: true);
|
|
dossier.Poll(State(1.24, 1.2), includeRaw: true);
|
|
dossier.Poll(State(1.25, 8.0), includeRaw: true); // il prezzo sale: ciclo chiuso
|
|
dossier.Reset(1, 1.25, "marco_82");
|
|
|
|
var reset = LinesOf(dossier).Single(l => l.Contains("\"type\":\"reset\"", StringComparison.Ordinal));
|
|
var r = JsonDocument.Parse(reset).RootElement;
|
|
|
|
Assert.Equal(1.2, r.GetProperty("minTimer").GetDouble(), 3);
|
|
Assert.True(r.GetProperty("cycleSeconds").GetDouble() >= 0);
|
|
|
|
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
|
}
|
|
|
|
[Fact]
|
|
public void Le_righe_RESET_del_registro_non_si_copiano_nel_dossier()
|
|
{
|
|
// Ripetevano parola per parola l'evento reset gia' scritto: una riga di log per
|
|
// ogni reset, e nessuna diceva qualcosa in piu'.
|
|
var auction = NewAuction();
|
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
|
|
|
dossier.Log("info", "General", "[RESET #3] marco_82 → €1,25 | Timer: 8,0s");
|
|
dossier.Log("info", "General", "qualcosa che invece conta");
|
|
|
|
var logs = LinesOf(dossier).Where(l => l.Contains("\"type\":\"log\"", StringComparison.Ordinal)).ToArray();
|
|
|
|
Assert.Single(logs);
|
|
Assert.Contains("qualcosa che invece conta", logs[0]);
|
|
|
|
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
|
}
|
|
|
|
[Fact]
|
|
public void Ogni_riga_e_json_valido_per_conto_suo()
|
|
{
|
|
// È la ragione del formato: un file troncato a metà perde l'ultima riga, non tutto.
|
|
var auction = NewAuction();
|
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
|
|
|
dossier.Poll(State(1.24, 15), includeRaw: true);
|
|
dossier.Reset(1, 1.25, "marco_82");
|
|
dossier.MyBid(1.25, 800, 762.4, 68, success: true, error: null, bidsUsed: 4, remainingBids: 255);
|
|
dossier.Log("info", "Status", "asta in corso");
|
|
|
|
var lines = LinesOf(dossier);
|
|
|
|
Assert.All(lines, line => JsonDocument.Parse(line));
|
|
Assert.Equal(5, lines.Length); // intestazione + quattro eventi
|
|
|
|
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
|
}
|
|
|
|
[Fact]
|
|
public void La_mia_puntata_registra_anticipo_voluto_e_ottenuto()
|
|
{
|
|
// È la coppia su cui si tara l'anticipo: da sola nessuna delle due dice nulla.
|
|
var auction = NewAuction();
|
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
|
|
|
dossier.MyBid(1.25, plannedLeadMs: 800, actualLeadMs: 762.4, pingMs: 68,
|
|
success: true, error: null, bidsUsed: 4, remainingBids: 255);
|
|
|
|
var bid = JsonDocument.Parse(LinesOf(dossier)[1]).RootElement;
|
|
|
|
Assert.Equal("my_bid", bid.GetProperty("type").GetString());
|
|
Assert.Equal(800, bid.GetProperty("plannedLeadMs").GetInt32());
|
|
Assert.Equal(762.4, bid.GetProperty("actualLeadMs").GetDouble(), 1);
|
|
Assert.Equal(-37.6, bid.GetProperty("leadErrorMs").GetDouble(), 1);
|
|
Assert.Equal(68, bid.GetProperty("pingMs").GetInt32());
|
|
Assert.Equal("ok", bid.GetProperty("result").GetString());
|
|
|
|
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
|
}
|
|
|
|
[Fact]
|
|
public void Senza_poll_grezzi_il_ping_si_misura_lo_stesso()
|
|
{
|
|
// Spegnere i poll grezzi riduce la dimensione del file, non la qualità delle
|
|
// statistiche di rete: quelle si accumulano comunque.
|
|
var auction = NewAuction();
|
|
var dossier = AuctionDossier.OpenFor(auction, Settings(rawPolls: false))!;
|
|
|
|
dossier.Poll(State(1.24, 15, ping: 60), includeRaw: false);
|
|
dossier.Poll(State(1.30, 12, ping: 80), includeRaw: false);
|
|
|
|
Assert.Single(LinesOf(dossier)); // solo l'intestazione
|
|
|
|
AuctionDossier.Close(auction, Detail(auction), null);
|
|
|
|
var summary = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
|
|
var network = summary.GetProperty("network");
|
|
|
|
Assert.Equal(2, network.GetProperty("samples").GetInt32());
|
|
Assert.Equal(70, network.GetProperty("avgPingMs").GetDouble());
|
|
Assert.Equal(60, network.GetProperty("minPingMs").GetInt32());
|
|
Assert.Equal(80, network.GetProperty("maxPingMs").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public void Il_riepilogo_dice_se_la_storia_e_completa()
|
|
{
|
|
var auction = NewAuction();
|
|
auction.ObservedFromStart = true;
|
|
auction.ObservedToEnd = true;
|
|
|
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
|
AuctionDossier.Close(auction, Detail(auction), null);
|
|
|
|
var summary = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
|
|
|
|
Assert.Equal("summary", summary.GetProperty("type").GetString());
|
|
Assert.True(summary.GetProperty("coverage").GetProperty("complete").GetBoolean());
|
|
|
|
// Costo reale se avessi vinto: prezzo + puntate + spedizione.
|
|
Assert.True(summary.GetProperty("value").GetProperty("totalCostIfWon").GetDouble() > 4.87);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dopo_il_riepilogo_non_si_scrive_piu_nulla()
|
|
{
|
|
// Una riga in coda al riepilogo farebbe dubitare di tutto ciò che c'è sopra.
|
|
var auction = NewAuction();
|
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
|
|
|
AuctionDossier.Close(auction, Detail(auction), null);
|
|
|
|
var before = LinesOf(dossier).Length;
|
|
dossier.Log("info", "Status", "questa riga non deve comparire");
|
|
|
|
Assert.Equal(before, LinesOf(dossier).Length);
|
|
}
|
|
|
|
[Fact]
|
|
public void Unasta_tolta_prima_della_fine_lo_dichiara()
|
|
{
|
|
// Una serie di prezzi che si interrompe sembra un'asta finita a quel prezzo: va
|
|
// detto che i dati sono incompleti.
|
|
var auction = NewAuction();
|
|
var dossier = AuctionDossier.OpenFor(auction, Settings())!;
|
|
|
|
AuctionDossier.Abandon(auction.AuctionId, "rimossa dal monitor");
|
|
|
|
var last = JsonDocument.Parse(LinesOf(dossier)[^1]).RootElement;
|
|
|
|
Assert.Equal("abandoned", last.GetProperty("type").GetString());
|
|
Assert.Contains("incompleti", last.GetProperty("note").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void Riaprire_la_stessa_asta_riprende_lo_stesso_file()
|
|
{
|
|
// Un riavvio a metà asta non deve spezzare la storia in due file.
|
|
var auction = NewAuction();
|
|
|
|
var first = AuctionDossier.OpenFor(auction, Settings())!;
|
|
var path = first.Path;
|
|
|
|
AuctionDossier.Abandon(auction.AuctionId, "simulazione riavvio");
|
|
|
|
var second = AuctionDossier.OpenFor(auction, Settings())!;
|
|
|
|
Assert.Equal(path, second.Path);
|
|
|
|
var lines = LinesOf(second);
|
|
Assert.Equal("resumed", JsonDocument.Parse(lines[^1]).RootElement.GetProperty("type").GetString());
|
|
|
|
AuctionDossier.Abandon(auction.AuctionId, "fine test");
|
|
}
|
|
|
|
[Fact]
|
|
public void Con_i_dossier_spenti_non_si_scrive_nessun_file()
|
|
{
|
|
var auction = NewAuction();
|
|
|
|
Assert.Null(AuctionDossier.OpenFor(auction, new AppSettings { WriteAuctionDossiers = false }));
|
|
}
|
|
}
|