Lab from the book · L17
L17 — The two coordinates of a move, and volume put to the test
Lab 17 — The two coordinates of a move
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 "Price and time". A market move is described by two numbers: how much the price moved and how long it took. Volume is the third column every platform shows. The chapter's question is whether it's a third coordinate or a consequence of the first two plus the calendar. Here you redo the measurement on the asset of your choice, and above all you can try to break it: change the threshold, change the window, change the market. Nothing in here is trading advice. The segmentation recognizes an extreme after the reversal: it describes finished moves, it does not announce one beginning.
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.ciclica import (
decomposizione,
effetto_scadenza,
movimenti,
r_quadro,
tavolo,
)
from cvbook.dati import carica
SERIE = "btcusdt" # ← PROVA / TRY: una delle 8 preparate nel setup qui sopra
# (btcusdt · ethusdt · solusdt · ftsemib · eni · enel · intesa · generali)
SOGLIA = 0.05 # ← quanto deve rientrare il prezzo perché un estremo sia definitivo
# PROVA / TRY: 0,02 · 0,05 · 0,15 (vedi esercizio 1)
CRIPTO = SERIE.endswith("usdt")
df = carica(SERIE).sort("data")
prezzi = df["chiusura"].to_numpy()
volumi = df["volume"].to_numpy()1. Where the extremes are
One single rule, declared upfront: an extreme becomes final when price has moved SOGLIA away in the opposite direction.
Output
342 movimenti su 3240 barre
The price of btcusdt from January to July 2026, between 60,000 and 95,000, with the zigzag of the fourteen most recent swings recognised at a 5% threshold drawn on top: every vertex is an extreme that became final only once the price had moved 5% the other way. Over the whole series the recognised swings number 342 out of 3,240 bars.
Show the script for this step
tratti = movimenti(prezzi, SOGLIA)
estremi = sorted({i for coppia in tratti for i in coppia})
print(f"{len(tratti)} movimenti su {len(prezzi)} barre")
with avvio.figura():
fig, ax = plt.subplots(figsize=(9, 4))
fetta = slice(estremi[-14], len(prezzi))
ax.plot(df["data"].to_numpy()[fetta], prezzi[fetta], linewidth=0.9)
dentro = [i for i in estremi if i >= estremi[-14]]
ax.plot(df["data"].to_numpy()[dentro], prezzi[dentro], marker="o", linewidth=1.4)
ax.set_title(f"{SERIE}: gli ultimi movimenti riconosciuti a soglia {SOGLIA:.0%}")
plt.show()2. How much price, time and volume explain
The target is the size of the move. The three blocks are measured on the same window and with the same treatment: that's what makes the comparison fair. The split is Shapley's, i.e. the average contribution over every possible insertion order — with correlated variables, "how much this explains" depends on the order, and the average is the only answer that doesn't pick one arbitrarily.
Output
movimenti misurati 256 velocità (quota Shapley) 21.8% tempo (quota Shapley) 40.1% volume (quota Shapley) 4.6% tutte e tre insieme 66.5% solo velocità e tempo 63.7% il volume aggiunge +2.8% di R quadro
Show the script for this step
t = tavolo(prezzi, volumi, SOGLIA)
d = decomposizione(t)
print(f"movimenti misurati {d['movimenti']:.0f}")
print(f"velocità (quota Shapley) {d['velocita']:.1%}")
print(f"tempo (quota Shapley) {d['tempo']:.1%}")
print(f"volume (quota Shapley) {d['volume']:.1%}")
print(f"tutte e tre insieme {d['totale']:.1%}")
print(f"solo velocità e tempo {d['velocita_e_tempo']:.1%}")
print(f"il volume aggiunge {d['guadagno_volume']:+.1%} di R quadro")3. First exercise: try to break the result
The zigzag threshold is a parameter, and a parameter is always suspect — see the chapter on optimizing. Vary it and see whether the conclusion moves. If it did, the chapter would need rewriting.
Output
soglia movimenti velocità tempo volume
2% 423 55.7% 32.8% 11.5%
3% 353 44.4% 46.1% 9.5%
5% 256 32.8% 60.2% 7.0%
8% 171 22.4% 73.8% 3.8%
10% 129 17.1% 78.8% 4.1%
15% 68 10.8% 87.0% 2.2%Show the script for this step
print(f"{'soglia':>7s} {'movimenti':>10s} {'velocità':>8s} {'tempo':>8s} {'volume':>8s}")
for s in (0.02, 0.03, 0.05, 0.08, 0.10, 0.15):
ts = tavolo(prezzi, volumi, s)
if len(ts) < 40:
print(f"{s:7.0%} {len(ts):10d} (troppo pochi movimenti)")
continue
ds = decomposizione(ts)
quota = ds["velocita"] + ds["tempo"] + ds["volume"]
print(f"{s:7.0%} {ds['movimenti']:10.0f} {ds['velocita'] / quota:8.1%} "
f"{ds['tempo'] / quota:8.1%} {ds['volume'] / quota:8.1%}")4. Second exercise: remove the link and watch it vanish
Shuffle the volume column across moves. Volume stays the same set of numbers, but no longer belongs to the move it sits next to. If its share were noise, almost nothing would change. Watching a structure disappear when you destroy it on purpose is the most direct way to convince yourself it was there.
Output
il volume vero aggiunge +2.77% un volume rimescolato +0.14% in media, +0.57% nel 5% dei casi migliori
Show the script for this step
rng = np.random.default_rng(0)
y = np.log(t.ampiezza)
velocita, tempo = np.log(t.velocita), np.log(t.durata)
volume = np.log(t.volume)
vero = r_quadro(y, [velocita, tempo, volume]) - r_quadro(y, [velocita, tempo])
finti = [
r_quadro(y, [velocita, tempo, rng.permutation(volume)]) - r_quadro(y, [velocita, tempo])
for _ in range(500)
]
print(f"il volume vero aggiunge {vero:+.2%}")
print(f"un volume rimescolato {np.mean(finti):+.2%} in media, "
f"{np.percentile(finti, 95):+.2%} nel 5% dei casi migliori")5. Where volume comes from: the calendar
Derivatives don't expire whenever. On Borsa Italiana's IDEM, indices and stocks expire the third Friday of the month; on crypto futures and options, the monthly expiry is the last Friday. These are public dates, known years in advance, that say nothing about where price will go.
Output
btcusdt: 105 giorni di scadenza (ultimo venerdì) volume mediano in scadenza 1.143 volume mediano negli altri 0.994 eccesso +4.9%
Show the script for this step
e = effetto_scadenza(df["data"].to_list(), volumi, cripto=CRIPTO)
quale = "ultimo venerdì" if CRIPTO else "terzo venerdì"
print(f"{SERIE}: {e['scadenze']} giorni di scadenza ({quale})")
print(f"volume mediano in scadenza {e['mediana_scadenza']:.3f}")
print(f"volume mediano negli altri {e['mediana_normale']:.3f}")
print(f"eccesso {e['eccesso']:+.1%}")6. Third exercise: the placebo test on the expiry
Shift the expiry date by one or two weeks. Staying on a Friday, the comparison doesn't change nature — only the fact that that Friday wasn't an expiry. If the volume excess really comes from the expiry, it must vanish on the fake dates. If it stayed, we'd be measuring something else. On ENI the jump is clear: the real date sits at +39%, the fake ones between −15% and +3%. On Bitcoin the real date sits at +5% and the fake ones swing between −5% and +3%: i.e. the +5% is within the noise of the wrong dates, and the honest conclusion is that there the expiry effect isn't visible.
Output
scadenza -14 giorni: eccesso -0.1% scadenza -7 giorni: eccesso +3.0% scadenza vera: eccesso +4.9% scadenza +7 giorni: eccesso -5.0% scadenza +14 giorni: eccesso +0.7%
Show the script for this step
import datetime as dt
date = df["data"].to_list()
for spostamento in (-14, -7, 0, 7, 14): # PROVA / TRY: aggiungi altri spostamenti (esercizio 3)
finte = [g + dt.timedelta(days=spostamento) for g in date]
e2 = effetto_scadenza(finte, volumi, cripto=CRIPTO)
etichetta = "vera" if spostamento == 0 else f"{spostamento:+d} giorni"
print(f"scadenza {etichetta:>10s}: eccesso {e2['eccesso']:+7.1%}")Takeaways
- A move has two coordinates, and the second — time — usually weighs more than the first.
- Volume had the exact same conditions as the other two columns and didn't make the cut. Not because it's useless: because what it says, price and time already said.
- Most of what's left of volume is calendar. The calendar is a form of time, not a third dimension.
Reproducibility & downloads
Run on 2026-08-27 from the repository notebook
The notebook
lab_17_prezzo_e_tempo.ipynb15.4 KB
sha256 1aaec719c1a1993ffdfbd9761a5810d9b3e6f7400d38254e9705d502631273fd
lab_17_prezzo_e_tempo.py11.8 KB
sha256 b6095d09a47804279ceb87126fe4d649b42a0f5627a2846b2588697b16667011
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
ethusdt.parquet87.0 KB
sha256 c2bd0259da905e0fec87235d7a62295532433fb89657726dd2d19558db7c072a
Source: Binance Data Vision · Period: 2017-08-17 → 2026-06-30 · 3,240 rows · extracted 2026-08-16
solusdt.parquet57.5 KB
sha256 c7ba2368a3e419b898fb31ec6d5345b7212b74784b69079d3d43571c2ac63657
Source: Binance Data Vision · Period: 2020-08-11 → 2026-06-30 · 2,150 rows · extracted 2026-08-16
ftsemib.parquet107.0 KB
sha256 ad37e8ac1ea979dc9ecf9f2dff58151166559945549ddb3f692066b1e3b85a78
Source: Yahoo Finance · Period: 2000-01-03 → 2026-06-30 · 6,757 rows · extracted 2026-08-17
eni.parquet212.1 KB
sha256 6db634e458915b7007412eb67ca098218fab6d7214b9f07b4ee4a6f46f060fca
Source: Yahoo Finance · Period: 2000-01-03 → 2026-06-30 · 6,767 rows · extracted 2026-08-17
enel.parquet211.8 KB
sha256 f96245c6b593edccaf8c26dfa325ca45efebe9e5b9a69c6cd691a936f01fde93
Source: Yahoo Finance · Period: 2000-01-03 → 2026-06-30 · 6,767 rows · extracted 2026-08-17
intesa.parquet213.8 KB
sha256 25b40b095ee7f72878b7fab98a709d6c1aca178b5b7351e8ac87292fb8da76f0
Source: Yahoo Finance · Period: 2000-01-03 → 2026-06-30 · 6,767 rows · extracted 2026-08-17
generali.parquet210.7 KB
sha256 7e14bf4937ce324e370ac9d216bfacb30604d4e0e87f79c4978ec7376daeea6a
Source: Yahoo Finance · Period: 2000-01-03 → 2026-06-30 · 6,767 rows · extracted 2026-08-17