Vai al contenuto
PAPER SIAT 2026Apri la pagina della ricerca
Cryptoverso

Lab del libro · L10

L10 — Più parametri, adattamenti perfetti e inutili

Lab 10 — Più parametri, meno sapere

Quaderno del capitolo «Più parametri, meno sapere» di La matematica di chi perde.

Costruiamo una regola nel modo in cui si costruisce davvero: un ingrediente alla volta, tenendo ogni volta quello che migliora di più il risultato sui dati che stiamo guardando.

Le condizioni fra cui scegliere sono numeri casuali: non contengono la minima informazione, né sul prezzo né su niente. La seconda metà della storia non la guardiamo mai durante la costruzione.

La curva sui dati usati per costruire sale a ogni passo. È garantito — e ha lo stesso identico aspetto che avrebbe se il metodo funzionasse davvero.

Le righe marcate PROVA sono quelle da cambiare: cambiale e riesegui per vedere l'effetto. Il resto — comprese le righe marcate NON TOCCARE — serve a mantenere il risultato confrontabile con quello stampato nel libro.

Output

3239 giorni: 1619 per costruire, 1620 mai visti
Espandi lo script di questo passo
lab_10_dimensionalita.py
python
import matplotlib.pyplot as plt
import numpy as np

from cvbook import seed_for
from cvbook.dati import carica
from cvbook.metriche import rendimenti

r = rendimenti(carica("btcusdt").sort("data")["chiusura"].to_numpy())
meta = len(r) // 2
dentro_campione, fuori_campione = r[:meta], r[meta:]
print(f"{len(r)} giorni: {len(dentro_campione)} per costruire, "
      f"{len(fuori_campione)} mai visti")

1. L'esperimento

Si riproduce quello che si fa davvero: si aggiunge un ingrediente alla volta. A ogni passo si prova ogni condizione disponibile e si tiene quella che migliora di più il risultato sulla prima metà della storia. La seconda metà non si guarda mai durante la costruzione.

Output

 1 condizioni →  dentro campione      37.84x   fuori campione     2.89x
 2 condizioni →  dentro campione      86.93x   fuori campione     4.35x
 3 condizioni →  dentro campione     132.92x   fuori campione     2.93x
 4 condizioni →  dentro campione     191.68x   fuori campione     2.51x
 5 condizioni →  dentro campione     222.08x   fuori campione     1.19x
 6 condizioni →  dentro campione     432.10x   fuori campione     1.24x

la ricerca si e' fermata dopo 6 aggiunte: nessuna condizione rimasta migliorava piu' il risultato che si stava guardando.
Espandi lo script di questo passo
lab_10_dimensionalita.py
python
CONDIZIONI_MAX = 25   # <- quante aggiunte al massimo
DISPONIBILI = 400     # <- quante condizioni ci sono nel cassetto
                      # PROVA / TRY: 2000 (vedi esercizio 1)

# In QUESTO esperimento i costi si tengono a zero, e va detto perche'.
# Aumentando le condizioni il segnale cambia stato piu' spesso, quindi opera di
# piu': con i costi dentro, la curva mescolerebbe due effetti diversi — i gradi
# di liberta' e la frequenza. Qui vogliamo isolare il primo. Il secondo lo
# guardiamo a parte, nella cella 2, che e' altrettanto istruttiva.
# In THIS experiment costs are held at zero, and here's why: more conditions
# means the signal flips more often, so it trades more — with costs in, the
# curve would blend two different effects (degrees of freedom and frequency).
# Here we want to isolate the first. We look at the second separately in
# cell 2, which is just as instructive.
COSTO = 0.0
# NON TOCCARE / DO NOT CHANGE: deve restare zero qui — è la cella 2 che
# rimette i costi per isolare il secondo effetto, non questa.
# It must stay zero here — it's cell 2 that puts costs back in to isolate
# the second effect, not this one.


def risultato(rend: np.ndarray, posizione: np.ndarray, costo: float = COSTO) -> float:
    movimenti = np.abs(np.diff(np.concatenate([[0.0], posizione])))
    return float(np.prod(1 + posizione * rend - movimenti * costo))


def ricerca_per_aggiunte(rumore: np.ndarray, passi: int) -> tuple[list[float], list[float]]:
    """Costruzione per aggiunte successive, come si fa davvero.

    A ogni passo si prova ad aggiungere ciascuna delle condizioni disponibili e
    si tiene quella che migliora di piu' il risultato **sui dati che si stanno
    guardando**. Ci si ferma quando nessuna aggiunta migliora — cioe' quando una
    persona reale smetterebbe.

    La regola sta dentro al mercato quando la somma delle condizioni scelte e'
    positiva: cosi' l'esposizione resta attorno alla meta' del tempo e l'unica
    cosa che cambia e' il numero di gradi di liberta'.
    """
    scelte: set[int] = set()
    somma = np.zeros(rumore.shape[1])
    corrente = 0.0
    curva_dentro, curva_fuori = [], []

    for _ in range(passi):
        migliore_indice, migliore_valore = None, -np.inf
        for k in range(len(rumore)):
            if k in scelte:
                continue
            posizione = ((somma + rumore[k]) > 0).astype(float)
            valore = risultato(dentro_campione, posizione[:meta])
            if valore > migliore_valore:
                migliore_indice, migliore_valore = k, valore

        if migliore_valore <= corrente:
            break  # nessuna aggiunta migliora: ci si ferma

        scelte.add(migliore_indice)
        somma = somma + rumore[migliore_indice]
        corrente = migliore_valore
        posizione = (somma > 0).astype(float)
        curva_dentro.append(migliore_valore)
        curva_fuori.append(risultato(fuori_campione, posizione[meta:]))

    return curva_dentro, curva_fuori


rng = np.random.default_rng(seed_for("lab-dimensionalita"))
# Le condizioni sono rumore puro allineato ai giorni: nessuna informazione dentro.
rumore = rng.normal(size=(DISPONIBILI, len(r)))
dentro, fuori = ricerca_per_aggiunte(rumore, CONDIZIONI_MAX)
CONDIZIONI = list(range(1, len(dentro) + 1))

for n, d, f in zip(CONDIZIONI, dentro, fuori):
    print(f"{n:2d} condizioni →  dentro campione {d:10.2f}x   fuori campione {f:8.2f}x")
print(f"\nla ricerca si e' fermata dopo {len(dentro)} aggiunte: nessuna condizione "
      f"rimasta migliorava piu' il risultato che si stava guardando.")

Due linee che si separano man mano che si aggiungono condizioni alla regola, da 1 a 6, con il capitale finale su scala logaritmica da 10 alla 0 a 10 alla 2. Quella continua, misurata sulla parte di storia usata per costruire la regola, sale a ogni condizione aggiunta; quella tratteggiata, misurata sulla parte mai vista, resta attorno alla riga punteggiata dell'uno e non migliora.

Aggiungere ingredienti migliora il passato e non tocca il futuro.Fonte: Binance Data Vision · Periodo: 2017-08-17 … 2026-06-30 · Metodo: A ogni passo si prova ogni condizione generata a caso e si tiene quella che migliora di piu' il risultato sulla prima meta' della storia; la seconda meta' non viene mai guardata durante la costruzione.
Espandi lo script di questo passo
lab_10_dimensionalita.py
python
with avvio.figura("schermo"):
    fig, ax = plt.subplots()
    ax.plot(CONDIZIONI, dentro, marker="o", linewidth=2,
            label="sulla parte usata per costruire")
    ax.plot(CONDIZIONI, fuori, marker="s", linewidth=2, linestyle="--",
            label="sulla parte mai vista")
    ax.axhline(1.0, linestyle=":", linewidth=1)
    ax.set_yscale("log")
    ax.set_xlabel("Condizioni aggiunte alla regola (tutte generate a caso)")
    ax.set_ylabel("Capitale finale (volte, scala log)")
    ax.legend()
    plt.show()

La curva continua sale a ogni singolo passo, e non perché il metodo stia imparando qualcosa: sale per costruzione, perché nessuno aggiunge un ingrediente che peggiora il numero che sta guardando.

Quella curva è identica a quella che vedresti se il metodo funzionasse davvero. Guardando solo il risultato della costruzione non puoi distinguere una scoperta da rumore memorizzato.

La curva tratteggiata è la risposta, e va letta con attenzione: migliora per le prime aggiunte, tocca un massimo, e da lì peggiora. Il guaio è che il passo in cui bisognava fermarsi non è visibile sulla curva continua.

2. E se i costi li rimettiamo dentro?

La cella precedente li teneva a zero per isolare i gradi di libertà. Rimettendoli, succede una cosa che il capitolo sui costi aveva già annunciato: più condizioni significa cambiare posizione più spesso, e ogni cambio si paga. Il risultato dentro campione smette perfino di crescere.

Output

 condizioni  operazioni   senza costi    con 0,12%
          1        1636        22.96x        3.22x
          2        1564         3.39x        0.52x
          3        1588         5.83x        0.87x
          4        1629         6.50x        0.92x
          5        1619         3.08x        0.44x
          6        1575         1.41x        0.21x

Due effetti diversi che spesso vengono confusi: i gradi di liberta' gonfiano il risultato apparente, la frequenza lo erode. Nella pratica agiscono insieme, ed e' per questo che vanno misurati separatamente.
Espandi lo script di questo passo
lab_10_dimensionalita.py
python
rng2 = np.random.default_rng(seed_for("lab-dimensionalita-costi"))
print(f"{'condizioni':>11s} {'operazioni':>11s} {'senza costi':>13s} {'con 0,12%':>12s}")
for n in CONDIZIONI:
    indici = rng2.integers(0, DISPONIBILI, size=n)
    posizione = (rumore[indici].sum(axis=0) > 0).astype(float)
    movimenti = float(np.abs(np.diff(np.concatenate([[0.0], posizione]))).sum())
    senza = risultato(r, posizione, costo=0.0)
    con = risultato(r, posizione, costo=0.0012)
    print(f"{n:11d} {movimenti:11.0f} {senza:12.2f}x {con:11.2f}x")

print("\nDue effetti diversi che spesso vengono confusi: i gradi di liberta' "
      "gonfiano il risultato apparente, la frequenza lo erode. Nella pratica "
      "agiscono insieme, ed e' per questo che vanno misurati separatamente.")

3. I parametri che non sai di avere

Prendiamo la regola più semplice immaginabile — «resto investito quando il prezzo sta sopra la sua media a N giorni» — e contiamo le scelte che quella frase nasconde.

Output

                                 quale asset: 3 valori plausibili
    quale intervallo (giorn./orario/settim.): 3 valori plausibili
                    da quando parte la serie: 4 valori plausibili
    quale prezzo (chiusura, apertura, medio): 3 valori plausibili
                     media semplice o pesata: 2 valori plausibili
                cosa fare quando si e' fuori: 2 valori plausibili
                  quale costo per operazione: 3 valori plausibili
                            quando si esegue: 3 valori plausibili
         ogni quanto si controlla il segnale: 3 valori plausibili

combinazioni nascoste dietro «un solo parametro»: 11,664
Non le hai provate tutte. Ne hai provata UNA, per abitudine, e non l'hai contata. Se il risultato fosse venuto brutto ne avresti cambiata qualcuna.
Espandi lo script di questo passo
lab_10_dimensionalita.py
python
scelte = {
    "quale asset": 3,
    "quale intervallo (giorn./orario/settim.)": 3,
    "da quando parte la serie": 4,
    "quale prezzo (chiusura, apertura, medio)": 3,
    "media semplice o pesata": 2,
    "cosa fare quando si e' fuori": 2,
    "quale costo per operazione": 3,
    "quando si esegue": 3,
    "ogni quanto si controlla il segnale": 3,
}

totale = 1
for nome, quante in scelte.items():
    totale *= quante
    print(f"  {nome:>42s}: {quante} valori plausibili")
print(f"\ncombinazioni nascoste dietro «un solo parametro»: {totale:,}")
print("Non le hai provate tutte. Ne hai provata UNA, per abitudine, e non l'hai "
      "contata. Se il risultato fosse venuto brutto ne avresti cambiata qualcuna.")

4. La dispersione: l'informazione più utile di un backtest

Invece di un numero, un intervallo. Riesegui la stessa regola cambiando una a una le scelte e guarda quanto si sparpagliano i risultati.

Output

16 varianti della STESSA idea (4 finestre × 4 date d'inizio)

  peggiore      5.94x
  mediana      13.37x
  migliore     64.40x
  rapporto migliore/peggiore: 10.8 volte

Questo intervallo e' l'informazione onesta. Il numero singolo che di solito si pubblica e' un punto scelto dentro di esso.
Espandi lo script di questo passo
lab_10_dimensionalita.py
python
prezzi = carica("btcusdt").sort("data")["chiusura"].to_numpy()


def sopra_media(p: np.ndarray, finestra: int, ritardo: int = 1) -> np.ndarray:
    cumulata = np.concatenate([[0.0], np.cumsum(p)])
    media = np.full(len(p), np.nan)
    media[finestra - 1:] = (cumulata[finestra:] - cumulata[:-finestra]) / finestra
    segnale = np.nan_to_num(np.where(p > media, 1.0, 0.0))
    posizione = np.zeros(len(p))
    posizione[ritardo:] = segnale[:-ritardo]
    return posizione


varianti = []
for finestra in (20, 50, 100, 200):
    for partenza in (0, 200, 400, 600):  # PROVA / TRY: aggiungi altre date (esercizio 3)
        p = prezzi[partenza:]
        rend = rendimenti(p)
        varianti.append(risultato(rend, sopra_media(p, finestra)[1:], costo=0.0012))

varianti = np.array(varianti)
print(f"{len(varianti)} varianti della STESSA idea (4 finestre × 4 date d'inizio)\n")
print(f"  peggiore  {varianti.min():8.2f}x")
print(f"  mediana   {np.median(varianti):8.2f}x")
print(f"  migliore  {varianti.max():8.2f}x")
print(f"  rapporto migliore/peggiore: {varianti.max() / varianti.min():.1f} volte")
print("\nQuesto intervallo e' l'informazione onesta. Il numero singolo che di "
      "solito si pubblica e' un punto scelto dentro di esso.")

Esercizi

  1. Nella prima cella porta DISPONIBILI a 2000. Con più condizioni fra cui scegliere la curva continua sale ancora di più e la ricerca si ferma più tardi: il numero di alternative esplorate è esattamente ciò che determina quanto bene si memorizza il rumore.
  2. Fai l'esperimento della prima cella con i tuoi indicatori al posto dei numeri casuali. Se le due curve si separano come qui, hai appena scoperto qualcosa di importante sul tuo metodo.
  3. Nell'ultima cella aggiungi altre due date d'inizio. La dispersione cresce, e con essa l'onestà del risultato.

Riproducibilità e download

Eseguito il 2026-08-27 dal quaderno del repository

Il quaderno

I dati

  • btcusdt.parquet93,2 KB

    sha256 ea75ad84e6e981507054df5c622c6b0ec3c8849c1f4dd007721878d4e4c8a329

    Fonte: Binance Data Vision · Periodo: 2017-08-17 → 2026-06-30 · 3240 righe · estratto 2026-08-16

Torna all’indice dei lab