Skip to content
SIAT PAPER 2026Open the research page
Cryptoverso

Lab from the book · L10

L10 — More parameters, perfect and useless fits

Lab 10 — More parameters, less knowledge

Code language

The code, its comments and its outputs are in Italian: they are the book’s code, kept identical to what the reader runs.

Notebook for the chapter "More parameters, less knowledge". We build a rule the way one really builds it: one ingredient at a time, each time keeping whatever improves the result the most on the data we're looking at. The conditions to choose among are random numbers: they contain zero information, about price or anything else. We never look at the second half of the history while building. The curve on the data used to build rises at every step. It's guaranteed — and it looks exactly like it would if the method actually worked.

The lines marked TRY are the ones to change: edit them and rerun to see the effect. Everything else — including lines marked DO NOT CHANGE — exists to keep the result comparable with the one printed in the book.

Output

3239 giorni: 1619 per costruire, 1620 mai visti
Show the script for this step
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. The experiment

We reproduce what actually happens: one ingredient is added at a time. At each step every available condition is tried, and whichever improves the result the most on the first half of the history is kept. The second half is never looked at during construction.

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.
Show the script for this step
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.")

Two lines that drift apart as conditions are added to the rule, from 1 to 6, with the final capital on a logarithmic scale from ten to the zero to ten squared. The solid one, measured on the stretch of history used to build the rule, rises with every condition added; the dashed one, measured on the stretch never seen, stays around the dotted line at one and does not improve.

Adding ingredients improves the past and leaves the future untouched.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: At each step every randomly generated condition is tried and the one that most improves the result on the first half of the history is kept; the second half is never looked at while building.
Show the script for this step
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()

The solid curve rises at every single step, and not because the method is learning anything: it rises by construction, because nobody adds an ingredient that worsens the number they're looking at. That curve is identical to the one you'd see if the method actually worked. Looking only at the construction result, you cannot tell a discovery apart from memorized noise. The dashed curve is the answer, and must be read carefully: it improves for the first few additions, peaks, then worsens from there. The trouble is that the step where you should have stopped is not visible on the solid curve.

2. What if we put the costs back in?

The previous cell kept them at zero to isolate degrees of freedom. Putting them back, something the costs chapter had already warned about happens: more conditions means changing position more often, and every change is paid for. The in-sample result even stops growing.

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.
Show the script for this step
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. The parameters you don't know you have

Take the simplest rule imaginable — "stay invested when price is above its N-day average" — and count the choices that sentence hides.

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.
Show the script for this step
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. Dispersion: the most useful information in a backtest

Instead of a number, a range. Rerun the same rule changing the choices one at a time and watch how much the results spread.

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.
Show the script for this step
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.")

Exercises

  1. In the first cell raise DISPONIBILI to 2000. With more conditions to choose among, the solid curve rises even more and the search stops later: the number of alternatives explored is exactly what determines how well noise gets memorized.
  2. Run the first cell's experiment with your own indicators instead of random numbers. If the two curves separate like here, you've just discovered something important about your method.
  3. In the last cell add two more start dates. The dispersion grows, and with it the honesty of the result.

Reproducibility & downloads

Run on 2026-08-27 from the repository notebook

The notebook

The data

  • btcusdt.parquet93.2 KB

    sha256 ea75ad84e6e981507054df5c622c6b0ec3c8849c1f4dd007721878d4e4c8a329

    Source: Binance Data Vision · Period: 2017-08-17 → 2026-06-30 · 3,240 rows · extracted 2026-08-16

Back to the lab index