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
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
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.
Show the script for this step
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
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
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
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
- In the first cell raise
DISPONIBILIto 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. - 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.
- 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
lab_10_dimensionalita.ipynb18.2 KB
sha256 9e2e848a2392b40b2e6bc1e2041a2294a6c5c60f5093df37b4f8ee87d5d7dbfe
lab_10_dimensionalita.py14.2 KB
sha256 d1b0e7346119951c4642f576545d5aced0b3b6502751f829801a5c9d18d71b57
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