Lab from the book · L04
L04 — Loss aversion and the disposition effect, measured on your own numbers
Lab 4 — The measured cost of your reflexes
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 "Why your brain isn't built for this". The chapter says that taking small profits and holding large losses is measurable and expensive. Here you measure it on your own parameters, then try the experiment the chapter describes: telling apart by eye a process with a real edge from one without. Almost nobody can.
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.
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 drawdown_massimo, equity, rendimenti
from cvbook.simulazioni import equity_casuali
prezzi = carica("btcusdt").sort("data")["chiusura"].to_numpy()
r = rendimenti(prezzi)1. The cost of taking the profit right away
Two behaviours, same price series, same capital, same costs. The first buys and never touches anything again. The second does what real-account experience documents: closes as soon as it's up a certain percentage, and stays in until the loss reaches a much wider threshold. No forecast tells the two apart. Only the two thresholds do.
Two capital curves on a logarithmic scale over 3,240 days, with the vertical axis running from ten to the zero to ten to the one. The solid one, belonging to whoever touches nothing, reaches 13.7 times the starting capital; the dashed one, belonging to whoever closes as soon as they are 10% up and sits through losses of up to 50%, stops at 8.5. The maximum drawdowns are similar, 83% and 81%.
Output
chi non ha toccato niente: 13.66x calo massimo -83.2% chi ha preso i piccoli utili: 9.01x calo massimo -81.2% differenza: -34.1%
Show the script for this step
PRENDI_UTILE = 0.10 # ← chiudi quando sei in utile di questa percentuale
# PROVA / TRY: 0,05 · 0,10 · 0,50 (vedi esercizi 1 e 2)
SOPPORTA_PERDITA = 0.50 # ← resti dentro finche' la perdita non arriva a questa
# PROVA / TRY: 0,10 · 0,50 · 0,70 (vedi esercizi 1 e 2)
COSTO = 0.0012 # ← costo di un GIRO COMPLETO — chiudere e riaprire
# PROVA / TRY: 0,0006 · 0,0012 · 0,0025
# NON TOCCARE / DO NOT CHANGE: qui lo 0,12% e' il costo di un giro completo, ed
# e' la convenzione che il libro dichiara fino al capitolo sull'analisi tecnica.
# Addebitarlo due volte per giro — una in uscita e una al rientro — e' la
# convenzione severa, che il libro introduce solo dal cap. 18b in avanti: con
# quella il quaderno chiudeva a 8,46 volte dove il capitolo stampa 9,0.
# Here 0.12% is the cost of a full round trip, the convention the book declares
# up to the technical-analysis chapter. Charging it twice per round trip is the
# stricter convention, introduced only from ch. 18b on.
def con_soglie(p: np.ndarray, su: float, giu: float, costo: float) -> np.ndarray:
"""Chiude quando la posizione tocca una soglia, e rientra il giorno dopo.
Sono tre le cose che questo comportamento paga rispetto al non far nulla,
e vale la pena tenerle distinte perche' pesano in modo molto diverso:
1. il costo del giro completo — uscita piu' rientro — addebitato una volta
sola, in uscita, secondo la convenzione dichiarata dal capitolo;
2. il giorno passato fuori dal mercato ad ogni chiusura — ed e' questa la
voce piu' cara, perche' il capitolo sulla media che mente ha mostrato
che pochissimi giorni contengono quasi tutto il risultato;
3. niente altro: nessuna previsione, nessuna scelta di direzione.
"""
valore = np.empty(len(p))
valore[0] = 1.0
ingresso, quota, liquido, dentro = p[0], 1.0 / p[0], 0.0, True
for i in range(1, len(p)):
if dentro:
corrente = quota * p[i]
variazione = p[i] / ingresso - 1.0
if variazione >= su or variazione <= -giu:
liquido = corrente * (1.0 - costo) # esce: paga il giro intero
dentro, corrente = False, liquido
valore[i] = corrente
else:
ingresso = p[i] # rientra il giorno dopo, senza ripagare:
quota = liquido / p[i] # il giro e' gia' stato addebitato in uscita
dentro = True
valore[i] = quota * p[i]
# Anche l'ingresso del primo giorno e' un'operazione, e va pagato: sotto
# sta il compra-e-tieni che lo paga, e le due colonne devono essere
# confrontabili fino all'ultimo centesimo.
return valore * (1.0 - costo)
# Anche chi non tocca niente paga il proprio ingresso: e' un'operazione, e il
# metro di confronto non puo' viaggiare gratis mentre l'altro comportamento
# paga. Sono dodici centesimi per mille euro, tutti a favore della tesi.
# Whoever never touches anything still pays for the entry: it is one trade, and
# the benchmark cannot travel for free while the other behaviour pays.
fermo = equity(r) * (1 - COSTO)
nervoso = con_soglie(prezzi, PRENDI_UTILE, SOPPORTA_PERDITA, COSTO)
with avvio.figura("schermo"):
fig, ax = plt.subplots()
ax.semilogy(fermo, linewidth=1.8, label="compra e non tocca niente")
ax.semilogy(nervoso, linewidth=1.8, linestyle="--",
label=f"chiude a +{PRENDI_UTILE:.0%}, sopporta -{SOPPORTA_PERDITA:.0%}")
ax.set_ylabel("Capitale (scala log)")
ax.set_xlabel("Giorni")
ax.legend()
plt.show()
print(f"chi non ha toccato niente: {fermo[-1]:6.2f}x calo massimo {drawdown_massimo(fermo):.1%}")
print(f"chi ha preso i piccoli utili: {nervoso[-1]:6.2f}x calo massimo {drawdown_massimo(nervoso):.1%}")
print(f"differenza: {nervoso[-1] / fermo[-1] - 1:+.1%}")Note the two maximum-drawdown columns. The second behaviour gave up part of the result without buying even a bit of peace of mind in return. It paid for the illusion of control.
2. The asymmetry of pain, and why it flattens
The experimental parameters of prospect theory: a loss weighs about two and a half times a gain of equal size, and the curve flattens out moving away from zero.
An S-shaped curve centred on the origin, with gains or losses in euros from minus 10,000 to 10,000 across and perceived value in arbitrary units up the side, from minus 6,000 to a little past 2,000. The loss branch falls far more steeply than the gain branch rises: at 2,000 euros the ratio between pain and pleasure is 2.25, and the stretch from minus 8,000 to minus 10,000 adds less pain than the first stretch below zero.
Output
2,000 euro: piacere 803.4 dolore 1807.5 rapporto 2.25 8,000 euro: piacere 2720.9 dolore 6122.1 rapporto 2.25 dolore nel passare da 0 a -2.000: 1807.5 dolore nel passare da -8.000 a -10.000: 1328.3 È il motivo per cui, dopo una perdita gia' grande, rischiare ancora costa pochissimo in termini di sofferenza attesa.
Show the script for this step
CURVATURA = 0.88
AVVERSIONE = 2.25
# NON TOCCARE / DO NOT CHANGE: sono i parametri stimati sperimentalmente dalla
# teoria del prospetto (Kahneman e Tversky), non un valore a piacere — cambiarli
# smetterebbe di rappresentare quella ricerca.
# These are the parameters experimentally estimated by prospect theory
# (Kahneman and Tversky), not an arbitrary value — changing them would stop
# representing that research.
importi = np.linspace(-10_000, 10_000, 400)
# np.where valuta entrambi i rami: si eleva a potenza il valore assoluto e si
# rimette il segno dopo, altrimenti numpy si lamenta delle radici di numeri
# negativi (e ha ragione).
grandezza = np.abs(importi) ** CURVATURA
valore = np.where(importi >= 0, grandezza, -AVVERSIONE * grandezza)
with avvio.figura("schermo"):
fig, ax = plt.subplots()
ax.plot(importi, valore, linewidth=2)
ax.axhline(0, linewidth=0.8, color="#8C8C8C")
ax.axvline(0, linewidth=0.8, color="#8C8C8C")
ax.set_xlabel("Guadagno o perdita (euro)")
ax.set_ylabel("Valore percepito (unità arbitrarie)")
plt.show()
for x in (2_000, 8_000):
su = x**CURVATURA
giu = AVVERSIONE * x**CURVATURA
print(f"{x:6,d} euro: piacere {su:9.1f} dolore {giu:9.1f} rapporto {giu / su:.2f}")
passo_vicino = AVVERSIONE * (2000**CURVATURA)
passo_lontano = AVVERSIONE * (10000**CURVATURA - 8000**CURVATURA)
print(f"\ndolore nel passare da 0 a -2.000: {passo_vicino:8.1f}")
print(f"dolore nel passare da -8.000 a -10.000: {passo_lontano:8.1f}")
print("È il motivo per cui, dopo una perdita gia' grande, rischiare ancora "
"costa pochissimo in termini di sofferenza attesa.")3. Can you tell the edge apart from noise?
Six series. Some have a real edge, others don't. Write down your answer before running the next cell.
Six panels titled «serie 1» to «serie 6», each with a capital curve rebased to 100 and a dotted line on the starting value. The vertical scales differ from one another: the first runs from 70 to 105, the second reaches 140, the fourth drops below 60. Two of the six have a real edge inside them and the other four do not, and the order is shuffled.
Show the script for this step
rng = np.random.default_rng(seed_for("lab-bias-indovina"))
# NON TOCCARE / DO NOT CHANGE: scrivi la tua risposta PRIMA di eseguire la
# cella successiva. Cambiare il seme dopo aver sbagliato per ottenere un
# disegno più facile vanificherebbe l'esercizio, non lo migliorerebbe.
# Write down your answer BEFORE running the next cell. Changing the seed
# after getting it wrong, to get an easier draw, would defeat the exercise,
# not improve it.
VANTAGGI = rng.permutation([0.0, 0.0, 0.0, 0.0005, 0.0005, 0.0])
with avvio.figura("schermo"):
fig, assi = plt.subplots(2, 3, figsize=(11, 5))
curve = []
for k, ax in enumerate(assi.flat):
c = equity_casuali(1, 400, rendimento_atteso=VANTAGGI[k],
volatilita_periodo=0.02, rng=rng)[0]
curve.append(c)
ax.plot(c * 100, linewidth=1.4)
ax.axhline(100, linestyle=":", linewidth=0.8)
ax.set_title(f"serie {k + 1}", fontsize=10)
ax.set_xticks([])
plt.show()Output
vantaggio reale per operazione: serie 1: 0.0000% → capitale finale 0.88x serie 2: 0.0500% → capitale finale 1.08x serie 3: 0.0000% → capitale finale 0.95x serie 4: 0.0000% → capitale finale 0.48x serie 5: 0.0000% → capitale finale 1.19x serie 6: 0.0500% → capitale finale 1.43x Se le due con vantaggio non sono quelle che avevi indicato, non e' un tuo limite: 400 osservazioni non bastano a distinguerle, e il capitolo sulla potenza statistica dice quante ne servirebbero.
Show the script for this step
print("vantaggio reale per operazione:")
for k, v in enumerate(VANTAGGI):
print(f" serie {k + 1}: {v:.4%} → capitale finale {curve[k][-1]:.2f}x")
print("\nSe le due con vantaggio non sono quelle che avevi indicato, non e' un "
"tuo limite: 400 osservazioni non bastano a distinguerle, e il capitolo "
"sulla potenza statistica dice quante ne servirebbero.")Exercises
- In the first cell set
PRENDI_UTILE = 0.05andSOPPORTA_PERDITA = 0.70: it's the extreme behaviour, and the cost grows accordingly. - Try
PRENDI_UTILE = 0.50andSOPPORTA_PERDITA = 0.10— the opposite of what almost everyone does. Watch what happens to the result and to the maximum drawdown: that isn't free either. - Redo the third cell's experiment changing
400to4000. With ten times the observations the distinction becomes possible. It's exactly the point of the chapter on statistical power.
Reproducibility & downloads
Run on 2026-08-27 from the repository notebook
The notebook
lab_04_bias.ipynb15.8 KB
sha256 ff5ff213ddd9e016fa2d3f454055be78f7adba9fdd3f8ef1914d89fe23d61c70
lab_04_bias.py12.3 KB
sha256 a154812752bac28498310133090ce0cf5e70fc067904e06d2cf7b9984bf6b373
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