Skip to content
SIAT PAPER 2026Open the research page
Cryptoverso

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
lab_17_prezzo_e_tempo.py
python
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.

The extremes of a move recognised by a rule declared before looking.Source: Binance Data Vision · Yahoo Finance · Period: 2000-01-03 … 2026-06-30 · Method: An extreme becomes final once the price has moved 5% in the opposite direction; the zigzag joins the extremes recognised that way.
Show the script for this step
lab_17_prezzo_e_tempo.py
python
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
lab_17_prezzo_e_tempo.py
python
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
lab_17_prezzo_e_tempo.py
python
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
lab_17_prezzo_e_tempo.py
python
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
lab_17_prezzo_e_tempo.py
python
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
lab_17_prezzo_e_tempo.py
python
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

  1. A move has two coordinates, and the second — time — usually weighs more than the first.
  2. 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.
  3. 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

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

Back to the lab index